From 535bb2d7b83344152c2d3458dafb848308f3f1ea Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 24 Sep 2026 14:05:41 -0700 Subject: [PATCH] RG-T55 Workflows and RMS UDF Fixes, Shift fixes --- .../Handlers/CallDetailActionHandler.cs | 9 +- .../Handlers/CallDispatchedActionHandler.cs | 13 +- .../Handlers/CallRespondersActionHandler.cs | 19 +- .../Handlers/CallsActionHandler.cs | 10 +- .../Handlers/RespondToCallHandler.cs | 9 +- .../Services/CallReferenceResolver.cs | 38 +- .../Services/IncidentContextResolver.cs | 8 +- Core/Resgrid.Config/DataProtectionConfig.cs | 43 +- .../Account/Login.ar.resx | 2 +- .../Account/Login.de.resx | 2 +- .../Account/Login.el.resx | 2 +- .../Account/Login.en.resx | 2 +- .../Account/Login.es.resx | 2 +- .../Account/Login.fr.resx | 2 +- .../Account/Login.it.resx | 2 +- .../Account/Login.pl.resx | 2 +- .../Account/Login.sv.resx | 2 +- .../Account/Login.uk.resx | 2 +- .../Areas/User/Department/Department.ar.resx | 18 + .../Areas/User/Department/Department.de.resx | 18 + .../Areas/User/Department/Department.el.resx | 18 + .../Areas/User/Department/Department.en.resx | 18 + .../Areas/User/Department/Department.es.resx | 18 + .../Areas/User/Department/Department.fr.resx | 18 + .../Areas/User/Department/Department.it.resx | 18 + .../Areas/User/Department/Department.pl.resx | 18 + .../Areas/User/Department/Department.resx | 18 + .../Areas/User/Department/Department.sv.resx | 18 + .../Areas/User/Department/Department.uk.resx | 18 + .../Areas/User/Dispatch/Call.ar.resx | 78 + .../Areas/User/Dispatch/Call.de.resx | 78 + .../Areas/User/Dispatch/Call.el.resx | 78 + .../Areas/User/Dispatch/Call.en.resx | 78 + .../Areas/User/Dispatch/Call.es.resx | 78 + .../Areas/User/Dispatch/Call.fr.resx | 78 + .../Areas/User/Dispatch/Call.it.resx | 78 + .../Areas/User/Dispatch/Call.pl.resx | 78 + .../Areas/User/Dispatch/Call.sv.resx | 78 + .../Areas/User/Dispatch/Call.uk.resx | 78 + .../Areas/User/Groups/Groups.ar.resx | 8 +- .../Areas/User/Groups/Groups.de.resx | 8 +- .../Areas/User/Groups/Groups.el.resx | 8 +- .../Areas/User/Groups/Groups.en.resx | 8 +- .../Areas/User/Groups/Groups.es.resx | 8 +- .../Areas/User/Groups/Groups.fr.resx | 8 +- .../Areas/User/Groups/Groups.it.resx | 8 +- .../Areas/User/Groups/Groups.pl.resx | 8 +- .../Areas/User/Groups/Groups.sv.resx | 8 +- .../Areas/User/Groups/Groups.uk.resx | 8 +- .../ProtectedWorkflows.ar.resx | 862 ++++++ .../ProtectedWorkflows/ProtectedWorkflows.cs | 46 + .../ProtectedWorkflows.de.resx | 862 ++++++ .../ProtectedWorkflows.el.resx | 862 ++++++ .../ProtectedWorkflows.en.resx | 862 ++++++ .../ProtectedWorkflows.es.resx | 862 ++++++ .../ProtectedWorkflows.fr.resx | 862 ++++++ .../ProtectedWorkflows.it.resx | 862 ++++++ .../ProtectedWorkflows.pl.resx | 862 ++++++ .../ProtectedWorkflows.sv.resx | 862 ++++++ .../ProtectedWorkflows.uk.resx | 862 ++++++ .../Areas/User/Shifts/Shifts.ar.resx | 107 + .../Areas/User/Shifts/Shifts.de.resx | 321 ++ .../Areas/User/Shifts/Shifts.el.resx | 321 ++ .../Areas/User/Shifts/Shifts.en.resx | 321 ++ .../Areas/User/Shifts/Shifts.es.resx | 321 ++ .../Areas/User/Shifts/Shifts.fr.resx | 321 ++ .../Areas/User/Shifts/Shifts.it.resx | 321 ++ .../Areas/User/Shifts/Shifts.pl.resx | 321 ++ .../Areas/User/Shifts/Shifts.sv.resx | 321 ++ .../Areas/User/Shifts/Shifts.uk.resx | 321 ++ .../UserDefinedFields.ar.resx | 15 + .../UserDefinedFields.de.resx | 15 + .../UserDefinedFields.el.resx | 15 + .../UserDefinedFields.en.resx | 15 + .../UserDefinedFields.es.resx | 15 + .../UserDefinedFields.fr.resx | 15 + .../UserDefinedFields.it.resx | 15 + .../UserDefinedFields.pl.resx | 15 + .../UserDefinedFields/UserDefinedFields.resx | 15 + .../UserDefinedFields.sv.resx | 15 + .../UserDefinedFields.uk.resx | 15 + .../Areas/User/Workflows/Workflows.ar.resx | 113 +- .../Areas/User/Workflows/Workflows.de.resx | 113 +- .../Areas/User/Workflows/Workflows.el.resx | 113 +- .../Areas/User/Workflows/Workflows.en.resx | 113 +- .../Areas/User/Workflows/Workflows.es.resx | 113 +- .../Areas/User/Workflows/Workflows.fr.resx | 113 +- .../Areas/User/Workflows/Workflows.it.resx | 113 +- .../Areas/User/Workflows/Workflows.pl.resx | 113 +- .../Areas/User/Workflows/Workflows.resx | 111 +- .../Areas/User/Workflows/Workflows.sv.resx | 113 +- .../Areas/User/Workflows/Workflows.uk.resx | 113 +- Core/Resgrid.Model/Call.cs | 15 + Core/Resgrid.Model/CallSubjectIdentifiers.cs | 93 + .../DepartmentProtectedDataEgressPolicy.cs | 41 +- Core/Resgrid.Model/DepartmentSettingTypes.cs | 6 + Core/Resgrid.Model/DispatchScope.cs | 66 + .../Events/ShiftRosterChangedEvent.cs | 27 + .../Resgrid.Model/GroupDispatchScopeConfig.cs | 38 + .../Helpers/DepartmentGroupHierarchy.cs | 99 + Core/Resgrid.Model/Helpers/ShiftTimeWindow.cs | 72 + .../Helpers/UdfValidationHelper.cs | 202 +- Core/Resgrid.Model/NearestUnitBoard.cs | 218 ++ Core/Resgrid.Model/OnShiftAssignment.cs | 21 + Core/Resgrid.Model/ProcessLogTypes.cs | 5 +- .../ProtectedPayloadValidator.cs | 177 ++ .../ProtectedReleaseState.cs | 31 + .../ProtectedStepOptions.cs | 275 ++ .../ProtectedWorkflowConstants.cs | 224 ++ .../ProtectedWorkflowDisclosure.cs | 126 + .../ProtectedWorkflowDisclosureChain.cs | 110 + .../ProtectedWorkflowFieldCatalog.cs | 183 ++ .../ProtectedWorkflowFingerprint.cs | 133 + .../ProtectedWorkflowModels.cs | 245 ++ .../ProtectedWorkflowValidator.cs | 294 ++ .../ProtectedWorkflows/WorkflowJwtKeys.cs | 249 ++ .../WorkflowProtectedRelease.cs | 181 ++ .../Providers/WorkflowActionContext.cs | 30 + .../Providers/WorkflowActionResult.cs | 23 + Core/Resgrid.Model/Queue/ShiftQueueItem.cs | 11 +- .../Repositories/ICallsRepository.cs | 7 + .../IProtectedWorkflowRepositories.cs | 44 + .../Repositories/IShiftSignupRepository.cs | 6 + .../IShiftSignupTradeRepository.cs | 10 +- .../IShiftSignupTradeUserShiftsRepository.cs | 9 +- .../Repositories/IWorkflowRepository.cs | 1 - .../Services/IAuthorizationService.cs | 7 + .../Services/IDepartmentGroupsService.cs | 6 + .../Services/IDepartmentSettingsService.cs | 5 + .../Services/IDispatchScopeService.cs | 40 + Core/Resgrid.Model/Services/IGeoService.cs | 7 + .../Services/INearestUnitService.cs | 16 + .../Services/IProtectedWorkflowService.cs | 195 ++ Core/Resgrid.Model/Services/IShiftsService.cs | 131 + .../Services/IUdfRenderingService.cs | 7 + .../Services/IWorkflowService.cs | 16 +- Core/Resgrid.Model/ShiftActionResult.cs | 41 + Core/Resgrid.Model/ShiftDay.cs | 29 +- Core/Resgrid.Model/ShiftDayRosterEntry.cs | 50 + Core/Resgrid.Model/ShiftDaySchedule.cs | 41 + Core/Resgrid.Model/ShiftManagementScope.cs | 66 + Core/Resgrid.Model/ShiftSignup.cs | 26 + Core/Resgrid.Model/ShiftSignupTrade.cs | 54 +- Core/Resgrid.Model/ShiftSignupTradeStates.cs | 4 +- Core/Resgrid.Model/UdfField.cs | 7 + Core/Resgrid.Model/UdfFieldDataType.cs | 11 +- Core/Resgrid.Model/UdfFieldSensitivity.cs | 18 + Core/Resgrid.Model/WorkflowCredential.cs | 6 + Core/Resgrid.Model/WorkflowCredentialType.cs | 9 +- .../WorkflowTemplateVariableCatalog.cs | 13 +- Core/Resgrid.Services/AdpTableBindings.cs | 2 +- Core/Resgrid.Services/AuthorizationService.cs | 82 +- .../CallDispatchStatusService.cs | 30 +- .../DepartmentGroupsService.cs | 32 + .../DepartmentSettingsService.cs | 52 + .../DispatchRecommendationService.cs | 20 +- Core/Resgrid.Services/DispatchScopeService.cs | 209 ++ Core/Resgrid.Services/GeoService.cs | 18 +- Core/Resgrid.Services/NearestUnitService.cs | 678 +++++ .../Resgrid.Services/ProtectedFieldCatalog.cs | 14 + Core/Resgrid.Services/ProtectedReadService.cs | 3 +- .../ProtectedWorkflowRuntime.cs | 544 ++++ .../ProtectedWorkflowService.cs | 1350 +++++++++ .../Records/RecordsUdfService.cs | 3 +- Core/Resgrid.Services/ServicesModule.cs | 7 + Core/Resgrid.Services/ShiftRosterBuilder.cs | 233 ++ .../ShiftsService.Scheduling.cs | 936 ++++++ Core/Resgrid.Services/ShiftsService.cs | 418 +-- Core/Resgrid.Services/UdfRenderingService.cs | 54 +- .../UserDefinedFieldsService.cs | 30 +- .../WorkflowSampleDataGenerator.cs | 31 + .../WorkflowService.ProtectedRelease.cs | 631 ++++ Core/Resgrid.Services/WorkflowService.cs | 231 +- .../WorkflowTemplateContextBuilder.cs | 8 +- .../WorkflowTemplateFunctions.cs | 174 ++ .../WorkflowTemplateGallery.cs | 243 ++ .../OutboundEventProvider.cs | 19 + .../Migrations/M0232_AddProtectedWorkflows.cs | 155 + .../M0233_AddProtectedWorkflowsEhr.cs | 47 + .../Migrations/M0234_AddShiftApprovals.cs | 43 + .../M0232_AddProtectedWorkflowsPg.cs | 121 + .../M0233_AddProtectedWorkflowsEhrPg.cs | 30 + .../Migrations/M0234_AddShiftApprovalsPg.cs | 39 + .../Executors/HttpApiExecutor.cs | 535 +++- .../Executors/ProtectedResponseRules.cs | 315 ++ .../Resgrid.Providers.Workflow.csproj | 4 + .../CallsRepository.SubjectIdentifiers.cs | 32 + .../Configs/SqlConfiguration.cs | 3 + .../Modules/DataModule.cs | 3 + .../Modules/TestingDataModule.cs | 3 + .../ProtectedWorkflowDisclosureRepository.cs | 147 + ...electOpenShiftSignupTradesByUserIdQuery.cs | 6 +- ...iftSignupTradeUserShiftsBySignupIdQuery.cs | 33 + ...ectShiftSignupTradesByDepartmentIdQuery.cs | 35 + ...tSignupsByDepartmentIdAndDateRangeQuery.cs | 35 + .../SelectWorkflowByDeptAndEventTypeQuery.cs | 28 - .../PostgreSql/PostgreSqlConfiguration.cs | 57 +- .../SqlServer/SqlServerConfiguration.cs | 27 +- .../ShiftSignupRepository.cs | 43 + .../ShiftSignupTradeRepository.cs | 61 +- .../ShiftSignupTradeUserRepository.cs | 2 +- .../ShiftSignupTradeUserShiftsRepository.cs | 50 +- .../WorkflowProtectedReleaseRepository.cs | 118 + .../WorkflowRepository.cs | 35 - Tests/Resgrid.Tests/Bootstrapper.cs | 6 + .../CallRespondersActionHandlerTests.cs | 6 +- .../Chatbot/ChatbotHandlerTests.cs | 96 +- .../Chatbot/ChatbotSecurityTests.cs | 5 +- .../ExternalChatbotAuthorizationTests.cs | 36 +- .../Helpers/DispatchScopeMocks.cs | 26 + .../TranslationCompletenessTests.cs | 28 + .../Providers/ProtectedExecutorEhrTests.cs | 324 +++ .../ProtectedHttpApiExecutorTests.cs | 223 ++ .../Repositories/ShiftQueryTests.cs | 70 + Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs | 2 +- .../Services/AuthorizationServiceTests.cs | 10 +- .../Services/CalendarServiceCheckInTests.cs | 3 +- .../CallDispatchStatusServiceTests.cs | 16 +- .../Services/DepartmentGroupHierarchyTests.cs | 102 + .../Services/DispatchScopeServiceTests.cs | 306 ++ .../Services/NearestUnitServiceTests.cs | 387 +++ .../ProtectedWorkflowCompositionTests.cs | 37 + .../ProtectedWorkflowEhrTests.cs | 643 ++++ .../ProtectedWorkflowHarness.cs | 697 +++++ .../ProtectedWorkflowLifecycleTests.cs | 632 ++++ .../ProtectedWorkflowModelTests.cs | 319 ++ .../ProtectedWorkflowRuntimeTests.cs | 606 ++++ .../WorkflowJwtKeysTests.cs | 233 ++ .../WorkflowTemplateFunctionsTests.cs | 201 ++ .../ShiftManagementScopeAuthorizationTests.cs | 118 + .../Services/ShiftRosterBuilderTests.cs | 389 +++ .../Services/ShiftTimeWindowTests.cs | 85 + .../Services/ShiftsServiceSchedulingTests.cs | 401 +++ .../Services/UdfRenderingServiceTests.cs | 225 ++ .../Services/UdfValidationHelperTests.cs | 273 ++ .../Services/UserDefinedFieldsServiceTests.cs | 79 + .../WorkflowTemplateVariableCatalogTests.cs | 13 +- .../WorkforceProtectionAndEventsTests.cs | 3 +- .../Web/Mcp/McpRouteConformanceTests.cs | 190 ++ .../Web/Mcp/McpServerToolResultTests.cs | 40 + .../Web/Services/CallsControllerTests.cs | 14 +- .../Services/ProtectedWorkflowsApiTests.cs | 198 ++ .../TwilioControllerVoiceVerificationTests.cs | 29 +- .../User/EnableMemberPersonnelLimitTests.cs | 2 +- .../Web/User/HydrantImportFormTests.cs | 1 + .../Web/User/PersonnelAddExistingUserTests.cs | 2 +- .../Web/User/PersonnelReactivationTests.cs | 2 +- .../Web/User/ShiftsControllerTests.cs | 701 +++++ .../Web/User/StatusDestinationScopeTests.cs | 207 ++ Web/Resgrid.Web.Mcp/ApiClient.cs | 2 +- .../Infrastructure/AuditLogger.cs | 3 +- .../ModelContextProtocol/McpServer.cs | 4 +- .../Tools/CalendarToolProvider.cs | 247 +- .../Tools/CallsToolProvider.cs | 99 +- .../Tools/DispatchToolProvider.cs | 113 +- .../Tools/InventoryToolProvider.cs | 8 +- .../Tools/MessagesToolProvider.cs | 20 +- .../Tools/PersonnelToolProvider.cs | 34 +- .../Tools/ReportsToolProvider.cs | 309 +- .../Tools/ShiftsToolProvider.cs | 31 +- .../Tools/UnitsToolProvider.cs | 17 +- Web/Resgrid.Web.Mcp/Tools/V4ResponseReader.cs | 76 + Web/Resgrid.Web.Mcp/V4Routes.cs | 67 + .../Controllers/TwilioController.cs | 21 +- .../Controllers/v4/CallsController.cs | 46 +- .../Controllers/v4/ContactsController.cs | 7 +- .../Controllers/v4/DispatchController.cs | 48 +- .../Controllers/v4/GroupsController.cs | 10 + .../Controllers/v4/MappingController.cs | 7 +- .../v4/PersonnelStatusesController.cs | 9 +- .../v4/ProtectedWorkflowsController.cs | 308 ++ .../Controllers/v4/ShiftsController.cs | 1153 +++++++- .../Controllers/v4/UnitStatusController.cs | 9 +- .../v4/UserDefinedFieldsController.cs | 38 +- .../v4/WorkflowCredentialKeysController.cs | 55 + .../Controllers/v4/WorkflowsController.cs | 10 +- .../Helpers/ProtectedWorkflowCallerHelper.cs | 42 + .../Models/v4/Calls/CallResult.cs | 9 + .../Models/v4/Calls/EditCallInput.cs | 13 + .../Models/v4/Calls/NewCallInput.cs | 13 + .../Models/v4/Contacts/ContactResult.cs | 2 + .../v4/Dispatch/GetNearestUnitsResult.cs | 17 + .../Models/v4/Groups/GroupResult.cs | 26 + .../Models/v4/Shifts/ShiftDaysResult.cs | 48 + .../Models/v4/Shifts/ShiftSchedulingModels.cs | 333 +++ .../Models/v4/Shifts/ShiftsResult.cs | 15 + .../Models/v4/Shifts/SignupShiftDayResult.cs | 6 + .../SaveUdfDefinitionInput.cs | 8 +- .../UserDefinedFields/UdfDefinitionResult.cs | 3 + .../v4/Workflows/ProtectedWorkflowModels.cs | 154 + .../Resgrid.Web.Services.xml | 1449 ++++++---- .../Controllers/DataProtectionController.cs | 8 +- .../User/Controllers/DepartmentController.cs | 16 + .../User/Controllers/DispatchController.cs | 42 +- .../User/Controllers/GroupsController.cs | 51 +- .../Areas/User/Controllers/HomeController.cs | 9 +- .../User/Controllers/MappingController.cs | 6 +- .../User/Controllers/PersonnelController.cs | 10 +- .../ProtectedWorkflowsController.cs | 304 ++ .../User/Controllers/ShiftsController.cs | 2574 +++++++++++------ .../Areas/User/Controllers/UnitsController.cs | 14 +- .../UserDefinedFieldsController.cs | 5 + .../User/Controllers/WorkflowsController.cs | 211 +- .../DataProtection/DataProtectionIndexView.cs | 6 + .../Departments/DispatchSettingsView.cs | 6 + .../Areas/User/Models/Groups/GeofenceView.cs | 3 + .../ProtectedWorkflowsViewModels.cs | 105 + .../Areas/User/Models/Shifts/ApprovalsView.cs | 47 + .../Areas/User/Models/Shifts/EditShiftView.cs | 13 +- .../User/Models/Shifts/FinishTradeView.cs | 28 +- .../Areas/User/Models/Shifts/NewShiftView.cs | 13 +- .../Areas/User/Models/Shifts/OnDutyView.cs | 45 + .../User/Models/Shifts/ProcessTradeView.cs | 12 +- .../User/Models/Shifts/RequestTradeView.cs | 5 +- .../Areas/User/Models/Shifts/ShiftDayView.cs | 138 + .../User/Models/Shifts/ShiftSignupView.cs | 5 +- .../User/Models/Shifts/ShiftStaffingView.cs | 7 +- .../User/Models/Shifts/ShiftsIndexModel.cs | 12 +- .../User/Models/Shifts/YourShiftsView.cs | 20 +- .../Models/UserDefinedFields/UdfModels.cs | 4 + .../User/Views/DataProtection/Index.cshtml | 85 + .../Views/Department/DispatchSettings.cshtml | 35 + .../Areas/User/Views/Dispatch/NewCall.cshtml | 52 + .../User/Views/Dispatch/UpdateCall.cshtml | 12 + .../Areas/User/Views/Dispatch/ViewCall.cshtml | 10 + .../Areas/User/Views/Groups/Geofence.cshtml | 15 +- .../Areas/User/Views/Groups/Index.cshtml | 6 +- .../ProtectedWorkflows/Disclosures.cshtml | 132 + .../Views/ProtectedWorkflows/Index.cshtml | 150 + .../Views/ProtectedWorkflows/_Messages.cshtml | 7 + .../ProtectedWorkflows/_ReleasePanel.cshtml | 412 +++ .../Areas/User/Views/Shifts/Approvals.cshtml | 175 ++ .../User/Views/Shifts/EditShiftDetails.cshtml | 28 +- .../User/Views/Shifts/EditShiftGroups.cshtml | 5 + .../User/Views/Shifts/FinishTrade.cshtml | 125 +- .../Areas/User/Views/Shifts/Index.cshtml | 33 +- .../Areas/User/Views/Shifts/NewShift.cshtml | 18 +- .../Areas/User/Views/Shifts/OnDuty.cshtml | 127 + .../User/Views/Shifts/ProcessTrade.cshtml | 62 +- .../User/Views/Shifts/RequestTrade.cshtml | 30 +- .../User/Views/Shifts/ShiftStaffing.cshtml | 40 +- .../Areas/User/Views/Shifts/Signup.cshtml | 237 -- .../User/Views/Shifts/SignupSuccess.cshtml | 20 +- .../Areas/User/Views/Shifts/ViewShift.cshtml | 528 ++-- .../Areas/User/Views/Shifts/YourShifts.cshtml | 189 +- .../User/Views/Shifts/_ShiftDayRoster.cshtml | 97 + .../User/Views/Shifts/_ShiftsMessage.cshtml | 17 + .../User/Views/UserDefinedFields/Edit.cshtml | 8 +- .../UserDefinedFields/_UdfFieldRow.cshtml | 20 + .../UserDefinedFields/_UdfPreview.cshtml | 19 + .../Views/Workflows/CredentialEdit.cshtml | 5 + .../User/Views/Workflows/CredentialNew.cshtml | 1 + .../Areas/User/Views/Workflows/Edit.cshtml | 167 +- .../Areas/User/Views/Workflows/Index.cshtml | 26 + .../Areas/User/Views/Workflows/New.cshtml | 68 +- .../Views/Workflows/_CredentialFields.cshtml | 86 + .../RequiresRecentTwoFactorAttribute.cs | 21 + .../Models/WorkflowCredentialViewModel.cs | 31 + .../dataprotection/resgrid.adp.reveal.js | 26 +- .../dispatch/resgrid.dispatch.newcall.js | 155 + .../resgrid.protectedworkflows.js | 88 + .../shifts/resgrid.shifts.calendar.js | 19 +- .../shifts/resgrid.shifts.editshiftdetails.js | 19 +- .../shifts/resgrid.shifts.editshiftgroups.js | 43 +- .../shifts/resgrid.shifts.newshift.js | 36 +- .../shifts/resgrid.shifts.processtrade.js | 21 +- .../shifts/resgrid.shifts.requesttrade.js | 7 +- .../shifts/resgrid.shifts.shiftStaffing.js | 22 +- .../Commands/ProtectedWorkflowSweepCommand.cs | 15 + Workers/Resgrid.Workers.Console/Program.cs | 8 + .../Tasks/ProtectedWorkflowSweepTask.cs | 22 + .../Tasks/ShiftNotiferTask.cs | 82 +- .../Logic/CallBroadcast.cs | 23 +- .../Logic/ProtectedWorkflowSweepLogic.cs | 36 + .../Logic/SecurityLogic.cs | 16 +- .../Logic/ShiftNotificationLogic.cs | 119 + .../Logic/ShiftNotifierLogic.cs | 15 +- .../ShiftNotifier/ShiftNotifierQueueItem.cs | 6 + 378 files changed, 43179 insertions(+), 3723 deletions(-) create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.cs create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.el.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.uk.resx create mode 100644 Core/Resgrid.Model/CallSubjectIdentifiers.cs create mode 100644 Core/Resgrid.Model/DispatchScope.cs create mode 100644 Core/Resgrid.Model/Events/ShiftRosterChangedEvent.cs create mode 100644 Core/Resgrid.Model/GroupDispatchScopeConfig.cs create mode 100644 Core/Resgrid.Model/Helpers/DepartmentGroupHierarchy.cs create mode 100644 Core/Resgrid.Model/Helpers/ShiftTimeWindow.cs create mode 100644 Core/Resgrid.Model/NearestUnitBoard.cs create mode 100644 Core/Resgrid.Model/OnShiftAssignment.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/ProtectedPayloadValidator.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/ProtectedReleaseState.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/ProtectedStepOptions.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowConstants.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowDisclosure.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowDisclosureChain.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowFieldCatalog.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowFingerprint.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowModels.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowValidator.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/WorkflowJwtKeys.cs create mode 100644 Core/Resgrid.Model/ProtectedWorkflows/WorkflowProtectedRelease.cs create mode 100644 Core/Resgrid.Model/Repositories/IProtectedWorkflowRepositories.cs create mode 100644 Core/Resgrid.Model/Services/IDispatchScopeService.cs create mode 100644 Core/Resgrid.Model/Services/INearestUnitService.cs create mode 100644 Core/Resgrid.Model/Services/IProtectedWorkflowService.cs create mode 100644 Core/Resgrid.Model/ShiftActionResult.cs create mode 100644 Core/Resgrid.Model/ShiftDayRosterEntry.cs create mode 100644 Core/Resgrid.Model/ShiftDaySchedule.cs create mode 100644 Core/Resgrid.Model/ShiftManagementScope.cs create mode 100644 Core/Resgrid.Model/UdfFieldSensitivity.cs create mode 100644 Core/Resgrid.Services/DispatchScopeService.cs create mode 100644 Core/Resgrid.Services/NearestUnitService.cs create mode 100644 Core/Resgrid.Services/ProtectedWorkflowRuntime.cs create mode 100644 Core/Resgrid.Services/ProtectedWorkflowService.cs create mode 100644 Core/Resgrid.Services/ShiftRosterBuilder.cs create mode 100644 Core/Resgrid.Services/ShiftsService.Scheduling.cs create mode 100644 Core/Resgrid.Services/WorkflowService.ProtectedRelease.cs create mode 100644 Core/Resgrid.Services/WorkflowTemplateFunctions.cs create mode 100644 Core/Resgrid.Services/WorkflowTemplateGallery.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0232_AddProtectedWorkflows.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0233_AddProtectedWorkflowsEhr.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0234_AddShiftApprovals.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0232_AddProtectedWorkflowsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0233_AddProtectedWorkflowsEhrPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0234_AddShiftApprovalsPg.cs create mode 100644 Providers/Resgrid.Providers.Workflow/Executors/ProtectedResponseRules.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/CallsRepository.SubjectIdentifiers.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/ProtectedWorkflowDisclosureRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupTradeUserShiftsBySignupIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupTradesByDepartmentIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupsByDepartmentIdAndDateRangeQuery.cs delete mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/SelectWorkflowByDeptAndEventTypeQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/WorkflowProtectedReleaseRepository.cs create mode 100644 Tests/Resgrid.Tests/Helpers/DispatchScopeMocks.cs create mode 100644 Tests/Resgrid.Tests/Providers/ProtectedExecutorEhrTests.cs create mode 100644 Tests/Resgrid.Tests/Providers/ProtectedHttpApiExecutorTests.cs create mode 100644 Tests/Resgrid.Tests/Repositories/ShiftQueryTests.cs create mode 100644 Tests/Resgrid.Tests/Services/DepartmentGroupHierarchyTests.cs create mode 100644 Tests/Resgrid.Tests/Services/DispatchScopeServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowCompositionTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowEhrTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowHarness.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowLifecycleTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowModelTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowRuntimeTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowJwtKeysTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowTemplateFunctionsTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ShiftManagementScopeAuthorizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ShiftRosterBuilderTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ShiftTimeWindowTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ShiftsServiceSchedulingTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Mcp/McpRouteConformanceTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Mcp/McpServerToolResultTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/ProtectedWorkflowsApiTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/ShiftsControllerTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/StatusDestinationScopeTests.cs create mode 100644 Web/Resgrid.Web.Mcp/Tools/V4ResponseReader.cs create mode 100644 Web/Resgrid.Web.Mcp/V4Routes.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/ProtectedWorkflowsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/WorkflowCredentialKeysController.cs create mode 100644 Web/Resgrid.Web.Services/Helpers/ProtectedWorkflowCallerHelper.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Dispatch/GetNearestUnitsResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Shifts/ShiftSchedulingModels.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Workflows/ProtectedWorkflowModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/ProtectedWorkflowsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/ProtectedWorkflows/ProtectedWorkflowsViewModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Shifts/ApprovalsView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Shifts/OnDutyView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Shifts/ShiftDayView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/ProtectedWorkflows/Disclosures.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/ProtectedWorkflows/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/ProtectedWorkflows/_Messages.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/ProtectedWorkflows/_ReleasePanel.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shifts/Approvals.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shifts/OnDuty.cshtml delete mode 100644 Web/Resgrid.Web/Areas/User/Views/Shifts/Signup.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shifts/_ShiftDayRoster.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shifts/_ShiftsMessage.cshtml create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/protectedworkflows/resgrid.protectedworkflows.js create mode 100644 Workers/Resgrid.Workers.Console/Commands/ProtectedWorkflowSweepCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/ProtectedWorkflowSweepTask.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/ProtectedWorkflowSweepLogic.cs diff --git a/Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs index 4496ec575..861747190 100644 --- a/Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs @@ -15,9 +15,12 @@ public class CallDetailActionHandler : IChatbotActionHandler private readonly ICallsService _callsService; private readonly IDepartmentsService _departmentsService; private readonly IAuthorizationService _authorizationService; + private readonly IDispatchScopeService _dispatchScopeService; - public CallDetailActionHandler(ICallsService callsService, IDepartmentsService departmentsService, IAuthorizationService authorizationService) + public CallDetailActionHandler(ICallsService callsService, IDepartmentsService departmentsService, IAuthorizationService authorizationService, + IDispatchScopeService dispatchScopeService) { + _dispatchScopeService = dispatchScopeService; _callsService = callsService; _departmentsService = departmentsService; _authorizationService = authorizationService; @@ -42,7 +45,9 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn return new ChatbotResponse { Text = ChatbotResources.Get("CallDetail_Specify", culture), Processed = false }; } - var call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference); + // Scoped so shorthand matches the user's own area rather than a call they'd be refused below. + var call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference, + _dispatchScopeService, session.UserId); if (call == null) { return new ChatbotResponse { Text = ChatbotResources.Get("Call_NoMatch", culture, reference), Processed = true }; diff --git a/Core/Resgrid.Chatbot/Handlers/CallDispatchedActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/CallDispatchedActionHandler.cs index 322b99147..8603f821a 100644 --- a/Core/Resgrid.Chatbot/Handlers/CallDispatchedActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/CallDispatchedActionHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -22,12 +23,15 @@ public class CallDispatchedActionHandler : IChatbotActionHandler private readonly ICallsService _callsService; private readonly IUserProfileService _userProfileService; private readonly IAuthorizationService _authorizationService; + private readonly IDispatchScopeService _dispatchScopeService; public CallDispatchedActionHandler( ICallsService callsService, IUserProfileService userProfileService, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IDispatchScopeService dispatchScopeService) { + _dispatchScopeService = dispatchScopeService; _callsService = callsService; _userProfileService = userProfileService; _authorizationService = authorizationService; @@ -47,13 +51,16 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn Call call; if (!string.IsNullOrWhiteSpace(reference)) { - call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference); + call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference, + _dispatchScopeService, session.UserId); if (call == null) return new ChatbotResponse { Text = ChatbotResources.Get("Call_NoMatch", culture, reference), Processed = true }; } else { - var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(session.DepartmentId); + // "The" call means the only one in the user's area, not the only one in the department. + var activeCalls = await _dispatchScopeService.FilterCallsForUserAsync(session.DepartmentId, session.UserId, + await _callsService.GetActiveCallsByDepartmentAsync(session.DepartmentId) ?? new List()); if (activeCalls?.Count == 1) call = activeCalls[0]; else diff --git a/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs index e94d7e8b7..1ceb8f40b 100644 --- a/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs @@ -39,6 +39,7 @@ private enum ResponderBucket private readonly ICustomStateService _customStateService; private readonly IUserProfileService _userProfileService; private readonly IAuthorizationService _authorizationService; + private readonly IDispatchScopeService _dispatchScopeService; public CallRespondersActionHandler( ICallsService callsService, @@ -46,8 +47,10 @@ public CallRespondersActionHandler( IUnitsService unitsService, ICustomStateService customStateService, IUserProfileService userProfileService, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IDispatchScopeService dispatchScopeService) { + _dispatchScopeService = dispatchScopeService; _callsService = callsService; _actionLogsService = actionLogsService; _unitsService = unitsService; @@ -66,7 +69,7 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn intent.Parameters.TryGetValue("mode", out var modeValue); var mode = ParseResponderMode(modeValue); - var call = await ResolveCallAsync(intent, session.DepartmentId); + var call = await ResolveCallAsync(intent, session); if (call == null) return new ChatbotResponse { Text = ChatbotResources.Get("CallResp_Specify", culture), Processed = false }; @@ -166,8 +169,10 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn } } - private async Task ResolveCallAsync(ChatbotIntent intent, int departmentId) + private async Task ResolveCallAsync(ChatbotIntent intent, ChatbotSession session) { + var departmentId = session.DepartmentId; + intent.Parameters.TryGetValue("callRef", out var reference); if (string.IsNullOrWhiteSpace(reference)) intent.Parameters.TryGetValue("callId", out reference); @@ -180,10 +185,12 @@ private async Task ResolveCallAsync(ChatbotIntent intent, int departmentId cleaned = null; if (!string.IsNullOrWhiteSpace(cleaned)) - return await Services.CallReferenceResolver.ResolveAsync(_callsService, departmentId, cleaned); + return await Services.CallReferenceResolver.ResolveAsync(_callsService, departmentId, cleaned, + _dispatchScopeService, session.UserId); - // No reference: when exactly one call is active it is unambiguous. - var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(departmentId); + // No reference: when exactly one call is active in the user's area it is unambiguous. + var activeCalls = await _dispatchScopeService.FilterCallsForUserAsync(departmentId, session.UserId, + await _callsService.GetActiveCallsByDepartmentAsync(departmentId) ?? new List()); return activeCalls?.Count == 1 ? activeCalls[0] : null; } diff --git a/Core/Resgrid.Chatbot/Handlers/CallsActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/CallsActionHandler.cs index fd802b80b..5c700d384 100644 --- a/Core/Resgrid.Chatbot/Handlers/CallsActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/CallsActionHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -18,14 +19,17 @@ public class CallsActionHandler : IChatbotActionHandler private readonly ICustomStateService _customStateService; private readonly IUserProfileService _userProfileService; private readonly IAuthorizationService _authorizationService; + private readonly IDispatchScopeService _dispatchScopeService; public CallsActionHandler( ICallsService callsService, IDepartmentsService departmentsService, ICustomStateService customStateService, IUserProfileService userProfileService, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IDispatchScopeService dispatchScopeService) { + _dispatchScopeService = dispatchScopeService; _callsService = callsService; _departmentsService = departmentsService; _customStateService = customStateService; @@ -48,7 +52,9 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var department = await _departmentsService.GetDepartmentByIdAsync(session.DepartmentId); var departmentName = department?.Name ?? ChatbotResources.Get("Common_YourDepartment", culture); - var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(session.DepartmentId); + // Group-scoped dispatch (off by default): list only the calls in the user's area or that they're on. + var activeCalls = await _dispatchScopeService.FilterCallsForUserAsync(session.DepartmentId, session.UserId, + await _callsService.GetActiveCallsByDepartmentAsync(session.DepartmentId) ?? new List()); // The department boundary is the per-row rule (the unread-messages list applies the same one). var callList = (activeCalls ?? Enumerable.Empty()) diff --git a/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs b/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs index e8b964438..840e1659b 100644 --- a/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs @@ -25,13 +25,15 @@ public class RespondToCallHandler : IChatbotActionHandler private readonly ICustomStateService _customStateService; private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IPersonnelRolesService _personnelRolesService; + private readonly IDispatchScopeService _dispatchScopeService; - public RespondToCallHandler(ICallsService callsService, IActionLogsService actionLogsService, + public RespondToCallHandler(ICallsService callsService, IActionLogsService actionLogsService, IDispatchScopeService dispatchScopeService, ICustomStateService customStateService = null, IDepartmentGroupsService departmentGroupsService = null, IPersonnelRolesService personnelRolesService = null) { _callsService = callsService; _actionLogsService = actionLogsService; + _dispatchScopeService = dispatchScopeService; _customStateService = customStateService; _departmentGroupsService = departmentGroupsService; _personnelRolesService = personnelRolesService; @@ -55,7 +57,10 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn call = await ResolveMostRecentDispatchAsync(session.UserId, session.DepartmentId); else { - call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference); + // Group-scoped dispatch (off by default): only a call in the user's area, or one they're on, resolves. + // The no-reference path above only ever picks a call the user was dispatched to, which is always in scope. + call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference, + _dispatchScopeService, session.UserId); } if (call == null) { diff --git a/Core/Resgrid.Chatbot/Services/CallReferenceResolver.cs b/Core/Resgrid.Chatbot/Services/CallReferenceResolver.cs index 5e32742ce..a87a2fb32 100644 --- a/Core/Resgrid.Chatbot/Services/CallReferenceResolver.cs +++ b/Core/Resgrid.Chatbot/Services/CallReferenceResolver.cs @@ -19,11 +19,36 @@ public static class CallReferenceResolver { private static readonly Regex CallNumberRegex = new Regex(@"^\d{2,4}-\d+$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(200)); - public static async Task ResolveAsync(ICallsService callsService, int departmentId, string reference) + public static Task ResolveAsync(ICallsService callsService, int departmentId, string reference) + { + return ResolveAsync(callsService, departmentId, reference, null, null); + } + + /// + /// As above, limited to the user's dispatch scope (group-scoped dispatch, off by default): a call + /// outside it resolves to null like a foreign one, and shorthand ("fire") matches only among the + /// in-scope active calls, so an area supervisor gets their own area's fire rather than a more + /// recent one elsewhere. A null means unscoped. + /// + public static async Task ResolveAsync(ICallsService callsService, int departmentId, string reference, + IDispatchScopeService dispatchScopeService, string userId) { if (string.IsNullOrWhiteSpace(reference)) return null; + DispatchScope scope = null; + if (dispatchScopeService != null) + { + scope = await dispatchScopeService.GetScopeForUserAsync(departmentId, userId); + + // Never null by contract; if it ever is, resolve nothing rather than everything. + if (scope == null) + return null; + + if (scope.IsDepartmentWide) + scope = null; + } + // Trailing punctuation is never part of a call reference ("omw to 26-1.", "respond to fire?"). var text = reference.Trim().TrimEnd('?', '!', '.', ','); if (text.Length == 0) @@ -37,10 +62,19 @@ public static async Task ResolveAsync(ICallsService callsService, int depa if (int.TryParse(text, out var callId)) { var call = await callsService.GetCallByIdAsync(callId); - return (call != null && call.DepartmentId == departmentId) ? call : null; + if (call == null || call.DepartmentId != departmentId) + return null; + + if (scope != null && !await dispatchScopeService.IsCallInScopeAsync(scope, call)) + return null; + + return call; } var activeCalls = await callsService.GetActiveCallsByDepartmentAsync(departmentId); + if (scope != null && activeCalls != null) + activeCalls = await dispatchScopeService.FilterCallsAsync(scope, activeCalls); + if (activeCalls == null || activeCalls.Count == 0) return null; diff --git a/Core/Resgrid.Chatbot/Services/IncidentContextResolver.cs b/Core/Resgrid.Chatbot/Services/IncidentContextResolver.cs index 10ff0c64e..c2b545cd8 100644 --- a/Core/Resgrid.Chatbot/Services/IncidentContextResolver.cs +++ b/Core/Resgrid.Chatbot/Services/IncidentContextResolver.cs @@ -23,13 +23,16 @@ public class IncidentContextResolver : IIncidentContextResolver private readonly IIncidentCommandService _incidentCommandService; private readonly IIncidentResourcesService _incidentResourcesService; private readonly IAuthorizationService _authorizationService; + private readonly IDispatchScopeService _dispatchScopeService; public IncidentContextResolver( ICallsService callsService, IIncidentCommandService incidentCommandService, IIncidentResourcesService incidentResourcesService, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IDispatchScopeService dispatchScopeService) { + _dispatchScopeService = dispatchScopeService; _callsService = callsService; _incidentCommandService = incidentCommandService; _incidentResourcesService = incidentResourcesService; @@ -50,7 +53,8 @@ public async Task ResolveAsync(ChatbotIntent intent, ChatbotSes var reference = GetParameter(intent, "callRef") ?? GetParameter(intent, "callId"); if (!string.IsNullOrWhiteSpace(reference)) { - var referenced = await CallReferenceResolver.ResolveAsync(_callsService, departmentId, reference); + var referenced = await CallReferenceResolver.ResolveAsync(_callsService, departmentId, reference, + _dispatchScopeService, session.UserId); if (referenced == null) return context; diff --git a/Core/Resgrid.Config/DataProtectionConfig.cs b/Core/Resgrid.Config/DataProtectionConfig.cs index 499fef866..f817141ce 100644 --- a/Core/Resgrid.Config/DataProtectionConfig.cs +++ b/Core/Resgrid.Config/DataProtectionConfig.cs @@ -57,10 +57,47 @@ public static class DataProtectionConfig /// separated (RMS plan section 5.9.4). Each purpose is an egress the department acknowledged in the /// application before the caller reaches the broker: neris-submission (worker 41), records-export /// (worker 45 / Workflow renders) and invoicing (invoice delivery, pay links and deployment finance: the - /// Workforce & Business Operations plan's document renders and the DTR void-reason append). Empty - /// disables the lane; callers fail closed with workload_purpose_denied. + /// Workforce & Business Operations plan's document renders and the DTR void-reason append) and + /// protected-workflow (an approved Protected Workflow release sending its allow-listed fields to its pinned + /// destination). Empty disables the lane; callers fail closed with workload_purpose_denied. /// - public static string BrokerWorkloadPurposes = "neris-submission,records-export,invoicing,workforce-costing,pay-data-reporting"; + public static string BrokerWorkloadPurposes = "neris-submission,records-export,invoicing,workforce-costing,pay-data-reporting,protected-workflow"; + + /// + /// Days an approved Protected Workflow release stays Active before it expires and must be renewed with a + /// fresh step-up and re-attestation. Expiry is checked at run time and by the daily sweep (worker 71). + /// + public static int ProtectedWorkflowReleaseLifetimeDays = 365; + + /// Hard HTTP timeout, in seconds, for a protected workflow step's request (and its OAuth2 token request). + public static int ProtectedWorkflowHttpTimeoutSeconds = 30; + + /// Ceiling on the number of catalog fields one Protected Workflow release may allow-list. + public static int ProtectedWorkflowMaxFieldsPerRelease = 16; + + /// + /// How recent, in minutes, the approving administrator's step-up MFA must be for a release request, approval, + /// renewal or the department toggle. Older proofs are refused with step_up_required. + /// + public static int ProtectedWorkflowStepUpFreshnessMinutes = 10; + + /// + /// Whether a protected step may authenticate with an HttpBasic credential. Off by default: Bearer, API key and + /// OAuth2 client credentials are allowed; Basic sends a long-lived password on every request. + /// + public static bool ProtectedWorkflowAllowHttpBasicCredentials = false; + + /// Days before ExpiresOn at which department administrators are emailed an expiry notice, comma separated. + public static string ProtectedWorkflowExpiryNoticeDays = "30,7"; + + /// Largest response body a protected step reads for its success rule or capture (larger is failed_response_too_large). + public static int ProtectedWorkflowMaxResponseBytes = 1048576; + + /// Most ResponseCapture entries one protected step may declare. + public static int ProtectedWorkflowMaxCaptureKeys = 5; + + /// Days a rotated private_key_jwt signing key stays published in the credential's JWKS. + public static int WorkflowJwksOverlapDays = 7; /// True on the broker host to run the ADP migration coordinator sweep there (the only /// host with a real KMS adapter). Workers.Console keeps its sweep for liveness/offboarding diff --git a/Core/Resgrid.Localization/Account/Login.ar.resx b/Core/Resgrid.Localization/Account/Login.ar.resx index 46537eea0..c110397cf 100644 --- a/Core/Resgrid.Localization/Account/Login.ar.resx +++ b/Core/Resgrid.Localization/Account/Login.ar.resx @@ -136,7 +136,7 @@ إذا لم يسجّل قسمك في Resgrid بعد، يمكنك إنشاء حساب جديد سيؤدي إلى إنشاء القسم تلقائيًا. - تُجرى الصيانة أسبوعيًا كل سبت ابتداءً من الساعة 20:00 بتوقيت المحيط الهادئ. + تُجرى الصيانة المجدولة ضمن نافذة أسبوعية ثابتة، أيام الثلاثاء من الساعة 10 مساءً حتى منتصف الليل بتوقيت المحيط الهادئ، ويُعلَن عنها مسبقًا على صفحة حالة اتفاقية مستوى الخدمة (SLA). أما نوافذ الصيانة الممتدة العرضية (حتى 4 ساعات، وبحد أقصى مرة واحدة كل ربع سنة) فيُعلَن عنها قبل 5 أيام عمل على الأقل. تسجيل الدخول diff --git a/Core/Resgrid.Localization/Account/Login.de.resx b/Core/Resgrid.Localization/Account/Login.de.resx index 10a40ac89..6871c925d 100644 --- a/Core/Resgrid.Localization/Account/Login.de.resx +++ b/Core/Resgrid.Localization/Account/Login.de.resx @@ -141,7 +141,7 @@ If your department has not yet signed up for Resgrid you can create a new account which will create the department for you. - Maintenance is performed weekly on Saturday starting at 2000 hours Pacific. + Scheduled maintenance runs in a standing weekly window, Tuesdays 10 p.m.–midnight Pacific, and is announced in advance on the SLA status page. Occasional extended windows (up to 4 hours, no more than once per quarter) are announced at least 5 business days ahead. Anmelden diff --git a/Core/Resgrid.Localization/Account/Login.el.resx b/Core/Resgrid.Localization/Account/Login.el.resx index 4e6acc720..ff0dfb71e 100644 --- a/Core/Resgrid.Localization/Account/Login.el.resx +++ b/Core/Resgrid.Localization/Account/Login.el.resx @@ -190,7 +190,7 @@ Αν το τμήμα σας δεν έχει εγγραφεί ακόμη στο Resgrid, μπορείτε να δημιουργήσετε νέο λογαριασμό, ο οποίος θα δημιουργήσει το τμήμα για εσάς. - Η συντήρηση πραγματοποιείται εβδομαδιαία, το Σάββατο, με έναρξη στις 2000 ώρα Ειρηνικού. + Η προγραμματισμένη συντήρηση πραγματοποιείται σε σταθερό εβδομαδιαίο παράθυρο, κάθε Τρίτη 10 μ.μ.–μεσάνυχτα ώρα Ειρηνικού, και ανακοινώνεται εκ των προτέρων στη σελίδα κατάστασης SLA. Περιστασιακά εκτεταμένα παράθυρα (έως 4 ώρες, όχι συχνότερα από μία φορά ανά τρίμηνο) ανακοινώνονται τουλάχιστον 5 εργάσιμες ημέρες νωρίτερα. Σύνδεση diff --git a/Core/Resgrid.Localization/Account/Login.en.resx b/Core/Resgrid.Localization/Account/Login.en.resx index 300127f54..43c1e62ea 100644 --- a/Core/Resgrid.Localization/Account/Login.en.resx +++ b/Core/Resgrid.Localization/Account/Login.en.resx @@ -190,7 +190,7 @@ If your department has not yet signed up for Resgrid you can create a new account which will create the department for you. - Maintenance is performed weekly on Saturday starting at 2000 hours Pacific. + Scheduled maintenance runs in a standing weekly window, Tuesdays 10 p.m.–midnight Pacific, and is announced in advance on the SLA status page. Occasional extended windows (up to 4 hours, no more than once per quarter) are announced at least 5 business days ahead. Log On diff --git a/Core/Resgrid.Localization/Account/Login.es.resx b/Core/Resgrid.Localization/Account/Login.es.resx index 147257ebd..25fa8e525 100644 --- a/Core/Resgrid.Localization/Account/Login.es.resx +++ b/Core/Resgrid.Localization/Account/Login.es.resx @@ -190,7 +190,7 @@ Si su departamento aún no se ha registrado en Resgrid, puede crear una nueva cuenta que creará el departamento por usted. - El mantenimiento se realiza semanalmente los sábados a partir de las 2000 horas del Pacífico. + El mantenimiento programado se realiza en una ventana semanal fija, los martes de 10 p. m. a medianoche (hora del Pacífico), y se anuncia con antelación en la página de estado del SLA. Las ventanas extendidas ocasionales (de hasta 4 horas, no más de una vez por trimestre) se anuncian con al menos 5 días hábiles de antelación. Acceder diff --git a/Core/Resgrid.Localization/Account/Login.fr.resx b/Core/Resgrid.Localization/Account/Login.fr.resx index 3632a63b0..aaeaeb57e 100644 --- a/Core/Resgrid.Localization/Account/Login.fr.resx +++ b/Core/Resgrid.Localization/Account/Login.fr.resx @@ -141,7 +141,7 @@ If your department has not yet signed up for Resgrid you can create a new account which will create the department for you. - Maintenance is performed weekly on Saturday starting at 2000 hours Pacific. + Scheduled maintenance runs in a standing weekly window, Tuesdays 10 p.m.–midnight Pacific, and is announced in advance on the SLA status page. Occasional extended windows (up to 4 hours, no more than once per quarter) are announced at least 5 business days ahead. Se connecter diff --git a/Core/Resgrid.Localization/Account/Login.it.resx b/Core/Resgrid.Localization/Account/Login.it.resx index e86952625..6e7a384c8 100644 --- a/Core/Resgrid.Localization/Account/Login.it.resx +++ b/Core/Resgrid.Localization/Account/Login.it.resx @@ -141,7 +141,7 @@ If your department has not yet signed up for Resgrid you can create a new account which will create the department for you. - Maintenance is performed weekly on Saturday starting at 2000 hours Pacific. + Scheduled maintenance runs in a standing weekly window, Tuesdays 10 p.m.–midnight Pacific, and is announced in advance on the SLA status page. Occasional extended windows (up to 4 hours, no more than once per quarter) are announced at least 5 business days ahead. Accedi diff --git a/Core/Resgrid.Localization/Account/Login.pl.resx b/Core/Resgrid.Localization/Account/Login.pl.resx index 307d3188f..09686d04e 100644 --- a/Core/Resgrid.Localization/Account/Login.pl.resx +++ b/Core/Resgrid.Localization/Account/Login.pl.resx @@ -141,7 +141,7 @@ If your department has not yet signed up for Resgrid you can create a new account which will create the department for you. - Maintenance is performed weekly on Saturday starting at 2000 hours Pacific. + Scheduled maintenance runs in a standing weekly window, Tuesdays 10 p.m.–midnight Pacific, and is announced in advance on the SLA status page. Occasional extended windows (up to 4 hours, no more than once per quarter) are announced at least 5 business days ahead. Zaloguj się diff --git a/Core/Resgrid.Localization/Account/Login.sv.resx b/Core/Resgrid.Localization/Account/Login.sv.resx index 240e8d57e..d6d1af284 100644 --- a/Core/Resgrid.Localization/Account/Login.sv.resx +++ b/Core/Resgrid.Localization/Account/Login.sv.resx @@ -141,7 +141,7 @@ If your department has not yet signed up for Resgrid you can create a new account which will create the department for you. - Maintenance is performed weekly on Saturday starting at 2000 hours Pacific. + Scheduled maintenance runs in a standing weekly window, Tuesdays 10 p.m.–midnight Pacific, and is announced in advance on the SLA status page. Occasional extended windows (up to 4 hours, no more than once per quarter) are announced at least 5 business days ahead. Logga in diff --git a/Core/Resgrid.Localization/Account/Login.uk.resx b/Core/Resgrid.Localization/Account/Login.uk.resx index 3ebc5e5f2..0751f914c 100644 --- a/Core/Resgrid.Localization/Account/Login.uk.resx +++ b/Core/Resgrid.Localization/Account/Login.uk.resx @@ -141,7 +141,7 @@ If your department has not yet signed up for Resgrid you can create a new account which will create the department for you. - Maintenance is performed weekly on Saturday starting at 2000 hours Pacific. + Scheduled maintenance runs in a standing weekly window, Tuesdays 10 p.m.–midnight Pacific, and is announced in advance on the SLA status page. Occasional extended windows (up to 4 hours, no more than once per quarter) are announced at least 5 business days ahead. Увійти diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx index 3e681089a..a4d06f5fe 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx @@ -1143,4 +1143,22 @@ تعذّر حفظ الملف؛ راجع الحقول المميّزة. + + التوجيه حسب المجموعة + + + قصر التوجيه على مناطق المجموعات + + + عند التفعيل، تعرض قوائم البلاغات النشطة والخرائط ولوحة أقرب الوحدات لكل عضو مجموعته فقط والمجموعات التابعة لها: البلاغات الواقعة داخل حدود تلك المجموعات أو الموجهة إليها، بالإضافة إلى أي بلاغ أبلغ عنه العضو أو وُجِّه إليه. يحتفظ مسؤولو القسم والأدوار أدناه بعرض القسم بالكامل. لا يتم نسخ أي شيء أو نقله، لذا فإن تسليم التوجيه إلى مكتب آخر عند تغيير المناوبة هو مجرد تغيير في الدور. + + + أدوار التوجيه على مستوى القسم + + + يرى الأعضاء الذين يحملون أيًا من هذه الأدوار (مثل مركز توجيه مركزي) جميع المناطق أثناء تفعيل التقييد. عيّن الدور أو أزِله لتسليم التوجيه؛ ويسري ذلك عند تحميل الصفحة التالي. + + + لم يتم تعريف أي أدوار للأفراد بعد. + \ 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 d92761e7a..6db3bc110 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx @@ -1094,4 +1094,22 @@ Das Profil konnte nicht gespeichert werden; prüfen Sie die markierten Felder. + + Gruppenbezogene Alarmierung + + + Alarmierung auf Gruppengebiete beschränken + + + Wenn aktiviert, zeigen die Listen aktiver Einsätze, die Karten und die Übersicht der nächsten Einheiten jedem Mitglied nur die eigene Gruppe und die darunterliegenden Gruppen: Einsätze innerhalb der Grenzen dieser Gruppen oder an sie alarmierte Einsätze sowie alle Einsätze, die das Mitglied gemeldet hat oder zu denen es alarmiert wurde. Abteilungsadministratoren und die unten gewählten Rollen behalten die abteilungsweite Ansicht. Es werden keine Daten kopiert oder verschoben – die Übergabe der Alarmierung an einen anderen Platz beim Schichtwechsel ist nur ein Rollenwechsel. + + + Abteilungsweite Alarmierungsrollen + + + Mitglieder mit einer dieser Rollen (zum Beispiel eine zentrale Leitstelle) sehen bei aktivierter Beschränkung alle Gebiete. Weisen Sie die Rolle zu oder entfernen Sie sie, um die Alarmierung zu übergeben; die Änderung gilt ab dem nächsten Laden der Seite. + + + Es sind noch keine Personalrollen definiert. + \ 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 1a2cf1048..ff83df33c 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx @@ -1173,4 +1173,22 @@ Το προφίλ δεν ήταν δυνατό να αποθηκευτεί· ελέγξτε τα επισημασμένα πεδία. + + Διάθεση ανά ομάδα + + + Περιορισμός διάθεσης στις περιοχές ομάδων + + + Όταν είναι ενεργό, οι λίστες ενεργών κλήσεων, οι χάρτες και ο πίνακας πλησιέστερων μονάδων κάθε μέλους εμφανίζουν μόνο τη δική του ομάδα και τις ομάδες κάτω από αυτή: κλήσεις εντός των ορίων αυτών των ομάδων ή που τους έχουν ανατεθεί, καθώς και κάθε κλήση που το μέλος ανέφερε ή στην οποία διατέθηκε. Οι διαχειριστές του τμήματος και οι παρακάτω ρόλοι διατηρούν προβολή ολόκληρου του τμήματος. Τίποτα δεν αντιγράφεται ή μετακινείται, οπότε η παράδοση της διάθεσης σε άλλη θέση στην αλλαγή βάρδιας είναι απλώς αλλαγή ρόλου. + + + Ρόλοι διάθεσης για όλο το τμήμα + + + Τα μέλη με οποιονδήποτε από αυτούς τους ρόλους (για παράδειγμα ένα κεντρικό κέντρο διάθεσης) βλέπουν όλες τις περιοχές όταν ο περιορισμός είναι ενεργός. Αναθέστε ή αφαιρέστε τον ρόλο για να παραδώσετε τη διάθεση· ισχύει από την επόμενη φόρτωση σελίδας. + + + Δεν έχουν οριστεί ακόμη ρόλοι προσωπικού. + \ 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 adc9c1050..ac49ee71f 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx @@ -1179,4 +1179,22 @@ The profile could not be saved; check the highlighted fields. + + Group-Scoped Dispatch + + + Scope Dispatch to Group Areas + + + When on, each member's active call lists, maps and nearest unit board show only their own group and the groups beneath it: calls inside those groups' boundaries or dispatched to them, plus any call the member reported or was dispatched to. Department admins and the roles below keep a department-wide view. Nothing is copied or moved, so handing dispatch to another desk at shift change is only a change of role. + + + Department-Wide Dispatch Roles + + + Members holding any of these roles (for example a central dispatch center) see every area while scoping is on. Assign or remove the role to hand dispatch over; it takes effect on their next page load. + + + No personnel roles are defined yet. + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx index ae5a4c77f..5bb42e958 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx @@ -1029,4 +1029,22 @@ No se pudo guardar el perfil; revise los campos marcados. + + Despacho por grupo + + + Limitar el despacho a las áreas de grupo + + + Si se activa, las listas de llamadas activas, los mapas y el panel de unidades más cercanas de cada miembro muestran solo su propio grupo y los grupos que dependen de él: llamadas dentro de los límites de esos grupos o despachadas a ellos, además de cualquier llamada que el miembro haya reportado o a la que haya sido despachado. Los administradores del departamento y los roles indicados abajo conservan la vista de todo el departamento. No se copia ni se mueve nada, por lo que traspasar el despacho a otra mesa en el cambio de turno es solo un cambio de rol. + + + Roles de despacho para todo el departamento + + + Los miembros con cualquiera de estos roles (por ejemplo, un centro de despacho central) ven todas las áreas mientras la limitación está activa. Asigne o quite el rol para traspasar el despacho; se aplica en la siguiente carga de página. + + + Todavía no hay roles de personal definidos. + \ 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 b62554c00..0f7c4d471 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx @@ -1094,4 +1094,22 @@ Le profil n'a pas pu être enregistré ; vérifiez les champs signalés. + + Répartition par groupe + + + Limiter la répartition aux zones de groupe + + + Lorsque cette option est activée, les listes d'appels actifs, les cartes et le tableau des unités les plus proches de chaque membre n'affichent que son propre groupe et les groupes qui en dépendent : les appels situés dans les limites de ces groupes ou qui leur ont été attribués, ainsi que tout appel que le membre a signalé ou auquel il a été affecté. Les administrateurs du département et les rôles ci-dessous conservent une vue de tout le département. Rien n'est copié ni déplacé : confier la répartition à un autre poste au changement de garde n'est qu'un changement de rôle. + + + Rôles de répartition pour tout le département + + + Les membres ayant l'un de ces rôles (par exemple un centre de répartition central) voient toutes les zones lorsque la limitation est active. Attribuez ou retirez le rôle pour transférer la répartition ; cela prend effet au prochain chargement de page. + + + Aucun rôle de personnel n'est encore défini. + \ 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 a6064fa7e..e304ba210 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx @@ -1094,4 +1094,22 @@ Impossibile salvare il profilo; controllate i campi evidenziati. + + Invio per gruppo + + + Limita l'invio alle aree di gruppo + + + Se attivo, gli elenchi delle chiamate attive, le mappe e il quadro delle unità più vicine di ciascun membro mostrano solo il proprio gruppo e i gruppi sottostanti: le chiamate all'interno dei confini di quei gruppi o inviate a essi, più qualsiasi chiamata che il membro ha segnalato o a cui è stato inviato. Gli amministratori del dipartimento e i ruoli indicati sotto mantengono la vista dell'intero dipartimento. Nulla viene copiato o spostato: passare l'invio a un'altra postazione al cambio turno è solo un cambio di ruolo. + + + Ruoli di invio per l'intero dipartimento + + + I membri con uno di questi ruoli (ad esempio una centrale operativa) vedono tutte le aree quando la limitazione è attiva. Assegna o rimuovi il ruolo per passare l'invio; ha effetto al successivo caricamento della pagina. + + + Non sono ancora definiti ruoli del personale. + \ 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 78ee9e6ee..64ae26c37 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx @@ -1094,4 +1094,22 @@ Nie udało się zapisać profilu; sprawdź wyróżnione pola. + + Dysponowanie według grup + + + Ogranicz dysponowanie do obszarów grup + + + Po włączeniu listy aktywnych zgłoszeń, mapy i tablica najbliższych jednostek każdego członka pokazują tylko jego własną grupę i grupy jej podległe: zgłoszenia w granicach tych grup lub do nich zadysponowane, a także każde zgłoszenie, które członek zgłosił lub do którego został zadysponowany. Administratorzy działu i poniższe role zachowują widok całego działu. Nic nie jest kopiowane ani przenoszone, więc przekazanie dysponowania innemu stanowisku przy zmianie dyżuru to tylko zmiana roli. + + + Role dysponowania dla całego działu + + + Członkowie z dowolną z tych ról (np. centralne stanowisko dyspozytorskie) widzą wszystkie obszary, gdy ograniczenie jest włączone. Przypisz lub usuń rolę, aby przekazać dysponowanie; zmiana obowiązuje od następnego załadowania strony. + + + Nie zdefiniowano jeszcze żadnych ról personelu. + \ 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 d1fe01d3c..32533d292 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx @@ -774,4 +774,22 @@ The profile could not be saved; check the highlighted fields. + + Group-Scoped Dispatch + + + Scope Dispatch to Group Areas + + + When on, each member's active call lists, maps and nearest unit board show only their own group and the groups beneath it: calls inside those groups' boundaries or dispatched to them, plus any call the member reported or was dispatched to. Department admins and the roles below keep a department-wide view. Nothing is copied or moved, so handing dispatch to another desk at shift change is only a change of role. + + + Department-Wide Dispatch Roles + + + Members holding any of these roles (for example a central dispatch center) see every area while scoping is on. Assign or remove the role to hand dispatch over; it takes effect on their next page load. + + + No personnel roles are defined yet. + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx index 658ca4924..b83c7d0f0 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx @@ -1094,4 +1094,22 @@ Profilen kunde inte sparas; kontrollera de markerade fälten. + + Gruppbaserad utlarmning + + + Begränsa utlarmning till gruppområden + + + När detta är på visar varje medlems listor över aktiva larm, kartor och tavla över närmaste enheter endast den egna gruppen och grupperna under den: larm inom de gruppernas gränser eller som larmats till dem, samt alla larm som medlemmen har rapporterat eller larmats till. Avdelningsadministratörer och rollerna nedan behåller en vy över hela avdelningen. Inget kopieras eller flyttas, så att lämna över utlarmningen till en annan plats vid skiftbyte är bara ett rollbyte. + + + Utlarmningsroller för hela avdelningen + + + Medlemmar med någon av dessa roller (till exempel en central larmcentral) ser alla områden när begränsningen är på. Tilldela eller ta bort rollen för att lämna över utlarmningen; det gäller från nästa sidladdning. + + + Inga personalroller har definierats ännu. + \ 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 4d07afa41..337c48155 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx @@ -1094,4 +1094,22 @@ Не вдалося зберегти профіль; перевірте виділені поля. + + Групове диспетчеризування + + + Обмежити диспетчеризування зонами груп + + + Якщо ввімкнено, списки активних викликів, мапи та панель найближчих підрозділів кожного учасника показують лише його власну групу та підпорядковані їй групи: виклики в межах цих груп або направлені їм, а також будь-які виклики, про які учасник повідомив або на які його було направлено. Адміністратори відділу та ролі нижче зберігають перегляд усього відділу. Нічого не копіюється і не переміщується, тож передача диспетчеризування іншому пульту під час зміни — це лише зміна ролі. + + + Ролі диспетчеризування для всього відділу + + + Учасники з будь-якою з цих ролей (наприклад, центральний диспетчерський центр) бачать усі зони, коли обмеження ввімкнено. Призначте або зніміть роль, щоб передати диспетчеризування; зміна діє з наступного завантаження сторінки. + + + Ролі персоналу ще не визначено. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx index 2ea7b9c61..aacdf68b3 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx @@ -299,4 +299,82 @@ أوقات الوحدات + + 42 CFR Part 2 + + + موافقة Part 2 مسجّلة + + + حدّده عندما يكون لدى القسم موافقة (أو أساس آخر وفق 42 CFR Part 2) لمشاركة معلومات هذا البلاغ المتعلقة باضطرابات تعاطي المواد. لا يرسل سير العمل المحمي حقول Part 2 إلا عند تحديده. + + + معرّفات الشخص + + + أقرب الوحدات + + + يمكن أن تكون الوحدة فريقًا أو مركبة أو فردًا. + + + الوحدة + + + من الطاقم في المناوبة + + + مناوبة المحطة + + + لم يتم تعيين طاقم + + + يتم عرض وحدات منطقتك فقط + + + الحالة + + + وقت الوصول المتوقع + + + الموقع + + + تغطية المناوبة + + + مزيج الأدوار + + + داخل المنطقة + + + داخل منطقة الخدمة + + + الحادث داخل: + + + الحادث ليس داخل حدود أي مجموعة. + + + تقديري + + + دقيقة + + + GPS مباشر + + + موقع المحطة + + + قديم + + + لا يوجد موقع + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx index fe5c9fab8..15c40a731 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx @@ -866,4 +866,82 @@ Einheitenzeiten + + 42 CFR Part 2 + + + Part-2-Einwilligung liegt vor + + + Setzen, wenn der Abteilung eine Einwilligung (oder eine andere Grundlage nach 42 CFR Part 2) vorliegt, die Informationen dieses Einsatzes zu Substanzgebrauchsstörungen weiterzugeben. Geschützte Workflows senden Part-2-Felder nur, wenn dies gesetzt ist. + + + Personenkennungen + + + Nächste Einheiten + + + Eine Einheit kann ein Team, ein Fahrzeug oder eine einzelne Person sein. + + + Einheit + + + Besatzung im Dienst + + + Wachschicht + + + Keine Besatzung zugewiesen + + + Nur die Einheiten Ihres Gebiets werden angezeigt + + + Status + + + Ankunft (ETA) + + + Position + + + Schichtbesetzung + + + Rollenverteilung + + + Im Gebiet + + + Im Versorgungsgebiet + + + Einsatzort liegt in: + + + Der Einsatzort liegt in keiner Gruppengrenze. + + + geschätzt + + + Min. + + + Live-GPS + + + Standort der Wache + + + veraltet + + + Keine Position + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx index 749eb98c1..559bec648 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx @@ -769,4 +769,82 @@ Χρόνοι μονάδων + + 42 CFR Part 2 + + + Υπάρχει συγκατάθεση Part 2 + + + Επιλέξτε το όταν το τμήμα έχει συγκατάθεση (ή άλλη βάση κατά 42 CFR Part 2) για την κοινοποίηση των πληροφοριών αυτής της κλήσης σχετικά με διαταραχές χρήσης ουσιών. Οι προστατευμένες ροές εργασιών στέλνουν πεδία Part 2 μόνο όταν είναι επιλεγμένο. + + + Αναγνωριστικά προσώπου + + + Πλησιέστερες μονάδες + + + Μια μονάδα μπορεί να είναι ομάδα, όχημα ή μεμονωμένο άτομο. + + + Μονάδα + + + πλήρωμα σε βάρδια + + + βάρδια σταθμού + + + Δεν έχει οριστεί πλήρωμα + + + Εμφανίζονται μόνο οι μονάδες της περιοχής σας + + + Κατάσταση + + + Εκτιμ. άφιξη + + + Θέση + + + Κάλυψη βάρδιας + + + Σύνθεση ρόλων + + + Εντός περιοχής + + + Εντός περιοχής εξυπηρέτησης + + + Το συμβάν βρίσκεται εντός: + + + Το συμβάν δεν βρίσκεται εντός ορίων καμίας ομάδας. + + + εκτίμ. + + + λεπ. + + + Ζωντανό GPS + + + Τοποθεσία σταθμού + + + παλιά + + + Χωρίς θέση + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx index da4adad7c..9fc7b9ef1 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx @@ -790,4 +790,82 @@ Unit Times + + 42 CFR Part 2 + + + Part 2 consent on file + + + Set when the department has consent (or another 42 CFR Part 2 basis) to share this call's substance use disorder information. Protected Workflows only send Part 2 fields when it is set. + + + Subject identifiers + + + Nearest Units + + + A unit can be a team, an apparatus or an individual. + + + Unit + + + crew on shift + + + station shift + + + No crew assigned + + + Showing your area's units only + + + Status + + + ETA + + + Position + + + Shift Coverage + + + Role Mix + + + In area + + + In service area + + + Incident is inside: + + + The incident is not inside any group boundary. + + + est. + + + min + + + Live GPS + + + Station location + + + stale + + + No position + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx index 9c93c184e..bc4f4e02c 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx @@ -915,4 +915,82 @@ Tiempos de unidades + + 42 CFR Part 2 + + + Consentimiento Part 2 registrado + + + Márquelo cuando el departamento tenga consentimiento (u otra base de 42 CFR Part 2) para compartir la información sobre trastornos por consumo de sustancias de esta llamada. Los flujos de trabajo protegidos solo envían campos Part 2 cuando está marcado. + + + Identificadores del sujeto + + + Unidades más cercanas + + + Una unidad puede ser un equipo, un vehículo o una persona. + + + Unidad + + + tripulación en turno + + + turno de la estación + + + Sin tripulación asignada + + + Solo se muestran las unidades de su área + + + Estado + + + Tiempo estimado + + + Posición + + + Cobertura de turno + + + Combinación de roles + + + En su área + + + En el área de servicio + + + El incidente está dentro de: + + + El incidente no está dentro de ningún límite de grupo. + + + estimado + + + min + + + GPS en vivo + + + Ubicación de la estación + + + desactualizada + + + Sin posición + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx index eb8ae276e..19aa97bc5 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx @@ -866,4 +866,82 @@ Horaires des unités + + 42 CFR Part 2 + + + Consentement Part 2 enregistré + + + À cocher lorsque le service dispose du consentement (ou d'un autre fondement 42 CFR Part 2) pour partager les informations de cet appel relatives aux troubles liés à l'usage de substances. Les flux de travail protégés n'envoient les champs Part 2 que lorsque cette case est cochée. + + + Identifiants de la personne + + + Unités les plus proches + + + Une unité peut être une équipe, un engin ou une personne. + + + Unité + + + équipage de garde + + + garde de la station + + + Aucun équipage affecté + + + Seules les unités de votre zone sont affichées + + + Statut + + + Arrivée estimée + + + Position + + + Couverture de garde + + + Répartition des rôles + + + Dans la zone + + + Dans la zone de service + + + L'incident se trouve dans : + + + L'incident ne se trouve dans aucune limite de groupe. + + + estimé + + + min + + + GPS en direct + + + Emplacement de la station + + + périmée + + + Aucune position + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx index 34b17adcd..4ee8df1f4 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx @@ -866,4 +866,82 @@ Tempi delle unità + + 42 CFR Part 2 + + + Consenso Part 2 registrato + + + Seleziona quando il dipartimento dispone del consenso (o di un'altra base 42 CFR Part 2) per condividere le informazioni di questa chiamata sui disturbi da uso di sostanze. I flussi di lavoro protetti inviano i campi Part 2 solo quando è selezionato. + + + Identificativi del soggetto + + + Unità più vicine + + + Un'unità può essere una squadra, un mezzo o una singola persona. + + + Unità + + + equipaggio in turno + + + turno della sede + + + Nessun equipaggio assegnato + + + Vengono mostrate solo le unità della tua area + + + Stato + + + Arrivo stimato + + + Posizione + + + Copertura turno + + + Composizione ruoli + + + Nell'area + + + Nell'area di servizio + + + L'incidente si trova in: + + + L'incidente non si trova all'interno di alcun confine di gruppo. + + + stima + + + min + + + GPS in tempo reale + + + Posizione della sede + + + non aggiornata + + + Nessuna posizione + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx index caa890943..49b04b20e 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx @@ -866,4 +866,82 @@ Czasy jednostek + + 42 CFR Part 2 + + + Zgoda Part 2 zarejestrowana + + + Zaznacz, gdy wydział ma zgodę (lub inną podstawę z 42 CFR Part 2) na udostępnienie informacji z tego zgłoszenia o zaburzeniach związanych z używaniem substancji. Chronione przepływy pracy wysyłają pola Part 2 tylko wtedy, gdy jest to zaznaczone. + + + Identyfikatory osoby + + + Najbliższe jednostki + + + Jednostka może być zespołem, pojazdem lub pojedynczą osobą. + + + Jednostka + + + załogi na zmianie + + + zmiana stacji + + + Brak przypisanej załogi + + + Wyświetlane są tylko jednostki z Twojego obszaru + + + Status + + + Szac. czas dojazdu + + + Pozycja + + + Obsada zmiany + + + Skład ról + + + W obszarze + + + W obszarze obsługi + + + Zdarzenie znajduje się w: + + + Zdarzenie nie znajduje się w granicach żadnej grupy. + + + szac. + + + min + + + GPS na żywo + + + Lokalizacja stacji + + + nieaktualna + + + Brak pozycji + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx index 840dfe4e2..fda4a1592 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx @@ -866,4 +866,82 @@ Enhetstider + + 42 CFR Part 2 + + + Part 2-samtycke registrerat + + + Markera när avdelningen har samtycke (eller annan grund enligt 42 CFR Part 2) för att dela larmets information om substansbrukssyndrom. Skyddade arbetsflöden skickar endast Part 2-fält när detta är markerat. + + + Personidentifierare + + + Närmaste enheter + + + En enhet kan vara ett team, ett fordon eller en enskild person. + + + Enhet + + + besättning i tjänst + + + stationens skift + + + Ingen besättning tilldelad + + + Visar endast ditt områdes enheter + + + Status + + + Beräknad ankomst + + + Position + + + Skiftbemanning + + + Rollfördelning + + + Inom området + + + Inom serviceområdet + + + Händelsen ligger inom: + + + Händelsen ligger inte inom någon gruppgräns. + + + uppsk. + + + min + + + Live-GPS + + + Stationens plats + + + inaktuell + + + Ingen position + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx index 2316ba969..af8ca9b8a 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx @@ -866,4 +866,82 @@ Час підрозділів + + 42 CFR Part 2 + + + Згоду Part 2 зареєстровано + + + Позначте, коли підрозділ має згоду (або іншу підставу згідно з 42 CFR Part 2) на передавання інформації цього виклику про розлади, пов'язані з уживанням речовин. Захищені робочі процеси надсилають поля Part 2 лише тоді, коли це позначено. + + + Ідентифікатори особи + + + Найближчі підрозділи + + + Підрозділом може бути команда, техніка або окрема особа. + + + Підрозділ + + + екіпажу на зміні + + + зміна станції + + + Екіпаж не призначено + + + Показано лише підрозділи вашої зони + + + Статус + + + Час прибуття + + + Місцезнаходження + + + Покриття зміни + + + Склад ролей + + + У зоні + + + У зоні обслуговування + + + Інцидент у межах: + + + Інцидент не перебуває в межах жодної групи. + + + орієнт. + + + хв + + + GPS наживо + + + Розташування станції + + + застаріла + + + Немає позиції + diff --git a/Core/Resgrid.Localization/Areas/User/Groups/Groups.ar.resx b/Core/Resgrid.Localization/Areas/User/Groups/Groups.ar.resx index abfc92668..a5a043820 100644 --- a/Core/Resgrid.Localization/Areas/User/Groups/Groups.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Groups/Groups.ar.resx @@ -255,16 +255,16 @@ سيؤدي تفعيل هذا الخيار إلى طباعة مكالمة على الطابعة المحددة عند إرفاق وحدة أو شخص من هذه المجموعة (إرساله) إلى مكالمة - السياج الجغرافي للمحطة + حدود المجموعة - السياج الجغرافي للمحطة + حدود المجموعة - السياج الجغرافي للمحطة + حدود المجموعة - أدناه يمكنك إنشاء سياج جغرافي لمحطتك. الحقول باللون الأزرق المائل مطلوبة. تتيح لك الأسيجة الجغرافية إنشاء حدود على الخريطة تمثل منطقة الاستجابة لهذه المحطة. + يمكنك أدناه رسم حدود هذه المجموعة: منطقة الاستجابة لمحطة أو منطقة الخدمة لمجموعة تنظيمية. تنتمي البلاغات الواقعة داخلها إلى هذه المجموعة لأغراض التوجيه حسب المجموعة والتوصيات المستندة إلى المحطات. لرسمها، انقر على الخريطة لوضع النقطة الأولى، ثم واصل النقر لإضافة خطوط حتى تُغلق المنطقة، ثم احفظ. لون المنطقة diff --git a/Core/Resgrid.Localization/Areas/User/Groups/Groups.de.resx b/Core/Resgrid.Localization/Areas/User/Groups/Groups.de.resx index 36ff7a0d7..1de4b8f9a 100644 --- a/Core/Resgrid.Localization/Areas/User/Groups/Groups.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Groups/Groups.de.resx @@ -255,16 +255,16 @@ Wenn diese Option aktiviert ist, wird ein Einsatz auf dem ausgewählten Drucker gedruckt, wenn eine Einheit oder Person dieser Gruppe einem Einsatz zugewiesen (disponiert) wird - Geofence-Station + Gruppengrenze - Geofence-Station + Gruppengrenze - Geofence-Station + Gruppengrenze - Unten können Sie einen Geofence für Ihre Station erstellen. Felder in blauer Kursivschrift sind Pflichtfelder. Geofences ermöglichen es Ihnen, eine Grenze auf der Karte zu erstellen, die das Einsatzgebiet dieser Station darstellt. Dies wird verwendet, um Einsätze automatisch zu leiten und Reaktionen zu bestimmen. + Unten können Sie die Grenze dieser Gruppe zeichnen: das Einsatzgebiet einer Station oder das Versorgungsgebiet einer Organisationsgruppe. Einsätze innerhalb dieser Grenze gehören für die gruppenbezogene Alarmierung und stationsbasierte Empfehlungen zu dieser Gruppe. Klicken Sie zum Zeichnen auf die Karte, um den ersten Punkt zu setzen, und klicken Sie weiter, um Linien hinzuzufügen, bis das Gebiet umschlossen ist. Speichern Sie anschließend. Bezirksfarbe diff --git a/Core/Resgrid.Localization/Areas/User/Groups/Groups.el.resx b/Core/Resgrid.Localization/Areas/User/Groups/Groups.el.resx index 3efc5fac1..5aa9a148a 100644 --- a/Core/Resgrid.Localization/Areas/User/Groups/Groups.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Groups/Groups.el.resx @@ -264,16 +264,16 @@ - Γεωφράχτης Σταθμού + Όριο ομάδας - Γεωφράχτης Σταθμού + Όριο ομάδας - Γεωφράχτης Σταθμού + Όριο ομάδας - Παρακάτω μπορείτε να δημιουργήσετε γεωφράχτη για τον σταθμό σας. Τα πεδία με μπλε πλάγια γράμματα είναι υποχρεωτικά. Οι γεωφράχτες σάς επιτρέπουν να δημιουργήσετε ένα όριο στον χάρτη που αποτελεί την περιοχή ανταπόκρισης αυτού του σταθμού. Θα χρησιμοποιηθεί για την αυτόματη δρομολόγηση κλήσεων και τον καθορισμό ανταποκρίσεων. Για να δημιουργήσετε τον γεωφράχτη, κάντε πρώτα κλικ στον χάρτη για να ορίσετε το σημείο έναρξης και μετά ξανά κλικ για να σχεδιάσετε γραμμή· συνεχίστε να σχεδιάζετε γραμμές μέχρι να περικλείσετε την περιοχή ανταπόκρισης. + Παρακάτω μπορείτε να σχεδιάσετε το όριο αυτής της ομάδας: την περιοχή απόκρισης ενός σταθμού ή την περιοχή εξυπηρέτησης μιας οργανωτικής ομάδας. Οι κλήσεις που βρίσκονται μέσα σε αυτό ανήκουν σε αυτή την ομάδα για τη διάθεση ανά ομάδα και τις προτάσεις βάσει σταθμού. Για να το σχεδιάσετε, κάντε κλικ στον χάρτη για να τοποθετήσετε το πρώτο σημείο, συνεχίστε να κάνετε κλικ για να προσθέσετε γραμμές μέχρι να κλείσει η περιοχή και, στη συνέχεια, αποθηκεύστε. Χρώμα Περιοχής diff --git a/Core/Resgrid.Localization/Areas/User/Groups/Groups.en.resx b/Core/Resgrid.Localization/Areas/User/Groups/Groups.en.resx index 849af6a27..28c57bdf4 100644 --- a/Core/Resgrid.Localization/Areas/User/Groups/Groups.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Groups/Groups.en.resx @@ -264,16 +264,16 @@ - Geofence Station + Group Boundary - GeoFence Station + Group Boundary - Geofence Station + Group Boundary - Below you can create a geofence for your station. Fields in blue italics are required. Geofences allow you to create a boundary on the map that is this stations response area. This will be used to automatically route calls and determine responses. To create the Geofence first click on the map to create your start point then click again to draw a line, keep drawing lines until you have encircled the response area. + Below you can draw the boundary for this group: a station's response area or an organizational group's service area. Calls located inside it belong to this group for group-scoped dispatch and station-based recommendations. To draw it, click on the map to place the first point, keep clicking to add lines until the area is enclosed, then save. District Color diff --git a/Core/Resgrid.Localization/Areas/User/Groups/Groups.es.resx b/Core/Resgrid.Localization/Areas/User/Groups/Groups.es.resx index ac53fd878..960044b4c 100644 --- a/Core/Resgrid.Localization/Areas/User/Groups/Groups.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Groups/Groups.es.resx @@ -255,16 +255,16 @@ Habilitar esta opción imprimirá una llamada en la impresora seleccionada cuando una unidad o persona de este grupo sea adjuntada (despachada) a una llamada - Geocerca de Estación + Límite del grupo - Geocerca de Estación + Límite del grupo - Geocerca de Estación + Límite del grupo - A continuación puede crear una geocerca para su estación. Los campos en cursiva azul son obligatorios. Las geocercas le permiten crear un límite en el mapa que es el área de respuesta de esta estación. Se usará para enrutar automáticamente las llamadas y determinar las respuestas. + A continuación puede dibujar el límite de este grupo: el área de respuesta de una estación o el área de servicio de un grupo organizativo. Las llamadas ubicadas dentro de él pertenecen a este grupo para el despacho por grupo y las recomendaciones basadas en estaciones. Para dibujarlo, haga clic en el mapa para colocar el primer punto, siga haciendo clic para añadir líneas hasta cerrar el área y luego guarde. Color del Distrito diff --git a/Core/Resgrid.Localization/Areas/User/Groups/Groups.fr.resx b/Core/Resgrid.Localization/Areas/User/Groups/Groups.fr.resx index 310744f38..43365453a 100644 --- a/Core/Resgrid.Localization/Areas/User/Groups/Groups.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Groups/Groups.fr.resx @@ -255,16 +255,16 @@ L'activation de cette option imprimera un appel sur l'imprimante sélectionnée lorsqu'une unité ou une personne de ce groupe est attachée (déployée) à un appel - Géorepérage de Station + Limite du groupe - Géorepérage de Station + Limite du groupe - Géorepérage de Station + Limite du groupe - Ci-dessous, vous pouvez créer un géorepérage pour votre station. Les champs en italique bleu sont obligatoires. Les géorepérages vous permettent de créer une limite sur la carte qui correspond à la zone de réponse de cette station. + Ci-dessous, vous pouvez tracer la limite de ce groupe : la zone d'intervention d'une station ou la zone de service d'un groupe organisationnel. Les appels situés à l'intérieur appartiennent à ce groupe pour la répartition par groupe et les recommandations par station. Pour la tracer, cliquez sur la carte pour placer le premier point, continuez à cliquer pour ajouter des lignes jusqu'à fermer la zone, puis enregistrez. Couleur du District diff --git a/Core/Resgrid.Localization/Areas/User/Groups/Groups.it.resx b/Core/Resgrid.Localization/Areas/User/Groups/Groups.it.resx index ddf4cfceb..9974fed7c 100644 --- a/Core/Resgrid.Localization/Areas/User/Groups/Groups.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Groups/Groups.it.resx @@ -255,16 +255,16 @@ L'abilitazione di questa opzione stamperà una chiamata sulla stampante selezionata quando un'unità o una persona di questo gruppo viene allegata (dispacciata) a una chiamata - Geofence Stazione + Confine del gruppo - Geofence Stazione + Confine del gruppo - Geofence Stazione + Confine del gruppo - Qui sotto puoi creare un geofence per la tua stazione. I campi in corsivo blu sono obbligatori. I geofence ti permettono di creare un confine sulla mappa che è l'area di risposta di questa stazione. + Di seguito puoi disegnare il confine di questo gruppo: l'area di intervento di una stazione o l'area di servizio di un gruppo organizzativo. Le chiamate situate al suo interno appartengono a questo gruppo per l'invio per gruppo e le raccomandazioni basate sulle stazioni. Per disegnarlo, fai clic sulla mappa per posizionare il primo punto, continua a fare clic per aggiungere linee fino a chiudere l'area, quindi salva. Colore del Distretto diff --git a/Core/Resgrid.Localization/Areas/User/Groups/Groups.pl.resx b/Core/Resgrid.Localization/Areas/User/Groups/Groups.pl.resx index f6a76c0d0..cc069d6a8 100644 --- a/Core/Resgrid.Localization/Areas/User/Groups/Groups.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Groups/Groups.pl.resx @@ -255,16 +255,16 @@ Włączenie tej opcji spowoduje wydrukowanie wezwania na wybranej drukarce, gdy jednostka lub osoba z tej grupy zostanie przydzielona (zadysponowana) do wezwania - Geofence Stacji + Granica grupy - Geofence Stacji + Granica grupy - Geofence Stacji + Granica grupy - Poniżej możesz utworzyć geofence dla swojej stacji. Pola w niebieskiej kursywie są wymagane. Geofence umożliwia utworzenie granicy na mapie, która jest obszarem reagowania tej stacji. + Poniżej możesz narysować granicę tej grupy: obszar działania stacji lub obszar obsługi grupy organizacyjnej. Zgłoszenia położone wewnątrz niej należą do tej grupy na potrzeby dysponowania według grup i rekomendacji opartych na stacjach. Aby ją narysować, kliknij mapę, aby umieścić pierwszy punkt, klikaj dalej, dodając linie, aż obszar zostanie zamknięty, a następnie zapisz. Kolor Dystryktu diff --git a/Core/Resgrid.Localization/Areas/User/Groups/Groups.sv.resx b/Core/Resgrid.Localization/Areas/User/Groups/Groups.sv.resx index 61b8f3c57..3bd5d8172 100644 --- a/Core/Resgrid.Localization/Areas/User/Groups/Groups.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Groups/Groups.sv.resx @@ -255,16 +255,16 @@ Aktivering av detta alternativ skriver ut ett larm på den valda skrivaren när en enhet eller person från denna grupp bifogas (utskickas) till ett larm - Geofence-station + Gruppgräns - Geofence-station + Gruppgräns - Geofence-station + Gruppgräns - Nedan kan du skapa en geofence för din station. Fält i blå kursiv är obligatoriska. Geofences låter dig skapa en gräns på kartan som är denna stations svarsområde. Detta kommer att användas för att automatiskt dirigera larm och bestämma svar. + Nedan kan du rita gränsen för den här gruppen: en stations insatsområde eller en organisationsgrupps serviceområde. Larm som ligger inom den tillhör den här gruppen för gruppbaserad utlarmning och stationsbaserade rekommendationer. Rita den genom att klicka på kartan för att placera den första punkten, fortsätt klicka för att lägga till linjer tills området är slutet och spara sedan. Distriktsfärg diff --git a/Core/Resgrid.Localization/Areas/User/Groups/Groups.uk.resx b/Core/Resgrid.Localization/Areas/User/Groups/Groups.uk.resx index 278bff2b0..da3c28da2 100644 --- a/Core/Resgrid.Localization/Areas/User/Groups/Groups.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Groups/Groups.uk.resx @@ -255,16 +255,16 @@ Увімкнення цієї опції друкуватиме виклик на вибраному принтері, коли підрозділ або особа з цієї групи буде прикріплена (задіяна) до виклику - Геозона Станції + Межа групи - Геозона Станції + Межа групи - Геозона Станції + Межа групи - Нижче ви можете створити геозону для вашої станції. Поля синім курсивом є обов'язковими. Геозони дозволяють створити межу на карті, яка є зоною реагування цієї станції. + Нижче ви можете накреслити межу цієї групи: зону реагування станції або зону обслуговування організаційної групи. Виклики, розташовані в її межах, належать цій групі для групового диспетчеризування та рекомендацій на основі станцій. Щоб накреслити її, клацніть на мапі, щоб поставити першу точку, продовжуйте клацати, додаючи лінії, доки область не буде замкнена, а потім збережіть. Колір Округу diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.ar.resx b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.ar.resx new file mode 100644 index 000000000..03d41d54a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.ar.resx @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + سير العمل المحمي + + + الإتاحات المحمية لسير العمل + + + سجل الإفصاح لسير العمل المحمي + + + سير العمل + + + حماية البيانات + + + سير العمل المحمي + + + يتلقى سير العمل قيم REDACTED افتراضيًا. تتيح ميزة سير العمل المحمي للمسؤول الموافقة على سير عمل محدد واحد لإرسال حقول محمية مختارة إلى وجهة HTTPS واحدة مثبّتة. ويستمر كل سير عمل آخر في تلقي قيم REDACTED. + + + تفعيل سير العمل المحمي + + + اشتراط موافقة مسؤول ثانٍ على سير العمل المحمي + + + تحذير + + + يرسل سير العمل المحمي بيانات محمية مفكوكة التشفير، قد تتضمن معلومات صحية محمية، إلى أنظمة خارجية تقوم أنت بتهيئتها. لا يمكن لـ Resgrid التحكم في كيفية تخزين النظام المستلِم لهذه البيانات أو استخدامها. لا تفعّل سير عمل إلا إذا كان المستلِم هو مؤسستك أو طرفًا تشمله اتفاقية شريك أعمال مبرمة مع مؤسستك، ولا تُتح إلا الحقول التي يحتاجها المستلِم. + + + لقد قرأت هذا التحذير وأُقرّ به (الإصدار {0}). + + + تم الإقرار بالتحذير في {0} (الإصدار {1}). + + + حفظ إعدادات سير العمل المحمي + + + تم حفظ إعدادات سير العمل المحمي. + + + لا تتوفر ميزة سير العمل المحمي إلا أثناء تفعيل حماية البيانات المتقدمة لإدارتك. + + + لا يمكن تغيير هذه الإعدادات إلا للمسؤولين الذين يملكون صلاحية «تهيئة إرسال البيانات المحمية». + + + إدارة سير العمل المحمي + + + يتطلب تشغيل هذا الإعداد أو إيقافه، وكذلك الموافقة على إتاحة أو تجديدها، تحققًا حديثًا متعدد العوامل. + + + يتطلب هذا الإجراء تحققًا حديثًا متعدد العوامل. سيتم نقلك لإجراء التحقق ثم إعادتك إلى هذه الصفحة؛ كرّر الإجراء بعد ذلك. + + + الإتاحة المحمية + + + أرسل حقولًا محمية مختارة من سير العمل هذا إلى وجهة HTTPS المثبّتة الخاصة به. لا يُفك تشفير إلا الحقول التي تحددها؛ وتظل جميع القيم المحمية الأخرى على هيئة REDACTED. + + + الحالة + + + غير محمي + + + مسودة + + + بانتظار الموافقة + + + نشطة + + + معلّقة + + + منتهية الصلاحية + + + ملغاة + + + توشك على الانتهاء + + + التجديد بانتظار الموافقة + + + محمي: {0} + + + تم تغيير التهيئة + + + تم إيقاف سير العمل المحمي + + + حماية البيانات المتقدمة غير مفعّلة + + + تم تغيير بيانات الاعتماد + + + علّقها أحد المسؤولين + + + ألغاها أحد المسؤولين + + + إنهاء تفعيل حماية البيانات المتقدمة + + + تم حذف سير العمل + + + الحقول المراد إتاحتها + + + لا يوجد أي تحديد افتراضيًا. أشِر إلى الحقول المُتاحة في قالب الإخراج بالصيغة protected.call.<name>؛ وتظل قيم call.* على هيئة REDACTED. + + + تتوفر بيانات النموذج أيضًا بصيغة محلَّلة عبر protected.call.form. + + + اسم البلاغ + + + نوع البلاغ + + + طبيعة البلاغ + + + ملاحظات البلاغ + + + ملاحظات الإغلاق + + + العنوان + + + الموقع الجغرافي + + + عنوان what3words + + + اسم جهة الاتصال + + + رقم جهة الاتصال + + + معرّف المصدر + + + رقم الحادث + + + المعرّف الخارجي + + + الرقم المرجعي + + + بيانات نموذج البلاغ + + + سبب الحذف + + + الوجهة + + + المضيف المثبّت + + + بيانات الاعتماد المثبّتة + + + مضيف الرمز المميز المثبّت + + + لا توجد وجهة صالحة بعد + + + لا يمكن حماية سير العمل هذا بعد + + + أضف خطوة مفعّلة واحدة على الأقل. + + + لا يمكن أن يحتوي سير العمل المحمي إلا على خطوات API من نوع POST أو PUT. + + + تحتاج كل خطوة إلى بيانات اعتماد. + + + يجب أن تستخدم جميع الخطوات بيانات الاعتماد نفسها. + + + استخدم بيانات اعتماد من نوع رمز Bearer أو مفتاح API أو بيانات اعتماد عميل OAuth2. + + + تستخدم إحدى الخطوات بيانات اعتماد لم تعد موجودة. + + + تحتاج كل خطوة إلى عنوان URL. + + + يجب أن يستخدم عنوان URL لكل خطوة بروتوكول HTTPS. + + + يجب كتابة مضيف عنوان URL صراحةً، لا إنشاؤه من قالب. + + + يجب أن ترسل جميع الخطوات إلى المضيف نفسه. + + + لا يمكن استخدام قيم protected.* في عنوان URL أو في ترويسة. + + + لا يمكن استخدام قيم protected.* في شرط خطوة. + + + في هذا الإصدار، لا يمكن حماية سوى مشغّلات البلاغات. + + + تحتاج بيانات اعتماد OAuth2 إلى عنوان URL للرمز المميز يستخدم HTTPS. + + + لا يحتوي سير العمل هذا على إتاحة محمية، لذا ستظهر قيم protected.* فارغة. قم أولًا بإعداد إتاحة محمية. + + + المستلِم + + + نوع المستلِم + + + جهة مشمولة + + + شريك أعمال + + + اسم المستلِم + + + على سبيل المثال: DMH في المقاطعة، إدارة الحالات في Dynamics 365 + + + الغرض + + + سبب حاجة المستلِم إلى هذه الحقول + + + أشهد بأن المستلِم هو مؤسستنا أو طرف تشمله اتفاقية شريك أعمال مبرمة مع مؤسستنا، وبأنه لا يحتاج إلا إلى الحقول المحددة أعلاه (إصدار التحذير {0}). + + + حفظ المسودة + + + الموافقة والتفعيل + + + طلب الموافقة + + + موافقة + + + تجديد + + + تعليق + + + إلغاء + + + تجاهل المسودة + + + إرسال اختبار ببيانات نموذجية + + + تم إرسال الاختبار ببيانات اصطناعية. أعادت كل خطوة حالة نجاح. + + + لم ينجح الاختبار بالكامل. راجع نتائج الخطوات أدناه وسجل الإفصاح. + + + الخطوة {0}: {1} (HTTP {2}) + + + هل تريد إلغاء هذه الإتاحة؟ لا يمكن التراجع عن هذا الإجراء. لن يعمل سير العمل حتى تتم الموافقة على إتاحة جديدة. + + + هل تريد تعليق هذه الإتاحة؟ لن يعمل سير العمل حتى يوافق عليها أحد المسؤولين مجددًا. + + + هل تريد تجاهل هذه المسودة؟ سيعمل سير العمل مجددًا بقيم REDACTED. + + + تغيّر سير العمل هذا منذ الموافقة عليه. لن يرسل أي شيء حتى يوافق أحد المسؤولين على التهيئة الحالية. + + + تاريخ الطلب: {0} + + + تاريخ الموافقة: {0} + + + تاريخ الانتهاء: {0} + + + بانتظار مسؤول ثانٍ. لا يمكن للشخص الذي طلب الإتاحة الموافقة عليها. + + + لا يمكن تغيير الإتاحة المحمية إلا للمسؤولين الذين يملكون صلاحية «تهيئة إرسال البيانات المحمية». يؤدي تعديل سير العمل هذا إلى تعليق الإتاحة المعتمدة حتى تتم الموافقة عليها مجددًا. + + + ما دام سير العمل هذا يملك إتاحة محمية غير نشطة، يتم تخطي عمليات تشغيله. ولا يعمل أبدًا بقيم REDACTED بدلًا من القيم المحمية. + + + سير العمل المحمي متوقف لإدارتك. شغّله من قسم حماية البيانات لتهيئة إتاحة محمية. + + + ليست لديك صلاحية للقيام بذلك. + + + يلزم إجراء تحقق حديث متعدد العوامل. + + + يجب أن ينفّذ هذا الإجراء مسؤول مسجّل الدخول، لا مفتاح API أو حساب خدمة. + + + حدّد خانة الإشهاد للمتابعة. + + + تغيّر نص التحذير. أعد تحميل الصفحة واقرأه ثم أقرّ به مجددًا. + + + أنت من طلب هذه الإتاحة، لذا يجب أن يوافق عليها مسؤول آخر. + + + لا يمكن تنفيذ هذا الإجراء على الإتاحة في حالتها الحالية. + + + سير العمل غير صالح للحماية. أصلح المشكلات المذكورة وحاول مرة أخرى. + + + لم يتم العثور على سير العمل أو الإتاحة. + + + في هذا الإصدار، لا يمكن حماية سوى مشغّلات البلاغات. + + + تم تحديد عدد كبير جدًا من الحقول. + + + حدّد حقلًا واحدًا على الأقل لإتاحته. + + + تم تحديد حقل غير معروف. + + + أدخل نوع المستلِم واسمه. + + + أدخل الغرض من الإفصاح. + + + يجب تفعيل حماية البيانات المتقدمة أولًا. + + + سير العمل المحمي متوقف لإدارتك. + + + تغيّر سير العمل بعد تقديم الطلب. اطلب الموافقة مجددًا على التهيئة الحالية. + + + لم تقبل الوجهة الطلب. + + + تعذّر إكمال الإجراء. حاول مرة أخرى. + + + سير العمل + + + الحالة + + + الحقول + + + المضيف + + + المستلِم + + + الموافِق + + + تاريخ الانتهاء + + + الإرسالات (30 يومًا) + + + الإجراءات + + + لا يوجد سير عمل لديه إتاحة محمية. + + + فتح سير العمل + + + سجل الإفصاح + + + تم التحقق من سلسلة التدقيق (عدد السجلات: {0}). + + + فشل التحقق من سلسلة التدقيق عند السجل {0}. تواصل مع دعم Resgrid. + + + جدّد من محرر سير العمل، حيث يظهر الإشهاد. + + + سير العمل + + + كل سير العمل + + + معرّف البلاغ + + + من (UTC) + + + إلى (UTC) + + + تصفية + + + تصدير CSV + + + بيانات وصفية فقط. لا يحتوي هذا السجل أبدًا على قيم الحقول أو الحمولات أو نصوص الاستجابات. + + + # + + + وقت الحدوث (UTC) + + + النوع + + + الحدث أو النتيجة + + + البلاغ + + + HTTP + + + البايتات + + + المنفّذ + + + التفاصيل + + + لا توجد سجلات مطابقة. + + + اختبار + + + إفصاح + + + إداري + + + تم الإرسال + + + فشل HTTP + + + فشل فك التشفير + + + محظور: المضيف + + + محظور: الإتاحة + + + محظور: الإدارة + + + محظور: نص مشفّر في الحمولة + + + فشل العرض + + + تم تفعيل سير العمل المحمي + + + تم تعطيل سير العمل المحمي + + + طُلبت الإتاحة + + + تمت الموافقة على الإتاحة + + + عُلّقت الإتاحة + + + أُلغيت الإتاحة + + + انتهت صلاحية الإتاحة + + + تم تدوير بيانات الاعتماد + + + سير عمل محمي توشك صلاحيته على الانتهاء + + + تنتهي صلاحية الإتاحة المحمية لسير العمل «{0}» في {1}. يجب أن يجددها أحد المسؤولين، وإلا سيتوقف سير العمل عن الإرسال. + + + انتهت صلاحية الإتاحة المحمية لسير العمل «{0}» وتوقف سير العمل عن الإرسال. يمكن لأحد المسؤولين تجديدها من محرر سير العمل. + + + فشل سير عمل محمي + + + فشل سير العمل المحمي «{0}» في محاولته الأخيرة (التشغيل {1}، الخطأ {2}). + + + محاولة (سُجّلت قبل الإرسال) + + + طُلب إلغاء اشتراط المعتمِد الثاني + + + لم يعد المعتمِد الثاني مطلوبًا + + + في {0} طلب أحد المسؤولين إيقاف اشتراط معتمِد ثانٍ. يجب أن يؤكد ذلك مسؤول آخر خلال 7 أيام بإلغاء تحديد المربع والحفظ. + + + تم تسجيل طلبك بإيقاف اشتراط معتمِد ثانٍ. يجب أن يؤكده مسؤول آخر قبل أن يصبح نافذًا. + + + قام شخص آخر بتغيير هذا الإذن في الوقت نفسه. أعد تحميل الصفحة وحاول مرة أخرى. + + + تعذّرت كتابة سجل الإفصاحات، لذلك لم يُرسل أي شيء. حاول مرة أخرى. + + + لا يوجد إذن نشط لسير العمل هذا. + + + انتهت صلاحية الإذن. جدّده لاستئناف الإرسال. + + + يمكن لخطوات استدعاء واجهة API فقط (POST أو PUT) إرسال البيانات المحمية. + + + بيانات اعتماد الخطوة ليست تلك المعتمدة لهذا الإذن. + + + مضيف الوجهة ليس المضيف المعتمد لهذا الإذن. + + + يجب أن تستخدم الوجهة بروتوكول HTTPS. + + + ردّت الوجهة بإعادة توجيه. لا تُتبع عمليات إعادة التوجيه أبدًا مع البيانات المحمية. + + + مضيف رموز OAuth2 ليس المضيف المعتمد لهذا الإذن. + + + تعذّر فك تشفير القيم المحمية. لم يُرسل أي شيء. + + + تعذّر تحميل البلاغ لفك التشفير. لم يُرسل أي شيء. + + + تعذّر إنشاء القالب. لم يُرسل أي شيء. + + + المحتوى المُنشأ كبير جدًا. لم يُرسل أي شيء. + + + احتوى المحتوى المُنشأ على بيانات مشفّرة فتم حظره. + + + لم تستجب الوجهة في الوقت المحدد. + + + فشلت الخطوة. لا يُسجَّل السبب لإبقاء القيم المحمية خارج السجلات. + + + معرّفات الشخص (جميع المفاتيح) + + + محتوى غير صالح + + + فشل الإسقاط + + + رفضته الوجهة + + + الاستجابة كبيرة جدًا + + + محظور: لا توجد موافقة Part 2 + + + المحتوى المُنشأ غير صالح لنوع المحتوى الخاص به. لم يُرسل أي شيء. + + + تعذّرت قراءة معرّفات الشخص في البلاغ. لم يُرسل أي شيء. + + + استلمت الوجهة الطلب لكنها رفضته. + + + كانت استجابة الوجهة أكبر من المسموح، لذا تعذّر التحقق منها. + + + لا توجد موافقة مسجّلة وفق 42 CFR Part 2 لهذا البلاغ، لذلك لم تُرسل حقوله الخاضعة لـ Part 2. + + + تم تسليم الطلب، لكن تعذّر حفظ القيم التي أعادتها الوجهة في البلاغ. + + + لم تُصدر نقطة نهاية رموز OAuth2 رمزًا. + + + لم تعد بيانات الاعتماد تُصادق بالطريقة التي اعتمدها هذا الإذن. + + + لا يوجد لبيانات الاعتماد مفتاح توقيع حالي. + + + اسمح إما بحقل معرّفات الشخص كاملًا أو بمفاتيح منفردة، وليس بكليهما. + + + أحد الحقول المحددة مقيّد. حدّد إقرار الحقول المقيّدة. + + + أحد الحقول المحددة يحتوي على معلومات خاضعة لـ 42 CFR Part 2. حدّد إقرار Part 2. + + + تحفظ إحدى الخطوات قيمًا من الاستجابة في معرّفات الشخص، لذا يجب أن يشملها الإذن (الحقل كاملًا أو مفتاحًا واحدًا على الأقل). + + + تعلن إحدى الخطوات عن نوع محتوى لا يمكن للخطوات المحمية استخدامه. + + + قاعدة النجاح لإحدى الخطوات غير مكتملة أو غير معروفة. + + + أحد إدخالات التقاط الاستجابة في خطوة غير صالح (المصدر أو التعبير أو المفتاح). + + + تلتقط إحدى الخطوات قيم استجابة أكثر من المسموح. + + + اسم ترويسة عدم التكرار لإحدى الخطوات غير صالح أو محجوز. + + + قيمة If-None-Exist لإحدى الخطوات طويلة جدًا أو تحتوي على فاصل أسطر. + + + تستخدم بيانات اعتماد OAuth2 طريقة private_key_jwt لكن ليس لديها مفتاح توقيع بعد. احفظ بيانات الاعتماد لإنشاء مفتاح. + + + يضع أحد القوالب قيمة محمية دون json_escape أو xml_escape أو hl7_escape. قد تؤدي علامة اقتباس أو فاصل أسطر في القيمة إلى إفساد المحتوى، فيفشل عندها في التحقق بدلًا من إرساله. + + + مصادقة العميل + + + مفاتيح معرّفات الشخص + + + اسمح بمعرّفات منفردة بدلًا من المجموعة كاملة: لا تصل إلى protected.call.subject_ids إلا المفاتيح المحددة. + + + مفاتيح أخرى (مفصولة بفواصل) + + + الحقول المخصصة للبلاغ + + + يُفك تشفير كل حقل مخصص محدد على حدة ويظهر بالشكل protected.call.udf.<الاسم>. + + + مقيّد + + + 42 CFR Part 2 + + + أؤكد أن هذا المستلم مخوّل باستلام الحقول المقيّدة ({0}). + + + 42 CFR Part 2: تحتوي الحقول المحددة على معلومات عن اضطرابات تعاطي المواد. تتطلب إعادة الإفصاح عنها موافقة خطية من المريض أو أساسًا آخر يسمح به 42 CFR Part 2، ويجب إبلاغ المستلم بأن أي إعادة إفصاح لاحقة محظورة. + + + أقرّ بأن القسم لديه الموافقة أو أي أساس آخر وفق 42 CFR Part 2 مطلوب لإعادة الإفصاح هذه ({0}). + + + لا يُرسل البلاغ إلا عند تحديد "موافقة Part 2 مسجّلة" عليه؛ وإلا تُحظر الخطوة ويُسجَّل ذلك. + + + خيارات التسليم المحمي + + + كيف تؤكد الوجهة الاستلام، وأي القيم المُعادة تُحفظ في البلاغ، وكيف يتم التعرّف على إعادة المحاولات. كل خيار هنا جزء من اعتماد الإذن. + + + قاعدة النجاح + + + المسار + + + القيمة المتوقعة + + + حفظ قيم الاستجابة + + + واحد في كل سطر: المصدر | التعبير | المفتاح. المصادر: json_path وxpath وhl7_field وheader وfhir_location_id. تُحفظ القيم مشفّرة في معرّفات الشخص الخاصة بالبلاغ. + + + ترويسة عدم التكرار + + + FHIR If-None-Exist + + + أي حالة 2xx + + + مسار JSON يساوي + + + XPath يساوي + + + إقرار HL7 (AA / CA) + + + لا يوجد خطأ في OperationOutcome الخاص بـ FHIR + + + نوع المحتوى + + + المفاتيح المحفوظة + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.cs b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.cs new file mode 100644 index 000000000..47d4edd9d --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.cs @@ -0,0 +1,46 @@ +using System.Globalization; +using System.Resources; + +namespace Resgrid.Localization.Areas.User.ProtectedWorkflows +{ + /// Marker type used by ASP.NET Core localization for the Protected Workflows screens. + public class ProtectedWorkflows + { + } + + /// + /// Culture-explicit access to the Protected Workflows resources, for the notices a background worker composes for + /// department administrators (expiry and final-failure). They render in the recipient's language + /// (UserProfile.Language); a missing culture falls back to English and a missing key surfaces the key name. + /// + public static class ProtectedWorkflowsResources + { + private static readonly ResourceManager ResourceManager = new ResourceManager( + typeof(ProtectedWorkflows).FullName!, typeof(ProtectedWorkflows).Assembly); + + public static string Get(string key, string? culture, params object[] arguments) + { + var cultureInfo = GetSupportedCulture(culture); + var value = ResourceManager.GetString(key, cultureInfo) + ?? ResourceManager.GetString(key, CultureInfo.GetCultureInfo("en")) + ?? key; + + return arguments == null || arguments.Length == 0 + ? value + : string.Format(cultureInfo, value, arguments); + } + + private static CultureInfo GetSupportedCulture(string? culture) + { + var candidate = string.IsNullOrWhiteSpace(culture) ? "en" : culture.Trim(); + var separator = candidate.IndexOfAny(new[] { '-', '_' }); + if (separator > 0) + candidate = candidate.Substring(0, separator); + + candidate = candidate.ToLowerInvariant(); + return SupportedLocales.SupportedLanguagesMap.ContainsKey(candidate) + ? CultureInfo.GetCultureInfo(candidate) + : CultureInfo.GetCultureInfo("en"); + } + } +} diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.de.resx b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.de.resx new file mode 100644 index 000000000..3ae705d49 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.de.resx @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Geschützte Workflows + + + Geschützte Workflow-Freigaben + + + Offenlegungsprotokoll für geschützte Workflows + + + Workflows + + + Datenschutz + + + Geschützte Workflows + + + Workflows erhalten standardmäßig REDACTED-Werte. Mit geschützten Workflows kann ein Administrator einen bestimmten Workflow dafür freigeben, ausgewählte geschützte Felder an ein einziges fixiertes HTTPS-Ziel zu senden. Alle anderen Workflows erhalten weiterhin REDACTED-Werte. + + + Geschützte Workflows aktivieren + + + Genehmigung geschützter Workflows durch einen zweiten Administrator verlangen + + + Warnung + + + Geschützte Workflows senden entschlüsselte geschützte Daten, die geschützte Gesundheitsinformationen enthalten können, an von Ihnen konfigurierte externe Systeme. Resgrid kann nicht kontrollieren, wie das empfangende System diese Daten speichert oder verwendet. Aktivieren Sie einen Workflow nur, wenn der Empfänger Ihre Organisation oder eine Partei ist, die durch eine Geschäftspartnervereinbarung mit Ihrer Organisation abgedeckt ist, und geben Sie nur die Felder frei, die der Empfänger benötigt. + + + Ich habe diese Warnung gelesen und nehme sie zur Kenntnis (Version {0}). + + + Warnung am {0} zur Kenntnis genommen (Version {1}). + + + Einstellungen für geschützte Workflows speichern + + + Einstellungen für geschützte Workflows gespeichert. + + + Geschützte Workflows sind nur verfügbar, solange der erweiterte Datenschutz für Ihre Abteilung aktiviert ist. + + + Nur Administratoren mit der Berechtigung „Weitergabe geschützter Daten konfigurieren“ können diese Einstellungen ändern. + + + Geschützte Workflows verwalten + + + Das Ein- oder Ausschalten dieser Einstellung sowie das Genehmigen oder Verlängern einer Freigabe erfordert eine frische Multi-Faktor-Bestätigung. + + + Für diese Aktion ist eine frische Multi-Faktor-Bestätigung erforderlich. Sie werden zur Bestätigung weitergeleitet und anschließend auf diese Seite zurückgebracht; wiederholen Sie die Aktion danach. + + + Geschützte Freigabe + + + Senden Sie ausgewählte geschützte Felder aus diesem Workflow an sein fixiertes HTTPS-Ziel. Nur die von Ihnen angehakten Felder werden entschlüsselt; alle anderen geschützten Werte bleiben REDACTED. + + + Status + + + Nicht geschützt + + + Entwurf + + + Genehmigung ausstehend + + + Aktiv + + + Ausgesetzt + + + Abgelaufen + + + Widerrufen + + + Läuft bald ab + + + Verlängerung wartet auf Genehmigung + + + Geschützt: {0} + + + Konfiguration geändert + + + Geschützte Workflows ausgeschaltet + + + Erweiterter Datenschutz nicht aktiviert + + + Zugangsdaten geändert + + + von einem Administrator ausgesetzt + + + von einem Administrator widerrufen + + + Deaktivierung des erweiterten Datenschutzes + + + Workflow gelöscht + + + Freizugebende Felder + + + Standardmäßig ist nichts ausgewählt. Verweisen Sie in der Ausgabevorlage mit protected.call.<name> auf freigegebene Felder; call.*-Werte bleiben REDACTED. + + + Formulardaten sind zusätzlich in geparster Form als protected.call.form verfügbar. + + + Einsatzname + + + Einsatztyp + + + Einsatzart + + + Einsatznotizen + + + Abschlussnotizen + + + Adresse + + + Geolokalisierung + + + what3words-Adresse + + + Kontaktname + + + Kontaktnummer + + + Quellkennung + + + Einsatznummer + + + Externe Kennung + + + Referenznummer + + + Einsatzformulardaten + + + Löschgrund + + + Ziel + + + Fixierter Host + + + Fixierte Zugangsdaten + + + Fixierter Token-Host + + + Noch kein gültiges Ziel + + + Dieser Workflow kann noch nicht geschützt werden + + + Fügen Sie mindestens einen aktivierten Schritt hinzu. + + + Geschützte Workflows dürfen nur API-Schritte mit POST oder PUT enthalten. + + + Jeder Schritt benötigt Zugangsdaten. + + + Alle Schritte müssen dieselben Zugangsdaten verwenden. + + + Verwenden Sie Zugangsdaten vom Typ Bearer-Token, API-Schlüssel oder OAuth2-Clientanmeldeinformationen. + + + Ein Schritt verwendet Zugangsdaten, die nicht mehr existieren. + + + Jeder Schritt benötigt eine URL. + + + Die URL jedes Schritts muss HTTPS verwenden. + + + Der Host der URL muss ausgeschrieben sein und darf nicht aus einer Vorlage erzeugt werden. + + + Alle Schritte müssen an denselben Host senden. + + + protected.*-Werte dürfen nicht in einer URL oder einem Header verwendet werden. + + + protected.*-Werte dürfen nicht in einer Schrittbedingung verwendet werden. + + + In dieser Version können nur Einsatz-Auslöser geschützt werden. + + + Die OAuth2-Zugangsdaten benötigen eine HTTPS-Token-URL. + + + Dieser Workflow hat keine geschützte Freigabe, daher würden protected.*-Werte leer ausgegeben. Richten Sie zuerst eine geschützte Freigabe ein. + + + Empfänger + + + Empfängertyp + + + Erfasste Einrichtung + + + Geschäftspartner + + + Name des Empfängers + + + Zum Beispiel: DMH des Landkreises, Fallverwaltung in Dynamics 365 + + + Zweck + + + Warum der Empfänger diese Felder benötigt + + + Ich versichere, dass der Empfänger unsere Organisation oder eine Partei ist, die durch eine Geschäftspartnervereinbarung mit unserer Organisation abgedeckt ist, und dass er nur die oben ausgewählten Felder benötigt (Warnungsversion {0}). + + + Entwurf speichern + + + Genehmigen und aktivieren + + + Genehmigung anfordern + + + Genehmigen + + + Verlängern + + + Aussetzen + + + Widerrufen + + + Entwurf verwerfen + + + Test mit Beispieldaten senden + + + Test mit synthetischen Daten gesendet. Jeder Schritt hat einen Erfolgsstatus zurückgegeben. + + + Der Test war nicht vollständig erfolgreich. Prüfen Sie die Schrittergebnisse unten und das Offenlegungsprotokoll. + + + Schritt {0}: {1} (HTTP {2}) + + + Diese Freigabe widerrufen? Dies kann nicht rückgängig gemacht werden. Der Workflow wird erst wieder ausgeführt, wenn eine neue Freigabe genehmigt wurde. + + + Diese Freigabe aussetzen? Der Workflow wird erst wieder ausgeführt, wenn ein Administrator sie erneut genehmigt. + + + Diesen Entwurf verwerfen? Der Workflow wird dann wieder mit REDACTED-Werten ausgeführt. + + + Dieser Workflow wurde seit seiner Genehmigung geändert. Er sendet nichts, bis ein Administrator die aktuelle Konfiguration genehmigt. + + + Angefordert am {0} + + + Genehmigt am {0} + + + Läuft ab am {0} + + + Warten auf einen zweiten Administrator. Die Person, die die Freigabe angefordert hat, kann sie nicht genehmigen. + + + Nur Administratoren mit der Berechtigung „Weitergabe geschützter Daten konfigurieren“ können die geschützte Freigabe ändern. Das Bearbeiten dieses Workflows setzt eine genehmigte Freigabe aus, bis sie erneut genehmigt wird. + + + Solange dieser Workflow eine geschützte Freigabe hat, die nicht aktiv ist, werden seine Ausführungen übersprungen. Er wird niemals mit REDACTED-Werten anstelle der geschützten Werte ausgeführt. + + + Geschützte Workflows sind für Ihre Abteilung ausgeschaltet. Schalten Sie sie unter Datenschutz ein, um eine geschützte Freigabe zu konfigurieren. + + + Sie sind nicht berechtigt, diese Aktion auszuführen. + + + Eine frische Multi-Faktor-Bestätigung ist erforderlich. + + + Diese Aktion muss von einem angemeldeten Administrator ausgeführt werden, nicht über einen API-Schlüssel oder ein Dienstkonto. + + + Setzen Sie das Häkchen bei der Versicherung, um fortzufahren. + + + Der Warnungstext hat sich geändert. Laden Sie die Seite neu, lesen Sie die Warnung und nehmen Sie sie erneut zur Kenntnis. + + + Sie haben diese Freigabe angefordert, daher muss ein anderer Administrator sie genehmigen. + + + Diese Aktion ist im aktuellen Zustand der Freigabe nicht möglich. + + + Der Workflow erfüllt die Voraussetzungen für den Schutz nicht. Beheben Sie die aufgeführten Probleme und versuchen Sie es erneut. + + + Der Workflow oder die Freigabe wurde nicht gefunden. + + + In dieser Version können nur Einsatz-Auslöser geschützt werden. + + + Es sind zu viele Felder ausgewählt. + + + Wählen Sie mindestens ein freizugebendes Feld aus. + + + Es wurde ein unbekanntes Feld ausgewählt. + + + Geben Sie Typ und Namen des Empfängers ein. + + + Geben Sie den Zweck der Offenlegung ein. + + + Der erweiterte Datenschutz muss zuerst aktiviert werden. + + + Geschützte Workflows sind für Ihre Abteilung ausgeschaltet. + + + Der Workflow wurde nach der Anforderung geändert. Fordern Sie die Genehmigung für die aktuelle Konfiguration erneut an. + + + Das Ziel hat die Anfrage nicht angenommen. + + + Die Aktion konnte nicht abgeschlossen werden. Versuchen Sie es erneut. + + + Workflow + + + Status + + + Felder + + + Host + + + Empfänger + + + Genehmigt von + + + Läuft ab + + + Sendungen (30 Tage) + + + Aktionen + + + Kein Workflow hat eine geschützte Freigabe. + + + Workflow öffnen + + + Offenlegungsprotokoll + + + Prüfkette verifiziert ({0} Datensätze). + + + Die Verifizierung der Prüfkette ist bei Datensatz {0} fehlgeschlagen. Wenden Sie sich an den Resgrid-Support. + + + Verlängern Sie die Freigabe im Workflow-Editor, in dem die Versicherung angezeigt wird. + + + Workflow + + + Alle Workflows + + + Einsatz-ID + + + Von (UTC) + + + Bis (UTC) + + + Filtern + + + CSV exportieren + + + Nur Metadaten. Dieses Protokoll enthält niemals Feldwerte, Nutzdaten oder Antworttexte. + + + # + + + Zeitpunkt (UTC) + + + Typ + + + Ereignis oder Ergebnis + + + Einsatz + + + HTTP + + + Bytes + + + Akteur + + + Details + + + Keine passenden Einträge. + + + Test + + + Offenlegung + + + Administrativ + + + Gesendet + + + HTTP fehlgeschlagen + + + Entschlüsselung fehlgeschlagen + + + Blockiert: Host + + + Blockiert: Freigabe + + + Blockiert: Abteilung + + + Blockiert: Chiffretext in Nutzdaten + + + Rendern fehlgeschlagen + + + Geschützte Workflows aktiviert + + + Geschützte Workflows deaktiviert + + + Freigabe angefordert + + + Freigabe genehmigt + + + Freigabe ausgesetzt + + + Freigabe widerrufen + + + Freigabe abgelaufen + + + Zugangsdaten erneuert + + + Geschützter Workflow läuft bald ab + + + Die geschützte Freigabe für den Workflow „{0}“ läuft am {1} ab. Ein Administrator muss sie verlängern, sonst stellt der Workflow das Senden ein. + + + Die geschützte Freigabe für den Workflow „{0}“ ist abgelaufen, und der Workflow hat das Senden eingestellt. Ein Administrator kann sie im Workflow-Editor verlängern. + + + Geschützter Workflow fehlgeschlagen + + + Der geschützte Workflow „{0}“ ist beim letzten Versuch fehlgeschlagen (Ausführung {1}, Fehler {2}). + + + Versucht (vor dem Senden protokolliert) + + + Aufhebung des zweiten Genehmigers beantragt + + + Zweiter Genehmiger nicht mehr erforderlich + + + Am {0} hat ein Administrator beantragt, keinen zweiten Genehmiger mehr zu verlangen. Ein anderer Administrator muss dies innerhalb von 7 Tagen bestätigen, indem er das Kontrollkästchen deaktiviert und speichert. + + + Ihr Antrag, keinen zweiten Genehmiger mehr zu verlangen, wurde erfasst. Ein anderer Administrator muss ihn bestätigen, bevor er wirksam wird. + + + Jemand anderes hat diese Freigabe gleichzeitig geändert. Laden Sie die Seite neu und versuchen Sie es erneut. + + + Das Offenlegungsprotokoll konnte nicht geschrieben werden, daher wurde nichts gesendet. Versuchen Sie es erneut. + + + Dieser Workflow hat keine aktive Freigabe. + + + Die Freigabe ist abgelaufen. Erneuern Sie sie, um das Senden fortzusetzen. + + + Nur API-Aufruf-Schritte (POST oder PUT) können geschützte Daten senden. + + + Die Zugangsdaten des Schritts sind nicht die für diese Freigabe genehmigten. + + + Der Zielhost ist nicht der für diese Freigabe genehmigte. + + + Das Ziel muss HTTPS verwenden. + + + Das Ziel hat mit einer Weiterleitung geantwortet. Weiterleitungen werden bei geschützten Daten nie befolgt. + + + Der OAuth2-Token-Host ist nicht der für diese Freigabe genehmigte. + + + Die geschützten Werte konnten nicht entschlüsselt werden. Es wurde nichts gesendet. + + + Der Einsatz konnte nicht zur Entschlüsselung geladen werden. Es wurde nichts gesendet. + + + Die Vorlage konnte nicht gerendert werden. Es wurde nichts gesendet. + + + Der gerenderte Inhalt ist zu groß. Es wurde nichts gesendet. + + + Der gerenderte Inhalt enthielt verschlüsselte Daten und wurde blockiert. + + + Das Ziel hat nicht rechtzeitig geantwortet. + + + Der Schritt ist fehlgeschlagen. Die Ursache wird nicht protokolliert, damit geschützte Werte nicht in die Protokolle gelangen. + + + Personenkennungen (alle Schlüssel) + + + Ungültiger Inhalt + + + Projektion fehlgeschlagen + + + Vom Ziel abgelehnt + + + Antwort zu groß + + + Blockiert: keine Part-2-Einwilligung + + + Der gerenderte Inhalt ist für seinen Inhaltstyp nicht gültig. Es wurde nichts gesendet. + + + Die Personenkennungen des Einsatzes konnten nicht gelesen werden. Es wurde nichts gesendet. + + + Das Ziel hat die Anfrage empfangen, aber abgelehnt. + + + Die Antwort des Ziels war größer als erlaubt und konnte daher nicht geprüft werden. + + + Für den Einsatz ist keine Einwilligung nach 42 CFR Part 2 hinterlegt, daher wurden seine Part-2-Felder nicht gesendet. + + + Die Anfrage wurde zugestellt, aber die vom Ziel zurückgegebenen Werte konnten nicht am Einsatz gespeichert werden. + + + Der OAuth2-Token-Endpunkt hat kein Token ausgestellt. + + + Die Zugangsdaten authentifizieren sich nicht mehr auf die Weise, die diese Freigabe genehmigt hat. + + + Die Zugangsdaten haben keinen aktuellen Signaturschlüssel. + + + Geben Sie entweder das ganze Feld der Personenkennungen oder einzelne Schlüssel frei, nicht beides. + + + Ein ausgewähltes Feld ist eingeschränkt. Bestätigen Sie die Erklärung für eingeschränkte Felder. + + + Ein ausgewähltes Feld enthält Informationen nach 42 CFR Part 2. Bestätigen Sie die Part-2-Erklärung. + + + Ein Schritt speichert Antwortwerte in den Personenkennungen, daher muss die Freigabe diese enthalten (das ganze Feld oder mindestens einen Schlüssel). + + + Ein Schritt gibt einen Inhaltstyp an, den geschützte Schritte nicht verwenden dürfen. + + + Die Erfolgsregel eines Schritts ist unvollständig oder unbekannt. + + + Ein Eintrag zur Antworterfassung eines Schritts ist ungültig (Quelle, Ausdruck oder Schlüssel). + + + Ein Schritt erfasst mehr Antwortwerte als erlaubt. + + + Der Name des Idempotenz-Headers eines Schritts ist ungültig oder reserviert. + + + Der If-None-Exist-Wert eines Schritts ist zu lang oder enthält einen Zeilenumbruch. + + + Die OAuth2-Zugangsdaten verwenden private_key_jwt, haben aber noch keinen Signaturschlüssel. Speichern Sie die Zugangsdaten, um einen zu erzeugen. + + + Eine Vorlage setzt einen geschützten Wert ohne json_escape, xml_escape oder hl7_escape ein. Ein Anführungszeichen oder Zeilenumbruch im Wert kann den Inhalt beschädigen, der dann die Prüfung nicht besteht, statt gesendet zu werden. + + + Client-Authentifizierung + + + Schlüssel der Personenkennungen + + + Geben Sie einzelne Kennungen statt des ganzen Satzes frei: Nur die angehakten Schlüssel gelangen in protected.call.subject_ids. + + + Weitere Schlüssel (kommagetrennt) + + + Benutzerdefinierte Einsatzfelder + + + Jedes angehakte benutzerdefinierte Feld wird einzeln entschlüsselt und als protected.call.udf.<Name> ausgegeben. + + + Eingeschränkt + + + 42 CFR Part 2 + + + Ich bestätige, dass dieser Empfänger berechtigt ist, eingeschränkte Felder zu erhalten ({0}). + + + 42 CFR Part 2: Die ausgewählten Felder enthalten Informationen zu Substanzgebrauchsstörungen. Ihre Weitergabe erfordert die schriftliche Einwilligung des Patienten oder eine andere nach 42 CFR Part 2 zulässige Grundlage, und der Empfänger muss darauf hingewiesen werden, dass eine weitere Weitergabe untersagt ist. + + + Ich erkläre, dass der Abteilung die Einwilligung oder eine andere nach 42 CFR Part 2 erforderliche Grundlage für diese Weitergabe vorliegt ({0}). + + + Ein Einsatz wird nur gesendet, wenn "Part-2-Einwilligung liegt vor" gesetzt ist; andernfalls wird der Schritt blockiert und protokolliert. + + + Optionen für geschützte Zustellung + + + Wie das Ziel den Empfang bestätigt, welche zurückgegebenen Werte am Einsatz gespeichert werden und wie Wiederholungen erkannt werden. Jede Option ist Teil der Freigabegenehmigung. + + + Erfolgsregel + + + Pfad + + + Erwarteter Wert + + + Antwortwerte speichern + + + Einer pro Zeile: Quelle | Ausdruck | Schlüssel. Quellen: json_path, xpath, hl7_field, header, fhir_location_id. Die Werte werden verschlüsselt in den Personenkennungen des Einsatzes gespeichert. + + + Idempotenz-Header + + + FHIR If-None-Exist + + + Beliebiger 2xx-Status + + + JSON-Pfad ist gleich + + + XPath ist gleich + + + HL7-Bestätigung (AA / CA) + + + Kein Fehler im FHIR-OperationOutcome + + + Inhaltstyp + + + Gespeicherte Schlüssel + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.el.resx b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.el.resx new file mode 100644 index 000000000..d39089d03 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.el.resx @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Προστατευμένες ροές εργασίας + + + Προστατευμένες αποδεσμεύσεις ροών εργασίας + + + Αρχείο γνωστοποιήσεων προστατευμένων ροών εργασίας + + + Ροές εργασίας + + + Προστασία δεδομένων + + + Προστατευμένες ροές εργασίας + + + Από προεπιλογή, οι ροές εργασίας λαμβάνουν τιμές REDACTED. Οι προστατευμένες ροές εργασίας επιτρέπουν σε έναν διαχειριστή να εγκρίνει μία συγκεκριμένη ροή εργασίας ώστε να στέλνει επιλεγμένα προστατευμένα πεδία σε έναν μόνο δεσμευμένο προορισμό HTTPS. Όλες οι άλλες ροές εργασίας συνεχίζουν να λαμβάνουν τιμές REDACTED. + + + Ενεργοποίηση προστατευμένων ροών εργασίας + + + Να απαιτείται έγκριση των προστατευμένων ροών εργασίας από δεύτερο διαχειριστή + + + Προειδοποίηση + + + Οι προστατευμένες ροές εργασίας στέλνουν αποκρυπτογραφημένα προστατευμένα δεδομένα, τα οποία ενδέχεται να περιλαμβάνουν προστατευμένες πληροφορίες υγείας, σε εξωτερικά συστήματα που ρυθμίζετε εσείς. Η Resgrid δεν μπορεί να ελέγξει τον τρόπο με τον οποίο το σύστημα παραλήπτη αποθηκεύει ή χρησιμοποιεί αυτά τα δεδομένα. Ενεργοποιήστε μια ροή εργασίας μόνο εάν ο παραλήπτης είναι ο οργανισμός σας ή μέρος που καλύπτεται από συμφωνία επιχειρηματικού συνεργάτη με τον οργανισμό σας, και αποδεσμεύστε μόνο τα πεδία που χρειάζεται ο παραλήπτης. + + + Έχω διαβάσει αυτή την προειδοποίηση και τη λαμβάνω υπόψη (έκδοση {0}). + + + Η προειδοποίηση επιβεβαιώθηκε στις {0} (έκδοση {1}). + + + Αποθήκευση ρυθμίσεων προστατευμένων ροών εργασίας + + + Οι ρυθμίσεις προστατευμένων ροών εργασίας αποθηκεύτηκαν. + + + Οι προστατευμένες ροές εργασίας είναι διαθέσιμες μόνο όσο η προηγμένη προστασία δεδομένων είναι ενεργοποιημένη για το τμήμα σας. + + + Μόνο διαχειριστές με το δικαίωμα «Ρύθμιση αποστολής προστατευμένων δεδομένων» μπορούν να αλλάξουν αυτές τις ρυθμίσεις. + + + Διαχείριση προστατευμένων ροών εργασίας + + + Η ενεργοποίηση ή απενεργοποίηση αυτής της ρύθμισης, καθώς και η έγκριση ή ανανέωση μιας αποδέσμευσης, απαιτεί πρόσφατη επαλήθευση πολλαπλών παραγόντων. + + + Αυτή η ενέργεια απαιτεί πρόσφατη επαλήθευση πολλαπλών παραγόντων. Θα μεταφερθείτε στην επαλήθευση και στη συνέχεια θα επιστρέψετε σε αυτή τη σελίδα· έπειτα επαναλάβετε την ενέργεια. + + + Προστατευμένη αποδέσμευση + + + Στείλτε επιλεγμένα προστατευμένα πεδία από αυτή τη ροή εργασίας στον δεσμευμένο προορισμό HTTPS της. Αποκρυπτογραφούνται μόνο τα πεδία που επιλέγετε· όλες οι άλλες προστατευμένες τιμές παραμένουν REDACTED. + + + Κατάσταση + + + Χωρίς προστασία + + + Πρόχειρο + + + Σε αναμονή έγκρισης + + + Ενεργή + + + Σε αναστολή + + + Έληξε + + + Ανακλήθηκε + + + Λήγει σύντομα + + + Ανανέωση σε αναμονή έγκρισης + + + Προστατευμένο: {0} + + + αλλαγή ρυθμίσεων + + + οι προστατευμένες ροές εργασίας απενεργοποιήθηκαν + + + η προηγμένη προστασία δεδομένων δεν είναι ενεργοποιημένη + + + αλλαγή διαπιστευτηρίου + + + ανεστάλη από διαχειριστή + + + ανακλήθηκε από διαχειριστή + + + απενεργοποίηση της προηγμένης προστασίας δεδομένων + + + η ροή εργασίας διαγράφηκε + + + Πεδία προς αποδέσμευση + + + Από προεπιλογή δεν είναι επιλεγμένο τίποτα. Στο πρότυπο εξόδου, αναφερθείτε στα αποδεσμευμένα πεδία ως protected.call.<name>· οι τιμές call.* παραμένουν REDACTED. + + + Τα δεδομένα της φόρμας είναι επίσης διαθέσιμα σε αναλυμένη μορφή ως protected.call.form. + + + Όνομα κλήσης + + + Τύπος κλήσης + + + Φύση της κλήσης + + + Σημειώσεις κλήσης + + + Σημειώσεις ολοκλήρωσης + + + Διεύθυνση + + + Γεωεντοπισμός + + + Διεύθυνση what3words + + + Όνομα επαφής + + + Αριθμός επαφής + + + Αναγνωριστικό πηγής + + + Αριθμός συμβάντος + + + Εξωτερικό αναγνωριστικό + + + Αριθμός αναφοράς + + + Δεδομένα φόρμας κλήσης + + + Αιτία διαγραφής + + + Προορισμός + + + Δεσμευμένος διακομιστής + + + Δεσμευμένο διαπιστευτήριο + + + Δεσμευμένος διακομιστής διακριτικού + + + Δεν υπάρχει ακόμη έγκυρος προορισμός + + + Αυτή η ροή εργασίας δεν μπορεί ακόμη να προστατευθεί + + + Προσθέστε τουλάχιστον ένα ενεργοποιημένο βήμα. + + + Οι προστατευμένες ροές εργασίας μπορούν να περιέχουν μόνο βήματα API POST ή PUT. + + + Κάθε βήμα χρειάζεται διαπιστευτήριο. + + + Όλα τα βήματα πρέπει να χρησιμοποιούν το ίδιο διαπιστευτήριο. + + + Χρησιμοποιήστε διαπιστευτήριο τύπου διακριτικό Bearer, κλειδί API ή διαπιστευτήρια πελάτη OAuth2. + + + Ένα βήμα χρησιμοποιεί διαπιστευτήριο που δεν υπάρχει πλέον. + + + Κάθε βήμα χρειάζεται URL. + + + Το URL κάθε βήματος πρέπει να χρησιμοποιεί HTTPS. + + + Ο διακομιστής του URL πρέπει να γράφεται ρητά και όχι να δημιουργείται από πρότυπο. + + + Όλα τα βήματα πρέπει να στέλνουν στον ίδιο διακομιστή. + + + Οι τιμές protected.* δεν μπορούν να χρησιμοποιηθούν σε URL ή σε κεφαλίδα. + + + Οι τιμές protected.* δεν μπορούν να χρησιμοποιηθούν σε συνθήκη βήματος. + + + Σε αυτή την έκδοση μπορούν να προστατευθούν μόνο τα συμβάντα ενεργοποίησης κλήσεων. + + + Το διαπιστευτήριο OAuth2 χρειάζεται URL διακριτικού HTTPS. + + + Αυτή η ροή εργασίας δεν έχει προστατευμένη αποδέσμευση, επομένως οι τιμές protected.* θα εμφανίζονταν κενές. Ρυθμίστε πρώτα μια προστατευμένη αποδέσμευση. + + + Παραλήπτης + + + Τύπος παραλήπτη + + + Καλυπτόμενη οντότητα + + + Επιχειρηματικός συνεργάτης + + + Όνομα παραλήπτη + + + Για παράδειγμα: DMH της κομητείας, διαχείριση υποθέσεων στο Dynamics 365 + + + Σκοπός + + + Γιατί ο παραλήπτης χρειάζεται αυτά τα πεδία + + + Βεβαιώνω ότι ο παραλήπτης είναι ο οργανισμός μας ή μέρος που καλύπτεται από συμφωνία επιχειρηματικού συνεργάτη με τον οργανισμό μας, και ότι χρειάζεται μόνο τα πεδία που επιλέχθηκαν παραπάνω (έκδοση προειδοποίησης {0}). + + + Αποθήκευση πρόχειρου + + + Έγκριση και ενεργοποίηση + + + Αίτημα έγκρισης + + + Έγκριση + + + Ανανέωση + + + Αναστολή + + + Ανάκληση + + + Απόρριψη πρόχειρου + + + Αποστολή δοκιμής με δείγμα δεδομένων + + + Η δοκιμή στάλθηκε με συνθετικά δεδομένα. Κάθε βήμα επέστρεψε κατάσταση επιτυχίας. + + + Η δοκιμή δεν ολοκληρώθηκε πλήρως με επιτυχία. Ελέγξτε τα αποτελέσματα των βημάτων παρακάτω και το αρχείο γνωστοποιήσεων. + + + Βήμα {0}: {1} (HTTP {2}) + + + Ανάκληση αυτής της αποδέσμευσης; Η ενέργεια δεν μπορεί να αναιρεθεί. Η ροή εργασίας δεν θα εκτελείται μέχρι να εγκριθεί νέα αποδέσμευση. + + + Αναστολή αυτής της αποδέσμευσης; Η ροή εργασίας δεν θα εκτελείται μέχρι να την εγκρίνει ξανά ένας διαχειριστής. + + + Απόρριψη αυτού του πρόχειρου; Η ροή εργασίας θα εκτελείται ξανά με τιμές REDACTED. + + + Αυτή η ροή εργασίας έχει αλλάξει από τότε που εγκρίθηκε. Δεν θα στείλει τίποτα μέχρι ένας διαχειριστής να εγκρίνει τις τρέχουσες ρυθμίσεις. + + + Ζητήθηκε στις {0} + + + Εγκρίθηκε στις {0} + + + Λήγει στις {0} + + + Αναμονή για δεύτερο διαχειριστή. Το άτομο που ζήτησε την αποδέσμευση δεν μπορεί να την εγκρίνει. + + + Μόνο διαχειριστές με το δικαίωμα «Ρύθμιση αποστολής προστατευμένων δεδομένων» μπορούν να αλλάξουν την προστατευμένη αποδέσμευση. Η επεξεργασία αυτής της ροής εργασίας αναστέλλει μια εγκεκριμένη αποδέσμευση μέχρι να εγκριθεί ξανά. + + + Όσο αυτή η ροή εργασίας έχει προστατευμένη αποδέσμευση που δεν είναι ενεργή, οι εκτελέσεις της παραλείπονται. Δεν εκτελείται ποτέ με τιμές REDACTED στη θέση των προστατευμένων. + + + Οι προστατευμένες ροές εργασίας είναι απενεργοποιημένες για το τμήμα σας. Ενεργοποιήστε τις στην ενότητα Προστασία δεδομένων για να ρυθμίσετε μια προστατευμένη αποδέσμευση. + + + Δεν έχετε δικαίωμα να εκτελέσετε αυτή την ενέργεια. + + + Απαιτείται πρόσφατη επαλήθευση πολλαπλών παραγόντων. + + + Αυτή η ενέργεια πρέπει να εκτελεστεί από συνδεδεμένο διαχειριστή, όχι από κλειδί API ή λογαριασμό υπηρεσίας. + + + Επιλέξτε τη βεβαίωση για να συνεχίσετε. + + + Το κείμενο της προειδοποίησης έχει αλλάξει. Φορτώστε ξανά τη σελίδα, διαβάστε την και επιβεβαιώστε την εκ νέου. + + + Εσείς ζητήσατε αυτή την αποδέσμευση, επομένως πρέπει να την εγκρίνει άλλος διαχειριστής. + + + Αυτή η ενέργεια δεν είναι δυνατή στην τρέχουσα κατάσταση της αποδέσμευσης. + + + Η ροή εργασίας δεν πληροί τις προϋποθέσεις προστασίας. Διορθώστε τα προβλήματα που αναφέρονται και δοκιμάστε ξανά. + + + Η ροή εργασίας ή η αποδέσμευση δεν βρέθηκε. + + + Σε αυτή την έκδοση μπορούν να προστατευθούν μόνο τα συμβάντα ενεργοποίησης κλήσεων. + + + Έχουν επιλεγεί πάρα πολλά πεδία. + + + Επιλέξτε τουλάχιστον ένα πεδίο προς αποδέσμευση. + + + Επιλέχθηκε άγνωστο πεδίο. + + + Εισαγάγετε τον τύπο και το όνομα του παραλήπτη. + + + Εισαγάγετε τον σκοπό της γνωστοποίησης. + + + Πρέπει πρώτα να ενεργοποιηθεί η προηγμένη προστασία δεδομένων. + + + Οι προστατευμένες ροές εργασίας είναι απενεργοποιημένες για το τμήμα σας. + + + Η ροή εργασίας άλλαξε μετά το αίτημα. Ζητήστε ξανά έγκριση για τις τρέχουσες ρυθμίσεις. + + + Ο προορισμός δεν αποδέχθηκε το αίτημα. + + + Δεν ήταν δυνατή η ολοκλήρωση της ενέργειας. Δοκιμάστε ξανά. + + + Ροή εργασίας + + + Κατάσταση + + + Πεδία + + + Διακομιστής + + + Παραλήπτης + + + Εγκρίθηκε από + + + Λήξη + + + Αποστολές (30 ημέρες) + + + Ενέργειες + + + Καμία ροή εργασίας δεν έχει προστατευμένη αποδέσμευση. + + + Άνοιγμα ροής εργασίας + + + Αρχείο γνωστοποιήσεων + + + Η αλυσίδα ελέγχου επαληθεύτηκε ({0} εγγραφές). + + + Η επαλήθευση της αλυσίδας ελέγχου απέτυχε στην εγγραφή {0}. Επικοινωνήστε με την υποστήριξη της Resgrid. + + + Ανανεώστε από το πρόγραμμα επεξεργασίας της ροής εργασίας, όπου εμφανίζεται η βεβαίωση. + + + Ροή εργασίας + + + Όλες οι ροές εργασίας + + + Αναγνωριστικό κλήσης + + + Από (UTC) + + + Έως (UTC) + + + Φιλτράρισμα + + + Εξαγωγή CSV + + + Μόνο μεταδεδομένα. Αυτό το αρχείο δεν περιέχει ποτέ τιμές πεδίων, ωφέλιμα φορτία ή σώματα αποκρίσεων. + + + # + + + Χρόνος (UTC) + + + Τύπος + + + Συμβάν ή αποτέλεσμα + + + Κλήση + + + HTTP + + + Byte + + + Εκτελών + + + Λεπτομέρειες + + + Δεν βρέθηκαν εγγραφές που να ταιριάζουν. + + + δοκιμή + + + Γνωστοποίηση + + + Διοικητικό + + + Στάλθηκε + + + Αποτυχία HTTP + + + Αποτυχία αποκρυπτογράφησης + + + Αποκλείστηκε: διακομιστής + + + Αποκλείστηκε: αποδέσμευση + + + Αποκλείστηκε: τμήμα + + + Αποκλείστηκε: κρυπτογραφημένο κείμενο στο φορτίο + + + Αποτυχία απόδοσης + + + Οι προστατευμένες ροές εργασίας ενεργοποιήθηκαν + + + Οι προστατευμένες ροές εργασίας απενεργοποιήθηκαν + + + Ζητήθηκε αποδέσμευση + + + Η αποδέσμευση εγκρίθηκε + + + Η αποδέσμευση ανεστάλη + + + Η αποδέσμευση ανακλήθηκε + + + Η αποδέσμευση έληξε + + + Εναλλαγή διαπιστευτηρίου + + + Προστατευμένη ροή εργασίας λήγει σύντομα + + + Η προστατευμένη αποδέσμευση για τη ροή εργασίας «{0}» λήγει στις {1}. Ένας διαχειριστής πρέπει να την ανανεώσει, διαφορετικά η ροή εργασίας θα σταματήσει να στέλνει. + + + Η προστατευμένη αποδέσμευση για τη ροή εργασίας «{0}» έληξε και η ροή εργασίας σταμάτησε να στέλνει. Ένας διαχειριστής μπορεί να την ανανεώσει από το πρόγραμμα επεξεργασίας της ροής εργασίας. + + + Αποτυχία προστατευμένης ροής εργασίας + + + Η προστατευμένη ροή εργασίας «{0}» απέτυχε στην τελευταία της απόπειρα (εκτέλεση {1}, σφάλμα {2}). + + + Απόπειρα (καταγράφηκε πριν από την αποστολή) + + + Ζητήθηκε κατάργηση του δεύτερου εγκρίνοντος + + + Δεν απαιτείται πλέον δεύτερος εγκρίνων + + + Στις {0} ένας διαχειριστής ζήτησε να μην απαιτείται πλέον δεύτερος εγκρίνων. Ένας άλλος διαχειριστής πρέπει να το επιβεβαιώσει εντός 7 ημερών αποεπιλέγοντας το πλαίσιο και αποθηκεύοντας. + + + Το αίτημά σας να μην απαιτείται πλέον δεύτερος εγκρίνων καταγράφηκε. Ένας άλλος διαχειριστής πρέπει να το επιβεβαιώσει πριν τεθεί σε ισχύ. + + + Κάποιος άλλος άλλαξε αυτή την έγκριση ταυτόχρονα. Φορτώστε ξανά τη σελίδα και δοκιμάστε πάλι. + + + Δεν ήταν δυνατή η εγγραφή στο αρχείο κοινοποιήσεων, οπότε δεν στάλθηκε τίποτα. Δοκιμάστε ξανά. + + + Αυτή η ροή εργασιών δεν έχει ενεργή έγκριση. + + + Η έγκριση έχει λήξει. Ανανεώστε την για να συνεχιστούν οι αποστολές. + + + Μόνο βήματα κλήσης API (POST ή PUT) μπορούν να στέλνουν προστατευμένα δεδομένα. + + + Τα διαπιστευτήρια του βήματος δεν είναι αυτά που εγκρίθηκαν για αυτή την έγκριση. + + + Ο κεντρικός υπολογιστής προορισμού δεν είναι αυτός που εγκρίθηκε για αυτή την έγκριση. + + + Ο προορισμός πρέπει να χρησιμοποιεί HTTPS. + + + Ο προορισμός απάντησε με ανακατεύθυνση. Οι ανακατευθύνσεις δεν ακολουθούνται ποτέ για προστατευμένα δεδομένα. + + + Ο κεντρικός υπολογιστής διακριτικών OAuth2 δεν είναι αυτός που εγκρίθηκε για αυτή την έγκριση. + + + Δεν ήταν δυνατή η αποκρυπτογράφηση των προστατευμένων τιμών. Δεν στάλθηκε τίποτα. + + + Δεν ήταν δυνατή η φόρτωση της κλήσης για αποκρυπτογράφηση. Δεν στάλθηκε τίποτα. + + + Δεν ήταν δυνατή η δημιουργία του προτύπου. Δεν στάλθηκε τίποτα. + + + Το περιεχόμενο που δημιουργήθηκε είναι πολύ μεγάλο. Δεν στάλθηκε τίποτα. + + + Το περιεχόμενο που δημιουργήθηκε περιείχε κρυπτογραφημένα δεδομένα και αποκλείστηκε. + + + Ο προορισμός δεν απάντησε εγκαίρως. + + + Το βήμα απέτυχε. Η αιτία δεν καταγράφεται, ώστε οι προστατευμένες τιμές να μένουν εκτός των αρχείων καταγραφής. + + + Αναγνωριστικά προσώπου (όλα τα κλειδιά) + + + Μη έγκυρο περιεχόμενο + + + Αποτυχία προβολής + + + Απορρίφθηκε από τον προορισμό + + + Πολύ μεγάλη απάντηση + + + Αποκλεισμός: χωρίς συγκατάθεση Part 2 + + + Το περιεχόμενο που δημιουργήθηκε δεν είναι έγκυρο για τον τύπο περιεχομένου του. Δεν στάλθηκε τίποτα. + + + Δεν ήταν δυνατή η ανάγνωση των αναγνωριστικών προσώπου της κλήσης. Δεν στάλθηκε τίποτα. + + + Ο προορισμός έλαβε το αίτημα αλλά το απέρριψε. + + + Η απάντηση του προορισμού ήταν μεγαλύτερη από το επιτρεπόμενο, οπότε δεν ήταν δυνατός ο έλεγχός της. + + + Η κλήση δεν έχει καταχωρισμένη συγκατάθεση κατά 42 CFR Part 2, οπότε τα πεδία Part 2 δεν στάλθηκαν. + + + Το αίτημα παραδόθηκε, αλλά οι τιμές που επέστρεψε ο προορισμός δεν ήταν δυνατό να αποθηκευτούν στην κλήση. + + + Το τελικό σημείο διακριτικών OAuth2 δεν εξέδωσε διακριτικό. + + + Τα διαπιστευτήρια δεν πιστοποιούνται πλέον με τον τρόπο που ενέκρινε αυτή η έγκριση. + + + Τα διαπιστευτήρια δεν έχουν τρέχον κλειδί υπογραφής. + + + Εγκρίνετε είτε ολόκληρο το πεδίο αναγνωριστικών προσώπου είτε μεμονωμένα κλειδιά, όχι και τα δύο. + + + Ένα επιλεγμένο πεδίο είναι περιορισμένο. Επιλέξτε τη βεβαίωση για τα περιορισμένα πεδία. + + + Ένα επιλεγμένο πεδίο περιέχει πληροφορίες 42 CFR Part 2. Επιλέξτε τη βεβαίωση Part 2. + + + Ένα βήμα αποθηκεύει τιμές της απάντησης στα αναγνωριστικά προσώπου, οπότε η έγκριση πρέπει να τα περιλαμβάνει (ολόκληρο το πεδίο ή τουλάχιστον ένα κλειδί). + + + Ένα βήμα δηλώνει τύπο περιεχομένου που δεν επιτρέπεται στα προστατευμένα βήματα. + + + Ο κανόνας επιτυχίας ενός βήματος είναι ελλιπής ή άγνωστος. + + + Μια καταχώριση καταγραφής απάντησης ενός βήματος δεν είναι έγκυρη (πηγή, έκφραση ή κλειδί). + + + Ένα βήμα καταγράφει περισσότερες τιμές απάντησης από όσες επιτρέπονται. + + + Το όνομα της κεφαλίδας idempotency ενός βήματος δεν είναι έγκυρο ή είναι δεσμευμένο. + + + Η τιμή If-None-Exist ενός βήματος είναι πολύ μεγάλη ή περιέχει αλλαγή γραμμής. + + + Τα διαπιστευτήρια OAuth2 χρησιμοποιούν private_key_jwt αλλά δεν έχουν ακόμη κλειδί υπογραφής. Αποθηκεύστε τα διαπιστευτήρια για να δημιουργηθεί. + + + Ένα πρότυπο τοποθετεί μια προστατευμένη τιμή χωρίς json_escape, xml_escape ή hl7_escape. Ένα εισαγωγικό ή μια αλλαγή γραμμής στην τιμή μπορεί να αλλοιώσει το περιεχόμενο, το οποίο τότε αποτυγχάνει στον έλεγχο αντί να σταλεί. + + + Πιστοποίηση πελάτη + + + Κλειδιά αναγνωριστικών προσώπου + + + Εγκρίνετε μεμονωμένα αναγνωριστικά αντί για ολόκληρο το σύνολο: μόνο τα επιλεγμένα κλειδιά φτάνουν στο protected.call.subject_ids. + + + Άλλα κλειδιά (διαχωρισμένα με κόμμα) + + + Προσαρμοσμένα πεδία κλήσης + + + Κάθε επιλεγμένο προσαρμοσμένο πεδίο αποκρυπτογραφείται ξεχωριστά και εμφανίζεται ως protected.call.udf.<όνομα>. + + + Περιορισμένο + + + 42 CFR Part 2 + + + Επιβεβαιώνω ότι αυτός ο παραλήπτης είναι εξουσιοδοτημένος να λαμβάνει περιορισμένα πεδία ({0}). + + + 42 CFR Part 2: τα επιλεγμένα πεδία περιέχουν πληροφορίες για διαταραχές χρήσης ουσιών. Η περαιτέρω κοινοποίησή τους απαιτεί τη γραπτή συγκατάθεση του ασθενούς ή άλλη βάση που επιτρέπει το 42 CFR Part 2, και ο παραλήπτης πρέπει να ενημερωθεί ότι απαγορεύεται κάθε περαιτέρω κοινοποίηση. + + + Βεβαιώνω ότι το τμήμα διαθέτει τη συγκατάθεση ή άλλη βάση κατά 42 CFR Part 2 που απαιτείται για αυτή την κοινοποίηση ({0}). + + + Μια κλήση στέλνεται μόνο όταν έχει επιλεγεί σε αυτήν το «Υπάρχει συγκατάθεση Part 2»· διαφορετικά το βήμα αποκλείεται και καταγράφεται. + + + Επιλογές προστατευμένης παράδοσης + + + Πώς επιβεβαιώνει ο προορισμός τη λήψη, ποιες επιστρεφόμενες τιμές αποθηκεύονται στην κλήση και πώς αναγνωρίζονται οι επαναλήψεις. Κάθε επιλογή εδώ αποτελεί μέρος της έγκρισης. + + + Κανόνας επιτυχίας + + + Διαδρομή + + + Αναμενόμενη τιμή + + + Αποθήκευση τιμών απάντησης + + + Μία ανά γραμμή: πηγή | έκφραση | κλειδί. Πηγές: json_path, xpath, hl7_field, header, fhir_location_id. Οι τιμές αποθηκεύονται κρυπτογραφημένες στα αναγνωριστικά προσώπου της κλήσης. + + + Κεφαλίδα idempotency + + + FHIR If-None-Exist + + + Οποιαδήποτε κατάσταση 2xx + + + Η διαδρομή JSON ισούται με + + + Το XPath ισούται με + + + Επιβεβαίωση HL7 (AA / CA) + + + Κανένα σφάλμα στο OperationOutcome του FHIR + + + Τύπος περιεχομένου + + + Αποθηκευμένα κλειδιά + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.en.resx b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.en.resx new file mode 100644 index 000000000..538752d62 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.en.resx @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Protected Workflows + + + Protected workflow releases + + + Protected workflow disclosure log + + + Workflows + + + Data Protection + + + Protected Workflows + + + Workflows receive REDACTED values by default. Protected Workflows let an administrator approve one specific workflow to send selected protected fields to one pinned HTTPS destination. Every other workflow keeps receiving REDACTED values. + + + Enable Protected Workflows + + + Require a second administrator to approve protected workflows + + + Warning + + + Protected Workflows send decrypted protected data, which may include protected health information, to external systems you configure. Resgrid cannot control how the receiving system stores or uses this data. Only enable a workflow if the recipient is your organization or a party covered by a business associate agreement with your organization, and only release the fields the recipient needs. + + + I have read and acknowledge this warning (version {0}). + + + Warning acknowledged on {0} (version {1}). + + + Save Protected Workflow settings + + + Protected Workflow settings saved. + + + Protected Workflows are available only while Advanced Data Protection is enabled for your department. + + + Only administrators with the Configure Protected Data Delivery permission can change these settings. + + + Manage protected workflows + + + Turning this setting on or off, and approving or renewing a release, requires a fresh multi-factor verification. + + + This action needs a fresh multi-factor verification. You will be taken to verify and then brought back to this page; repeat the action afterwards. + + + Protected release + + + Send selected protected fields from this workflow to its pinned HTTPS destination. Only the fields you tick are decrypted; every other protected value stays REDACTED. + + + Status + + + Not protected + + + Draft + + + Pending approval + + + Active + + + Suspended + + + Expired + + + Revoked + + + Expiring + + + Renewal pending approval + + + Protected: {0} + + + configuration changed + + + Protected Workflows turned off + + + Advanced Data Protection not enabled + + + credential changed + + + suspended by an administrator + + + revoked by an administrator + + + Advanced Data Protection offboarding + + + workflow deleted + + + Fields to release + + + Nothing is selected by default. Reference released fields in the output template as protected.call.<name>; call.* values stay REDACTED. + + + Form data is also available parsed as protected.call.form. + + + Call name + + + Call type + + + Nature of call + + + Call notes + + + Completion notes + + + Address + + + Geolocation + + + what3words address + + + Contact name + + + Contact number + + + Source identifier + + + Incident number + + + External identifier + + + Reference number + + + Call form data + + + Deletion reason + + + Destination + + + Pinned host + + + Pinned credential + + + Pinned token host + + + No valid destination yet + + + This workflow cannot be protected yet + + + Add at least one enabled step. + + + Protected workflows can only contain API POST or PUT steps. + + + Every step needs a credential. + + + Every step must use the same credential. + + + Use a Bearer token, API key or OAuth2 client credentials credential. + + + A step uses a credential that no longer exists. + + + Every step needs a URL. + + + Every step URL must use HTTPS. + + + The URL host must be written out, not built from a template. + + + Every step must send to the same host. + + + protected.* values cannot be used in a URL or a header. + + + protected.* values cannot be used in a step condition. + + + Only call triggers can be protected in this version. + + + The OAuth2 credential needs an HTTPS token URL. + + + This workflow has no protected release, so protected.* values would render empty. Set up a protected release first. + + + Recipient + + + Recipient type + + + Covered entity + + + Business associate + + + Recipient name + + + For example: County DMH, Dynamics 365 case management + + + Purpose + + + Why the recipient needs these fields + + + I attest that the recipient is our organization or a party covered by a business associate agreement with our organization, and that it needs only the fields selected above (warning version {0}). + + + Save draft + + + Approve and activate + + + Request approval + + + Approve + + + Renew + + + Suspend + + + Revoke + + + Discard draft + + + Send test with sample data + + + Test sent with synthetic data. Every step returned a success status. + + + The test did not fully succeed. Check the step results below and the disclosure log. + + + Step {0}: {1} (HTTP {2}) + + + Revoke this release? This cannot be undone. The workflow will not run until a new release is approved. + + + Suspend this release? The workflow will not run until an administrator approves it again. + + + Discard this draft? The workflow will run again with REDACTED values. + + + This workflow has changed since it was approved. It will not send anything until an administrator approves the current configuration. + + + Requested {0} + + + Approved {0} + + + Expires {0} + + + Waiting for a second administrator. The person who requested the release cannot approve it. + + + Only administrators with the Configure Protected Data Delivery permission can change the protected release. Editing this workflow suspends an approved release until it is approved again. + + + While this workflow has a protected release that is not Active, its runs are skipped. It never runs with REDACTED values in place of the protected ones. + + + Protected Workflows are turned off for your department. Turn them on under Data Protection to configure a protected release. + + + You do not have permission to do this. + + + A fresh multi-factor verification is required. + + + This action must be done by a signed-in administrator, not an API key or service account. + + + Tick the attestation to continue. + + + The warning text has changed. Reload the page, read it and acknowledge it again. + + + You requested this release, so another administrator must approve it. + + + This release cannot do that in its current state. + + + The workflow is not valid for protection. Fix the problems listed and try again. + + + The workflow or release was not found. + + + Only call triggers can be protected in this version. + + + Too many fields are selected. + + + Select at least one field to release. + + + An unknown field was selected. + + + Enter the recipient type and name. + + + Enter the purpose of the disclosure. + + + Advanced Data Protection must be enabled first. + + + Protected Workflows are turned off for your department. + + + The workflow changed after the request. Request approval again for the current configuration. + + + The destination did not accept the request. + + + The action could not be completed. Try again. + + + Workflow + + + Status + + + Fields + + + Host + + + Recipient + + + Approved by + + + Expires + + + Sends (30 days) + + + Actions + + + No workflow has a protected release. + + + Open workflow + + + Disclosure log + + + Audit chain verified ({0} records). + + + Audit chain verification failed at record {0}. Contact Resgrid support. + + + Renew from the workflow editor, where the attestation is shown. + + + Workflow + + + All workflows + + + Call ID + + + From (UTC) + + + To (UTC) + + + Filter + + + Export CSV + + + Metadata only. This log never contains field values, payloads or response bodies. + + + # + + + Occurred (UTC) + + + Type + + + Event or outcome + + + Call + + + HTTP + + + Bytes + + + Actor + + + Detail + + + No records match. + + + test + + + Disclosure + + + Administrative + + + Sent + + + HTTP failed + + + Decrypt failed + + + Blocked: host + + + Blocked: release + + + Blocked: department + + + Blocked: ciphertext in payload + + + Render failed + + + Protected Workflows enabled + + + Protected Workflows disabled + + + Release requested + + + Release approved + + + Release suspended + + + Release revoked + + + Release expired + + + Credential rotated + + + Protected workflow expiring + + + The protected release for workflow "{0}" expires on {1}. An administrator must renew it or the workflow will stop sending. + + + The protected release for workflow "{0}" has expired and the workflow has stopped sending. An administrator can renew it from the workflow editor. + + + Protected workflow failed + + + Protected workflow "{0}" failed on its final attempt (run {1}, error {2}). + + + Attempted (recorded before sending) + + + Second-approver removal requested + + + Second approver no longer required + + + On {0} an administrator asked to stop requiring a second approver. A different administrator must confirm it within 7 days by clearing the box and saving. + + + Your request to stop requiring a second approver was recorded. A different administrator must confirm it before it takes effect. + + + Someone else changed this release at the same time. Reload the page and try again. + + + The disclosure log could not be written, so nothing was sent. Try again. + + + This workflow has no active release. + + + The release has expired. Renew it to resume sending. + + + Only Call API (POST or PUT) steps can send protected data. + + + The step's credential is not the one approved for this release. + + + The destination host is not the one approved for this release. + + + The destination must use HTTPS. + + + The destination answered with a redirect. Redirects are never followed for protected data. + + + The OAuth2 token host is not the one approved for this release. + + + The protected values could not be decrypted. Nothing was sent. + + + The call could not be loaded for decryption. Nothing was sent. + + + The template could not be rendered. Nothing was sent. + + + The rendered payload is too large. Nothing was sent. + + + The rendered payload contained encrypted data and was blocked. + + + The destination did not respond in time. + + + The step failed. The cause is not recorded, to keep protected values out of the logs. + + + Subject identifiers (every key) + + + Invalid payload + + + Projection failed + + + Rejected by destination + + + Response too large + + + Blocked: no Part 2 consent + + + The rendered payload is not valid for its content type. Nothing was sent. + + + The subject identifiers on the call could not be read. Nothing was sent. + + + The destination received the request but rejected it. + + + The destination's response was larger than allowed, so it could not be checked. + + + The call has no 42 CFR Part 2 consent on file, so its Part 2 fields were not sent. + + + The request was delivered, but the values returned by the destination could not be saved on the call. + + + The OAuth2 token endpoint did not issue a token. + + + The credential no longer authenticates the way this release approved. + + + The credential has no current signing key. + + + Release either the whole subject identifiers field or individual keys, not both. + + + A selected field is restricted. Tick the restricted-field attestation. + + + A selected field is 42 CFR Part 2 information. Tick the Part 2 attestation. + + + A step saves response values into the subject identifiers, so the release must include the subject identifiers (the whole field or at least one key). + + + A step declares a content type that protected steps cannot use. + + + A step's success rule is incomplete or unknown. + + + A step's response capture entry is invalid (source, expression or key). + + + A step captures more response values than allowed. + + + A step's idempotency header name is invalid or reserved. + + + A step's If-None-Exist value is too long or contains a line break. + + + The OAuth2 credential uses private_key_jwt but has no signing key yet. Save the credential to generate one. + + + A template places a protected value without json_escape, xml_escape or hl7_escape. A quote or line break in the value can break the payload, which then fails validation instead of being sent. + + + Client authentication + + + Subject identifier keys + + + Release individual identifiers instead of the whole set: only the ticked keys reach protected.call.subject_ids. + + + Other keys (comma separated) + + + Call custom fields + + + Each ticked custom field is decrypted on its own and renders as protected.call.udf.<name>. + + + Restricted + + + 42 CFR Part 2 + + + I confirm this recipient is authorized to receive restricted fields ({0}). + + + 42 CFR Part 2: the selected fields contain substance use disorder information. Redisclosing it requires the patient's written consent or another basis permitted by 42 CFR Part 2, and the recipient must be told that further redisclosure is prohibited. + + + I attest that the department has the consent or another 42 CFR Part 2 basis required for this redisclosure ({0}). + + + A call is only sent when "Part 2 consent on file" is set on it; otherwise the step is blocked and recorded. + + + Protected delivery options + + + How the destination confirms receipt, which returned values are saved on the call, and how retries are recognized. Every option here is part of the release approval. + + + Success rule + + + Path + + + Expected value + + + Save response values + + + One per line: source | expression | key. Sources: json_path, xpath, hl7_field, header, fhir_location_id. Values are saved encrypted in the call's subject identifiers. + + + Idempotency header + + + FHIR If-None-Exist + + + Any 2xx status + + + JSON path equals + + + XPath equals + + + HL7 acknowledgement (AA / CA) + + + No FHIR OperationOutcome error + + + Content type + + + Saved keys + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.es.resx b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.es.resx new file mode 100644 index 000000000..2d13cdad2 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.es.resx @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Flujos de trabajo protegidos + + + Liberaciones protegidas de flujos de trabajo + + + Registro de divulgaciones de flujos de trabajo protegidos + + + Flujos de trabajo + + + Protección de datos + + + Flujos de trabajo protegidos + + + De forma predeterminada, los flujos de trabajo reciben valores REDACTED. Los flujos de trabajo protegidos permiten que un administrador apruebe un flujo de trabajo específico para enviar campos protegidos seleccionados a un único destino HTTPS fijado. Todos los demás flujos de trabajo siguen recibiendo valores REDACTED. + + + Habilitar flujos de trabajo protegidos + + + Exigir que un segundo administrador apruebe los flujos de trabajo protegidos + + + Advertencia + + + Los flujos de trabajo protegidos envían datos protegidos descifrados, que pueden incluir información de salud protegida, a los sistemas externos que usted configure. Resgrid no puede controlar cómo el sistema receptor almacena o utiliza estos datos. Habilite un flujo de trabajo solo si el destinatario es su organización o una parte cubierta por un acuerdo de socio comercial con su organización, y libere únicamente los campos que el destinatario necesita. + + + He leído y reconozco esta advertencia (versión {0}). + + + Advertencia reconocida el {0} (versión {1}). + + + Guardar la configuración de flujos de trabajo protegidos + + + Se guardó la configuración de flujos de trabajo protegidos. + + + Los flujos de trabajo protegidos solo están disponibles mientras la protección de datos avanzada esté habilitada para su departamento. + + + Solo los administradores con el permiso “Configurar el envío de datos protegidos” pueden cambiar esta configuración. + + + Administrar flujos de trabajo protegidos + + + Activar o desactivar esta configuración, así como aprobar o renovar una liberación, requiere una verificación multifactor reciente. + + + Esta acción necesita una verificación multifactor reciente. Se le llevará a la verificación y luego se le devolverá a esta página; después, repita la acción. + + + Liberación protegida + + + Envíe campos protegidos seleccionados de este flujo de trabajo a su destino HTTPS fijado. Solo se descifran los campos que usted marque; todos los demás valores protegidos permanecen como REDACTED. + + + Estado + + + Sin protección + + + Borrador + + + Pendiente de aprobación + + + Activa + + + Suspendida + + + Vencida + + + Revocada + + + Por vencer + + + Renovación pendiente de aprobación + + + Protegido: {0} + + + configuración modificada + + + flujos de trabajo protegidos desactivados + + + protección de datos avanzada no habilitada + + + credencial modificada + + + suspendida por un administrador + + + revocada por un administrador + + + baja de la protección de datos avanzada + + + flujo de trabajo eliminado + + + Campos a liberar + + + De forma predeterminada no hay nada seleccionado. Haga referencia a los campos liberados en la plantilla de salida como protected.call.<name>; los valores call.* permanecen como REDACTED. + + + Los datos del formulario también están disponibles ya analizados como protected.call.form. + + + Nombre de la llamada + + + Tipo de llamada + + + Naturaleza de la llamada + + + Notas de la llamada + + + Notas de cierre + + + Dirección + + + Geolocalización + + + Dirección what3words + + + Nombre de contacto + + + Número de contacto + + + Identificador de origen + + + Número de incidente + + + Identificador externo + + + Número de referencia + + + Datos del formulario de llamada + + + Motivo de eliminación + + + Destino + + + Host fijado + + + Credencial fijada + + + Host de token fijado + + + Aún no hay un destino válido + + + Este flujo de trabajo aún no se puede proteger + + + Agregue al menos un paso habilitado. + + + Los flujos de trabajo protegidos solo pueden contener pasos de API POST o PUT. + + + Cada paso necesita una credencial. + + + Todos los pasos deben usar la misma credencial. + + + Use una credencial de tipo token Bearer, clave de API o credenciales de cliente OAuth2. + + + Un paso usa una credencial que ya no existe. + + + Cada paso necesita una URL. + + + La URL de cada paso debe usar HTTPS. + + + El host de la URL debe escribirse de forma explícita, no generarse a partir de una plantilla. + + + Todos los pasos deben enviar al mismo host. + + + Los valores protected.* no se pueden usar en una URL ni en un encabezado. + + + Los valores protected.* no se pueden usar en la condición de un paso. + + + En esta versión solo se pueden proteger los desencadenadores de llamadas. + + + La credencial OAuth2 necesita una URL de token HTTPS. + + + Este flujo de trabajo no tiene una liberación protegida, por lo que los valores protected.* se mostrarían vacíos. Configure primero una liberación protegida. + + + Destinatario + + + Tipo de destinatario + + + Entidad cubierta + + + Socio comercial + + + Nombre del destinatario + + + Por ejemplo: DMH del condado, gestión de casos en Dynamics 365 + + + Propósito + + + Por qué el destinatario necesita estos campos + + + Declaro que el destinatario es nuestra organización o una parte cubierta por un acuerdo de socio comercial con nuestra organización, y que necesita únicamente los campos seleccionados arriba (versión de la advertencia {0}). + + + Guardar borrador + + + Aprobar y activar + + + Solicitar aprobación + + + Aprobar + + + Renovar + + + Suspender + + + Revocar + + + Descartar borrador + + + Enviar prueba con datos de ejemplo + + + Prueba enviada con datos sintéticos. Todos los pasos devolvieron un estado de éxito. + + + La prueba no se completó correctamente en su totalidad. Revise los resultados de los pasos a continuación y el registro de divulgaciones. + + + Paso {0}: {1} (HTTP {2}) + + + ¿Revocar esta liberación? Esta acción no se puede deshacer. El flujo de trabajo no se ejecutará hasta que se apruebe una nueva liberación. + + + ¿Suspender esta liberación? El flujo de trabajo no se ejecutará hasta que un administrador la apruebe de nuevo. + + + ¿Descartar este borrador? El flujo de trabajo volverá a ejecutarse con valores REDACTED. + + + Este flujo de trabajo ha cambiado desde que se aprobó. No enviará nada hasta que un administrador apruebe la configuración actual. + + + Solicitada el {0} + + + Aprobada el {0} + + + Vence el {0} + + + En espera de un segundo administrador. La persona que solicitó la liberación no puede aprobarla. + + + Solo los administradores con el permiso “Configurar el envío de datos protegidos” pueden cambiar la liberación protegida. Editar este flujo de trabajo suspende una liberación aprobada hasta que se vuelva a aprobar. + + + Mientras este flujo de trabajo tenga una liberación protegida que no esté activa, sus ejecuciones se omiten. Nunca se ejecuta con valores REDACTED en lugar de los protegidos. + + + Los flujos de trabajo protegidos están desactivados para su departamento. Actívelos en Protección de datos para configurar una liberación protegida. + + + No tiene permiso para realizar esta acción. + + + Se requiere una verificación multifactor reciente. + + + Esta acción debe realizarla un administrador con sesión iniciada, no una clave de API ni una cuenta de servicio. + + + Marque la declaración para continuar. + + + El texto de la advertencia ha cambiado. Vuelva a cargar la página, léala y reconózcala de nuevo. + + + Usted solicitó esta liberación, por lo que otro administrador debe aprobarla. + + + Esta liberación no admite esa acción en su estado actual. + + + El flujo de trabajo no es válido para la protección. Corrija los problemas indicados e inténtelo de nuevo. + + + No se encontró el flujo de trabajo o la liberación. + + + En esta versión solo se pueden proteger los desencadenadores de llamadas. + + + Hay demasiados campos seleccionados. + + + Seleccione al menos un campo para liberar. + + + Se seleccionó un campo desconocido. + + + Ingrese el tipo y el nombre del destinatario. + + + Ingrese el propósito de la divulgación. + + + Primero debe habilitarse la protección de datos avanzada. + + + Los flujos de trabajo protegidos están desactivados para su departamento. + + + El flujo de trabajo cambió después de la solicitud. Vuelva a solicitar la aprobación para la configuración actual. + + + El destino no aceptó la solicitud. + + + No se pudo completar la acción. Inténtelo de nuevo. + + + Flujo de trabajo + + + Estado + + + Campos + + + Host + + + Destinatario + + + Aprobada por + + + Vence + + + Envíos (30 días) + + + Acciones + + + Ningún flujo de trabajo tiene una liberación protegida. + + + Abrir flujo de trabajo + + + Registro de divulgaciones + + + Cadena de auditoría verificada ({0} registros). + + + La verificación de la cadena de auditoría falló en el registro {0}. Comuníquese con el soporte de Resgrid. + + + Renueve desde el editor del flujo de trabajo, donde se muestra la declaración. + + + Flujo de trabajo + + + Todos los flujos de trabajo + + + ID de llamada + + + Desde (UTC) + + + Hasta (UTC) + + + Filtrar + + + Exportar CSV + + + Solo metadatos. Este registro nunca contiene valores de campos, cargas útiles ni cuerpos de respuesta. + + + # + + + Fecha y hora (UTC) + + + Tipo + + + Evento o resultado + + + Llamada + + + HTTP + + + Bytes + + + Autor + + + Detalle + + + Ningún registro coincide. + + + prueba + + + Divulgación + + + Administrativo + + + Enviado + + + Error HTTP + + + Error al descifrar + + + Bloqueado: host + + + Bloqueado: liberación + + + Bloqueado: departamento + + + Bloqueado: texto cifrado en la carga útil + + + Error de representación + + + Flujos de trabajo protegidos habilitados + + + Flujos de trabajo protegidos deshabilitados + + + Liberación solicitada + + + Liberación aprobada + + + Liberación suspendida + + + Liberación revocada + + + Liberación vencida + + + Credencial rotada + + + Flujo de trabajo protegido por vencer + + + La liberación protegida del flujo de trabajo "{0}" vence el {1}. Un administrador debe renovarla o el flujo de trabajo dejará de enviar. + + + La liberación protegida del flujo de trabajo "{0}" ha vencido y el flujo de trabajo dejó de enviar. Un administrador puede renovarla desde el editor del flujo de trabajo. + + + Error en flujo de trabajo protegido + + + El flujo de trabajo protegido "{0}" falló en su último intento (ejecución {1}, error {2}). + + + Intentado (registrado antes del envío) + + + Solicitud para quitar el segundo aprobador + + + Ya no se requiere un segundo aprobador + + + El {0} un administrador pidió dejar de exigir un segundo aprobador. Otro administrador debe confirmarlo en un plazo de 7 días desmarcando la casilla y guardando. + + + Se registró su solicitud para dejar de exigir un segundo aprobador. Otro administrador debe confirmarla antes de que entre en vigor. + + + Otra persona modificó esta autorización al mismo tiempo. Recargue la página e inténtelo de nuevo. + + + No se pudo escribir el registro de divulgaciones, por lo que no se envió nada. Inténtelo de nuevo. + + + Este flujo de trabajo no tiene una autorización activa. + + + La autorización ha caducado. Renuévela para reanudar los envíos. + + + Solo los pasos de llamada a API (POST o PUT) pueden enviar datos protegidos. + + + La credencial del paso no es la aprobada para esta autorización. + + + El host de destino no es el aprobado para esta autorización. + + + El destino debe usar HTTPS. + + + El destino respondió con una redirección. Las redirecciones nunca se siguen con datos protegidos. + + + El host de tokens OAuth2 no es el aprobado para esta autorización. + + + No se pudieron descifrar los valores protegidos. No se envió nada. + + + No se pudo cargar la llamada para descifrarla. No se envió nada. + + + No se pudo generar la plantilla. No se envió nada. + + + El contenido generado es demasiado grande. No se envió nada. + + + El contenido generado incluía datos cifrados y se bloqueó. + + + El destino no respondió a tiempo. + + + El paso falló. La causa no se registra para mantener los valores protegidos fuera de los registros. + + + Identificadores del sujeto (todas las claves) + + + Contenido no válido + + + Error de proyección + + + Rechazado por el destino + + + Respuesta demasiado grande + + + Bloqueado: sin consentimiento Part 2 + + + El contenido generado no es válido para su tipo de contenido. No se envió nada. + + + No se pudieron leer los identificadores del sujeto de la llamada. No se envió nada. + + + El destino recibió la solicitud, pero la rechazó. + + + La respuesta del destino superó el tamaño permitido, por lo que no se pudo comprobar. + + + La llamada no tiene consentimiento 42 CFR Part 2 registrado, por lo que no se enviaron sus campos Part 2. + + + La solicitud se entregó, pero no se pudieron guardar en la llamada los valores devueltos por el destino. + + + El punto de conexión de tokens OAuth2 no emitió un token. + + + La credencial ya no se autentica de la forma aprobada en esta autorización. + + + La credencial no tiene una clave de firma vigente. + + + Autorice el campo completo de identificadores del sujeto o claves individuales, no ambos. + + + Un campo seleccionado es restringido. Marque la declaración de campos restringidos. + + + Un campo seleccionado contiene información 42 CFR Part 2. Marque la declaración Part 2. + + + Un paso guarda valores de la respuesta en los identificadores del sujeto, por lo que la autorización debe incluirlos (el campo completo o al menos una clave). + + + Un paso declara un tipo de contenido que los pasos protegidos no pueden usar. + + + La regla de éxito de un paso está incompleta o es desconocida. + + + Una entrada de captura de respuesta de un paso no es válida (origen, expresión o clave). + + + Un paso captura más valores de respuesta de los permitidos. + + + El nombre del encabezado de idempotencia de un paso no es válido o está reservado. + + + El valor If-None-Exist de un paso es demasiado largo o contiene un salto de línea. + + + La credencial OAuth2 usa private_key_jwt pero aún no tiene clave de firma. Guarde la credencial para generarla. + + + Una plantilla coloca un valor protegido sin json_escape, xml_escape ni hl7_escape. Una comilla o un salto de línea en el valor puede romper el contenido, que entonces no supera la validación en lugar de enviarse. + + + Autenticación del cliente + + + Claves de identificadores del sujeto + + + Autorice identificadores individuales en lugar del conjunto completo: solo las claves marcadas llegan a protected.call.subject_ids. + + + Otras claves (separadas por comas) + + + Campos personalizados de la llamada + + + Cada campo personalizado marcado se descifra por separado y se muestra como protected.call.udf.<nombre>. + + + Restringido + + + 42 CFR Part 2 + + + Confirmo que este destinatario está autorizado a recibir campos restringidos ({0}). + + + 42 CFR Part 2: los campos seleccionados contienen información sobre trastornos por consumo de sustancias. Su redivulgación requiere el consentimiento por escrito del paciente u otra base permitida por 42 CFR Part 2, y se debe informar al destinatario de que está prohibida cualquier redivulgación posterior. + + + Declaro que el departamento cuenta con el consentimiento u otra base de 42 CFR Part 2 necesaria para esta redivulgación ({0}). + + + Una llamada solo se envía cuando tiene marcado "Consentimiento Part 2 registrado"; de lo contrario, el paso se bloquea y se registra. + + + Opciones de entrega protegida + + + Cómo confirma el destino la recepción, qué valores devueltos se guardan en la llamada y cómo se reconocen los reintentos. Cada opción forma parte de la aprobación de la autorización. + + + Regla de éxito + + + Ruta + + + Valor esperado + + + Guardar valores de la respuesta + + + Uno por línea: origen | expresión | clave. Orígenes: json_path, xpath, hl7_field, header, fhir_location_id. Los valores se guardan cifrados en los identificadores del sujeto de la llamada. + + + Encabezado de idempotencia + + + FHIR If-None-Exist + + + Cualquier estado 2xx + + + La ruta JSON es igual a + + + XPath es igual a + + + Acuse de recibo HL7 (AA / CA) + + + Sin error en OperationOutcome de FHIR + + + Tipo de contenido + + + Claves guardadas + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.fr.resx b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.fr.resx new file mode 100644 index 000000000..6d0d0b680 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.fr.resx @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Workflows protégés + + + Autorisations de transmission protégée des workflows + + + Journal des divulgations des workflows protégés + + + Workflows + + + Protection des données + + + Workflows protégés + + + Par défaut, les workflows reçoivent des valeurs REDACTED. Les workflows protégés permettent à un administrateur d'autoriser un workflow précis à envoyer des champs protégés sélectionnés vers une seule destination HTTPS fixée. Tous les autres workflows continuent de recevoir des valeurs REDACTED. + + + Activer les workflows protégés + + + Exiger l'approbation des workflows protégés par un second administrateur + + + Avertissement + + + Les workflows protégés envoient des données protégées déchiffrées, qui peuvent inclure des informations de santé protégées, vers les systèmes externes que vous configurez. Resgrid ne peut pas contrôler la manière dont le système destinataire stocke ou utilise ces données. N'activez un workflow que si le destinataire est votre organisation ou une partie couverte par un accord de partenaire commercial conclu avec votre organisation, et ne transmettez que les champs dont le destinataire a besoin. + + + J'ai lu cet avertissement et j'en prends acte (version {0}). + + + Avertissement pris en compte le {0} (version {1}). + + + Enregistrer les paramètres des workflows protégés + + + Paramètres des workflows protégés enregistrés. + + + Les workflows protégés ne sont disponibles que tant que la protection avancée des données est activée pour votre département. + + + Seuls les administrateurs disposant de la permission « Configurer la transmission des données protégées » peuvent modifier ces paramètres. + + + Gérer les workflows protégés + + + L'activation ou la désactivation de ce paramètre, ainsi que l'approbation ou le renouvellement d'une autorisation, exigent une vérification multifacteur récente. + + + Cette action nécessite une vérification multifacteur récente. Vous serez redirigé vers la vérification, puis ramené sur cette page ; recommencez ensuite l'action. + + + Autorisation de transmission protégée + + + Envoyez les champs protégés sélectionnés de ce workflow vers sa destination HTTPS fixée. Seuls les champs que vous cochez sont déchiffrés ; toutes les autres valeurs protégées restent REDACTED. + + + Statut + + + Non protégé + + + Brouillon + + + En attente d'approbation + + + Active + + + Suspendue + + + Expirée + + + Révoquée + + + Expire bientôt + + + Renouvellement en attente d'approbation + + + Protégé : {0} + + + configuration modifiée + + + workflows protégés désactivés + + + protection avancée des données non activée + + + identifiants modifiés + + + suspendue par un administrateur + + + révoquée par un administrateur + + + désactivation de la protection avancée des données + + + workflow supprimé + + + Champs à transmettre + + + Aucun champ n'est sélectionné par défaut. Dans le modèle de sortie, référencez les champs transmis sous la forme protected.call.<name> ; les valeurs call.* restent REDACTED. + + + Les données du formulaire sont également disponibles sous forme analysée via protected.call.form. + + + Nom de l'appel + + + Type d'appel + + + Nature de l'appel + + + Notes de l'appel + + + Notes de clôture + + + Adresse + + + Géolocalisation + + + Adresse what3words + + + Nom du contact + + + Numéro du contact + + + Identifiant source + + + Numéro d'incident + + + Identifiant externe + + + Numéro de référence + + + Données du formulaire d'appel + + + Motif de suppression + + + Destination + + + Hôte fixé + + + Identifiants fixés + + + Hôte de jeton fixé + + + Aucune destination valide pour le moment + + + Ce workflow ne peut pas encore être protégé + + + Ajoutez au moins une étape activée. + + + Les workflows protégés ne peuvent contenir que des étapes API POST ou PUT. + + + Chaque étape nécessite des identifiants. + + + Toutes les étapes doivent utiliser les mêmes identifiants. + + + Utilisez des identifiants de type jeton Bearer, clé API ou identifiants client OAuth2. + + + Une étape utilise des identifiants qui n'existent plus. + + + Chaque étape nécessite une URL. + + + L'URL de chaque étape doit utiliser HTTPS. + + + L'hôte de l'URL doit être écrit en toutes lettres, et non construit à partir d'un modèle. + + + Toutes les étapes doivent envoyer vers le même hôte. + + + Les valeurs protected.* ne peuvent pas être utilisées dans une URL ou un en-tête. + + + Les valeurs protected.* ne peuvent pas être utilisées dans la condition d'une étape. + + + Dans cette version, seuls les déclencheurs d'appel peuvent être protégés. + + + Les identifiants OAuth2 nécessitent une URL de jeton HTTPS. + + + Ce workflow n'a pas d'autorisation de transmission protégée ; les valeurs protected.* seraient donc vides. Configurez d'abord une autorisation de transmission protégée. + + + Destinataire + + + Type de destinataire + + + Entité couverte + + + Partenaire commercial + + + Nom du destinataire + + + Par exemple : DMH du comté, gestion des dossiers dans Dynamics 365 + + + Finalité + + + Pourquoi le destinataire a besoin de ces champs + + + J'atteste que le destinataire est notre organisation ou une partie couverte par un accord de partenaire commercial conclu avec notre organisation, et qu'il n'a besoin que des champs sélectionnés ci-dessus (version de l'avertissement {0}). + + + Enregistrer le brouillon + + + Approuver et activer + + + Demander l'approbation + + + Approuver + + + Renouveler + + + Suspendre + + + Révoquer + + + Abandonner le brouillon + + + Envoyer un test avec des données d'exemple + + + Test envoyé avec des données synthétiques. Chaque étape a renvoyé un statut de réussite. + + + Le test n'a pas entièrement réussi. Consultez les résultats des étapes ci-dessous et le journal des divulgations. + + + Étape {0} : {1} (HTTP {2}) + + + Révoquer cette autorisation ? Cette action est irréversible. Le workflow ne s'exécutera plus tant qu'une nouvelle autorisation n'aura pas été approuvée. + + + Suspendre cette autorisation ? Le workflow ne s'exécutera plus tant qu'un administrateur ne l'aura pas approuvée de nouveau. + + + Abandonner ce brouillon ? Le workflow s'exécutera de nouveau avec des valeurs REDACTED. + + + Ce workflow a été modifié depuis son approbation. Il n'enverra rien tant qu'un administrateur n'aura pas approuvé la configuration actuelle. + + + Demandée le {0} + + + Approuvée le {0} + + + Expire le {0} + + + En attente d'un second administrateur. La personne qui a demandé l'autorisation ne peut pas l'approuver. + + + Seuls les administrateurs disposant de la permission « Configurer la transmission des données protégées » peuvent modifier l'autorisation de transmission protégée. La modification de ce workflow suspend une autorisation approuvée jusqu'à ce qu'elle soit de nouveau approuvée. + + + Tant que ce workflow possède une autorisation de transmission protégée qui n'est pas active, ses exécutions sont ignorées. Il ne s'exécute jamais avec des valeurs REDACTED à la place des valeurs protégées. + + + Les workflows protégés sont désactivés pour votre département. Activez-les dans Protection des données pour configurer une autorisation de transmission protégée. + + + Vous n'êtes pas autorisé à effectuer cette action. + + + Une vérification multifacteur récente est requise. + + + Cette action doit être effectuée par un administrateur connecté, et non par une clé API ou un compte de service. + + + Cochez l'attestation pour continuer. + + + Le texte de l'avertissement a changé. Rechargez la page, lisez-le et prenez-en acte de nouveau. + + + Vous avez demandé cette autorisation ; un autre administrateur doit donc l'approuver. + + + Cette action n'est pas possible dans l'état actuel de l'autorisation. + + + Le workflow ne remplit pas les conditions de protection. Corrigez les problèmes indiqués et réessayez. + + + Le workflow ou l'autorisation est introuvable. + + + Dans cette version, seuls les déclencheurs d'appel peuvent être protégés. + + + Trop de champs sont sélectionnés. + + + Sélectionnez au moins un champ à transmettre. + + + Un champ inconnu a été sélectionné. + + + Saisissez le type et le nom du destinataire. + + + Saisissez la finalité de la divulgation. + + + La protection avancée des données doit d'abord être activée. + + + Les workflows protégés sont désactivés pour votre département. + + + Le workflow a été modifié après la demande. Demandez de nouveau l'approbation pour la configuration actuelle. + + + La destination n'a pas accepté la requête. + + + L'action n'a pas pu être effectuée. Réessayez. + + + Workflow + + + Statut + + + Champs + + + Hôte + + + Destinataire + + + Approuvée par + + + Expiration + + + Envois (30 jours) + + + Actions + + + Aucun workflow n'a d'autorisation de transmission protégée. + + + Ouvrir le workflow + + + Journal des divulgations + + + Chaîne d'audit vérifiée ({0} enregistrements). + + + La vérification de la chaîne d'audit a échoué à l'enregistrement {0}. Contactez le support Resgrid. + + + Renouvelez depuis l'éditeur de workflow, où l'attestation est affichée. + + + Workflow + + + Tous les workflows + + + ID d'appel + + + Du (UTC) + + + Au (UTC) + + + Filtrer + + + Exporter en CSV + + + Métadonnées uniquement. Ce journal ne contient jamais de valeurs de champs, de charges utiles ni de corps de réponse. + + + # + + + Date (UTC) + + + Type + + + Événement ou résultat + + + Appel + + + HTTP + + + Octets + + + Auteur + + + Détail + + + Aucun enregistrement ne correspond. + + + test + + + Divulgation + + + Administratif + + + Envoyé + + + Échec HTTP + + + Échec du déchiffrement + + + Bloqué : hôte + + + Bloqué : autorisation + + + Bloqué : département + + + Bloqué : texte chiffré dans la charge utile + + + Échec du rendu + + + Workflows protégés activés + + + Workflows protégés désactivés + + + Autorisation demandée + + + Autorisation approuvée + + + Autorisation suspendue + + + Autorisation révoquée + + + Autorisation expirée + + + Identifiants renouvelés + + + Expiration prochaine d'un workflow protégé + + + L'autorisation de transmission protégée du workflow « {0} » expire le {1}. Un administrateur doit la renouveler, sinon le workflow cessera d'envoyer. + + + L'autorisation de transmission protégée du workflow « {0} » a expiré et le workflow a cessé d'envoyer. Un administrateur peut la renouveler depuis l'éditeur de workflow. + + + Échec d'un workflow protégé + + + Le workflow protégé « {0} » a échoué lors de sa dernière tentative (exécution {1}, erreur {2}). + + + Tentative (enregistrée avant l'envoi) + + + Suppression du second approbateur demandée + + + Second approbateur plus requis + + + Le {0}, un administrateur a demandé de ne plus exiger de second approbateur. Un autre administrateur doit le confirmer dans les 7 jours en décochant la case et en enregistrant. + + + Votre demande de ne plus exiger de second approbateur a été enregistrée. Un autre administrateur doit la confirmer avant qu'elle ne prenne effet. + + + Quelqu'un d'autre a modifié cette autorisation en même temps. Rechargez la page et réessayez. + + + Le journal des divulgations n'a pas pu être écrit, rien n'a donc été envoyé. Réessayez. + + + Ce flux de travail n'a pas d'autorisation active. + + + L'autorisation a expiré. Renouvelez-la pour reprendre les envois. + + + Seules les étapes d'appel d'API (POST ou PUT) peuvent envoyer des données protégées. + + + L'identifiant de l'étape n'est pas celui approuvé pour cette autorisation. + + + L'hôte de destination n'est pas celui approuvé pour cette autorisation. + + + La destination doit utiliser HTTPS. + + + La destination a répondu par une redirection. Les redirections ne sont jamais suivies pour les données protégées. + + + L'hôte de jetons OAuth2 n'est pas celui approuvé pour cette autorisation. + + + Les valeurs protégées n'ont pas pu être déchiffrées. Rien n'a été envoyé. + + + L'appel n'a pas pu être chargé pour le déchiffrement. Rien n'a été envoyé. + + + Le modèle n'a pas pu être généré. Rien n'a été envoyé. + + + Le contenu généré est trop volumineux. Rien n'a été envoyé. + + + Le contenu généré contenait des données chiffrées et a été bloqué. + + + La destination n'a pas répondu à temps. + + + L'étape a échoué. La cause n'est pas enregistrée afin de tenir les valeurs protégées à l'écart des journaux. + + + Identifiants de la personne (toutes les clés) + + + Contenu non valide + + + Échec de la projection + + + Refusé par la destination + + + Réponse trop volumineuse + + + Bloqué : pas de consentement Part 2 + + + Le contenu généré n'est pas valide pour son type de contenu. Rien n'a été envoyé. + + + Les identifiants de la personne de l'appel n'ont pas pu être lus. Rien n'a été envoyé. + + + La destination a reçu la requête mais l'a refusée. + + + La réponse de la destination dépassait la taille autorisée et n'a donc pas pu être vérifiée. + + + L'appel n'a pas de consentement 42 CFR Part 2 enregistré, ses champs Part 2 n'ont donc pas été envoyés. + + + La requête a été livrée, mais les valeurs renvoyées par la destination n'ont pas pu être enregistrées sur l'appel. + + + Le point de terminaison de jetons OAuth2 n'a pas émis de jeton. + + + L'identifiant ne s'authentifie plus de la manière approuvée pour cette autorisation. + + + L'identifiant n'a pas de clé de signature en cours. + + + Autorisez soit le champ complet des identifiants de la personne, soit des clés individuelles, pas les deux. + + + Un champ sélectionné est restreint. Cochez l'attestation relative aux champs restreints. + + + Un champ sélectionné contient des informations 42 CFR Part 2. Cochez l'attestation Part 2. + + + Une étape enregistre des valeurs de réponse dans les identifiants de la personne ; l'autorisation doit donc les inclure (le champ complet ou au moins une clé). + + + Une étape déclare un type de contenu que les étapes protégées ne peuvent pas utiliser. + + + La règle de réussite d'une étape est incomplète ou inconnue. + + + Une entrée de capture de réponse d'une étape n'est pas valide (source, expression ou clé). + + + Une étape capture plus de valeurs de réponse que le nombre autorisé. + + + Le nom de l'en-tête d'idempotence d'une étape n'est pas valide ou est réservé. + + + La valeur If-None-Exist d'une étape est trop longue ou contient un saut de ligne. + + + L'identifiant OAuth2 utilise private_key_jwt mais n'a pas encore de clé de signature. Enregistrez l'identifiant pour en générer une. + + + Un modèle place une valeur protégée sans json_escape, xml_escape ni hl7_escape. Un guillemet ou un saut de ligne dans la valeur peut casser le contenu, qui échoue alors à la validation au lieu d'être envoyé. + + + Authentification du client + + + Clés des identifiants de la personne + + + Autorisez des identifiants individuels plutôt que l'ensemble : seules les clés cochées parviennent à protected.call.subject_ids. + + + Autres clés (séparées par des virgules) + + + Champs personnalisés de l'appel + + + Chaque champ personnalisé coché est déchiffré séparément et s'affiche comme protected.call.udf.<nom>. + + + Restreint + + + 42 CFR Part 2 + + + Je confirme que ce destinataire est autorisé à recevoir des champs restreints ({0}). + + + 42 CFR Part 2 : les champs sélectionnés contiennent des informations sur des troubles liés à l'usage de substances. Leur redivulgation exige le consentement écrit du patient ou un autre fondement permis par 42 CFR Part 2, et le destinataire doit être informé que toute redivulgation ultérieure est interdite. + + + J'atteste que le service dispose du consentement ou d'un autre fondement 42 CFR Part 2 requis pour cette redivulgation ({0}). + + + Un appel n'est envoyé que si « Consentement Part 2 enregistré » est coché ; sinon l'étape est bloquée et consignée. + + + Options de livraison protégée + + + Comment la destination confirme la réception, quelles valeurs renvoyées sont enregistrées sur l'appel et comment les nouvelles tentatives sont reconnues. Chaque option fait partie de l'approbation de l'autorisation. + + + Règle de réussite + + + Chemin + + + Valeur attendue + + + Enregistrer des valeurs de la réponse + + + Une par ligne : source | expression | clé. Sources : json_path, xpath, hl7_field, header, fhir_location_id. Les valeurs sont enregistrées chiffrées dans les identifiants de la personne de l'appel. + + + En-tête d'idempotence + + + FHIR If-None-Exist + + + Tout statut 2xx + + + Le chemin JSON est égal à + + + XPath est égal à + + + Accusé de réception HL7 (AA / CA) + + + Aucune erreur dans l'OperationOutcome FHIR + + + Type de contenu + + + Clés enregistrées + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.it.resx b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.it.resx new file mode 100644 index 000000000..48588903d --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.it.resx @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Flussi di lavoro protetti + + + Rilasci protetti dei flussi di lavoro + + + Registro delle divulgazioni dei flussi di lavoro protetti + + + Flussi di lavoro + + + Protezione dei dati + + + Flussi di lavoro protetti + + + Per impostazione predefinita, i flussi di lavoro ricevono valori REDACTED. I flussi di lavoro protetti consentono a un amministratore di approvare uno specifico flusso di lavoro affinché invii campi protetti selezionati a un'unica destinazione HTTPS fissata. Tutti gli altri flussi di lavoro continuano a ricevere valori REDACTED. + + + Abilita i flussi di lavoro protetti + + + Richiedi l'approvazione dei flussi di lavoro protetti da parte di un secondo amministratore + + + Avviso + + + I flussi di lavoro protetti inviano dati protetti decifrati, che possono includere informazioni sanitarie protette, ai sistemi esterni che configuri. Resgrid non può controllare il modo in cui il sistema ricevente archivia o utilizza questi dati. Abilita un flusso di lavoro solo se il destinatario è la tua organizzazione o una parte vincolata da un accordo di partner commerciale con la tua organizzazione, e rilascia solo i campi di cui il destinatario ha bisogno. + + + Ho letto e prendo atto di questo avviso (versione {0}). + + + Presa d'atto dell'avviso registrata il {0} (versione {1}). + + + Salva le impostazioni dei flussi di lavoro protetti + + + Impostazioni dei flussi di lavoro protetti salvate. + + + I flussi di lavoro protetti sono disponibili solo finché la Protezione avanzata dei dati è abilitata per il tuo dipartimento. + + + Solo gli amministratori con l'autorizzazione "Configura l'invio dei dati protetti" possono modificare queste impostazioni. + + + Gestisci i flussi di lavoro protetti + + + Per attivare o disattivare questa impostazione, nonché per approvare o rinnovare un rilascio, è necessaria una verifica multifattore recente. + + + Questa azione richiede una verifica multifattore recente. Verrai indirizzato alla verifica e poi riportato a questa pagina; al termine, ripeti l'azione. + + + Rilascio protetto + + + Invia i campi protetti selezionati da questo flusso di lavoro alla sua destinazione HTTPS fissata. Vengono decifrati solo i campi che spunti; tutti gli altri valori protetti restano REDACTED. + + + Stato + + + Non protetto + + + Bozza + + + In attesa di approvazione + + + Attivo + + + Sospeso + + + Scaduto + + + Revocato + + + In scadenza + + + Rinnovo in attesa di approvazione + + + Protetto: {0} + + + configurazione modificata + + + flussi di lavoro protetti disattivati + + + Protezione avanzata dei dati non abilitata + + + credenziale modificata + + + sospeso da un amministratore + + + revocato da un amministratore + + + disattivazione della Protezione avanzata dei dati + + + flusso di lavoro eliminato + + + Campi da rilasciare + + + Per impostazione predefinita non è selezionato nulla. Nel modello di output fai riferimento ai campi rilasciati come protected.call.<name>; i valori call.* restano REDACTED. + + + I dati del modulo sono disponibili anche già analizzati come protected.call.form. + + + Nome della chiamata + + + Tipo di chiamata + + + Natura della chiamata + + + Note della chiamata + + + Note di chiusura + + + Indirizzo + + + Geolocalizzazione + + + Indirizzo what3words + + + Nome del contatto + + + Numero del contatto + + + Identificativo di origine + + + Numero dell'incidente + + + Identificativo esterno + + + Numero di riferimento + + + Dati del modulo di chiamata + + + Motivo dell'eliminazione + + + Destinazione + + + Host fissato + + + Credenziale fissata + + + Host del token fissato + + + Ancora nessuna destinazione valida + + + Questo flusso di lavoro non può ancora essere protetto + + + Aggiungi almeno un passaggio abilitato. + + + I flussi di lavoro protetti possono contenere solo passaggi API POST o PUT. + + + Ogni passaggio richiede una credenziale. + + + Tutti i passaggi devono usare la stessa credenziale. + + + Usa una credenziale di tipo token Bearer, chiave API o credenziali client OAuth2. + + + Un passaggio usa una credenziale che non esiste più. + + + Ogni passaggio richiede un URL. + + + L'URL di ogni passaggio deve usare HTTPS. + + + L'host dell'URL deve essere scritto per esteso, non generato da un modello. + + + Tutti i passaggi devono inviare allo stesso host. + + + I valori protected.* non possono essere usati in un URL o in un'intestazione. + + + I valori protected.* non possono essere usati nella condizione di un passaggio. + + + In questa versione possono essere protetti solo gli eventi di attivazione delle chiamate. + + + La credenziale OAuth2 richiede un URL del token HTTPS. + + + Questo flusso di lavoro non ha un rilascio protetto, quindi i valori protected.* risulterebbero vuoti. Configura prima un rilascio protetto. + + + Destinatario + + + Tipo di destinatario + + + Entità coperta + + + Partner commerciale + + + Nome del destinatario + + + Ad esempio: DMH della contea, gestione dei casi in Dynamics 365 + + + Finalità + + + Perché il destinatario ha bisogno di questi campi + + + Attesto che il destinatario è la nostra organizzazione o una parte vincolata da un accordo di partner commerciale con la nostra organizzazione, e che ha bisogno solo dei campi selezionati sopra (versione dell'avviso {0}). + + + Salva bozza + + + Approva e attiva + + + Richiedi approvazione + + + Approva + + + Rinnova + + + Sospendi + + + Revoca + + + Elimina bozza + + + Invia un test con dati di esempio + + + Test inviato con dati sintetici. Ogni passaggio ha restituito uno stato di esito positivo. + + + Il test non è riuscito completamente. Controlla i risultati dei passaggi qui sotto e il registro delle divulgazioni. + + + Passaggio {0}: {1} (HTTP {2}) + + + Revocare questo rilascio? L'operazione non può essere annullata. Il flusso di lavoro non verrà eseguito finché non sarà approvato un nuovo rilascio. + + + Sospendere questo rilascio? Il flusso di lavoro non verrà eseguito finché un amministratore non lo approverà di nuovo. + + + Eliminare questa bozza? Il flusso di lavoro tornerà a essere eseguito con valori REDACTED. + + + Questo flusso di lavoro è stato modificato dopo l'approvazione. Non invierà nulla finché un amministratore non approverà la configurazione attuale. + + + Richiesto il {0} + + + Approvato il {0} + + + Scade il {0} + + + In attesa di un secondo amministratore. La persona che ha richiesto il rilascio non può approvarlo. + + + Solo gli amministratori con l'autorizzazione "Configura l'invio dei dati protetti" possono modificare il rilascio protetto. La modifica di questo flusso di lavoro sospende un rilascio approvato finché non viene approvato di nuovo. + + + Finché questo flusso di lavoro ha un rilascio protetto non attivo, le sue esecuzioni vengono saltate. Non viene mai eseguito con valori REDACTED al posto di quelli protetti. + + + I flussi di lavoro protetti sono disattivati per il tuo dipartimento. Attivali in Protezione dei dati per configurare un rilascio protetto. + + + Non hai l'autorizzazione per eseguire questa operazione. + + + È necessaria una verifica multifattore recente. + + + Questa azione deve essere eseguita da un amministratore che ha effettuato l'accesso, non da una chiave API o da un account di servizio. + + + Spunta l'attestazione per continuare. + + + Il testo dell'avviso è cambiato. Ricarica la pagina, leggilo e prendine nuovamente atto. + + + Hai richiesto tu questo rilascio, quindi deve approvarlo un altro amministratore. + + + Questa operazione non è consentita nello stato attuale del rilascio. + + + Il flusso di lavoro non è valido per la protezione. Correggi i problemi elencati e riprova. + + + Flusso di lavoro o rilascio non trovato. + + + In questa versione possono essere protetti solo gli eventi di attivazione delle chiamate. + + + Sono selezionati troppi campi. + + + Seleziona almeno un campo da rilasciare. + + + È stato selezionato un campo sconosciuto. + + + Inserisci il tipo e il nome del destinatario. + + + Inserisci la finalità della divulgazione. + + + Occorre prima abilitare la Protezione avanzata dei dati. + + + I flussi di lavoro protetti sono disattivati per il tuo dipartimento. + + + Il flusso di lavoro è stato modificato dopo la richiesta. Richiedi di nuovo l'approvazione per la configurazione attuale. + + + La destinazione non ha accettato la richiesta. + + + Impossibile completare l'azione. Riprova. + + + Flusso di lavoro + + + Stato + + + Campi + + + Host + + + Destinatario + + + Approvato da + + + Scadenza + + + Invii (30 giorni) + + + Azioni + + + Nessun flusso di lavoro ha un rilascio protetto. + + + Apri flusso di lavoro + + + Registro delle divulgazioni + + + Catena di audit verificata ({0} record). + + + Verifica della catena di audit non riuscita al record {0}. Contatta l'assistenza Resgrid. + + + Rinnova dall'editor del flusso di lavoro, dove è visualizzata l'attestazione. + + + Flusso di lavoro + + + Tutti i flussi di lavoro + + + ID chiamata + + + Da (UTC) + + + A (UTC) + + + Filtra + + + Esporta CSV + + + Solo metadati. Questo registro non contiene mai valori dei campi, payload o corpi delle risposte. + + + # + + + Data e ora (UTC) + + + Tipo + + + Evento o esito + + + Chiamata + + + HTTP + + + Byte + + + Autore + + + Dettaglio + + + Nessun record corrispondente. + + + prova + + + Divulgazione + + + Amministrativo + + + Inviato + + + Errore HTTP + + + Decifratura non riuscita + + + Bloccato: host + + + Bloccato: rilascio + + + Bloccato: dipartimento + + + Bloccato: testo cifrato nel payload + + + Rendering non riuscito + + + Flussi di lavoro protetti abilitati + + + Flussi di lavoro protetti disabilitati + + + Rilascio richiesto + + + Rilascio approvato + + + Rilascio sospeso + + + Rilascio revocato + + + Rilascio scaduto + + + Credenziale ruotata + + + Flusso di lavoro protetto in scadenza + + + Il rilascio protetto per il flusso di lavoro "{0}" scade il {1}. Un amministratore deve rinnovarlo, altrimenti il flusso di lavoro smetterà di inviare dati. + + + Il rilascio protetto per il flusso di lavoro "{0}" è scaduto e il flusso di lavoro ha smesso di inviare dati. Un amministratore può rinnovarlo dall'editor del flusso di lavoro. + + + Flusso di lavoro protetto non riuscito + + + Il flusso di lavoro protetto "{0}" non è riuscito all'ultimo tentativo (esecuzione {1}, errore {2}). + + + Tentato (registrato prima dell'invio) + + + Richiesta rimozione del secondo approvatore + + + Secondo approvatore non più richiesto + + + Il {0} un amministratore ha chiesto di non richiedere più un secondo approvatore. Un altro amministratore deve confermarlo entro 7 giorni deselezionando la casella e salvando. + + + La tua richiesta di non richiedere più un secondo approvatore è stata registrata. Un altro amministratore deve confermarla prima che diventi effettiva. + + + Qualcun altro ha modificato questa autorizzazione nello stesso momento. Ricarica la pagina e riprova. + + + Non è stato possibile scrivere il registro delle divulgazioni, quindi non è stato inviato nulla. Riprova. + + + Questo flusso di lavoro non ha un'autorizzazione attiva. + + + L'autorizzazione è scaduta. Rinnovala per riprendere gli invii. + + + Solo i passaggi di chiamata API (POST o PUT) possono inviare dati protetti. + + + La credenziale del passaggio non è quella approvata per questa autorizzazione. + + + L'host di destinazione non è quello approvato per questa autorizzazione. + + + La destinazione deve usare HTTPS. + + + La destinazione ha risposto con un reindirizzamento. I reindirizzamenti non vengono mai seguiti per i dati protetti. + + + L'host dei token OAuth2 non è quello approvato per questa autorizzazione. + + + Non è stato possibile decifrare i valori protetti. Non è stato inviato nulla. + + + Non è stato possibile caricare la chiamata per la decifratura. Non è stato inviato nulla. + + + Non è stato possibile generare il modello. Non è stato inviato nulla. + + + Il contenuto generato è troppo grande. Non è stato inviato nulla. + + + Il contenuto generato conteneva dati cifrati ed è stato bloccato. + + + La destinazione non ha risposto in tempo. + + + Il passaggio non è riuscito. La causa non viene registrata per tenere i valori protetti fuori dai log. + + + Identificativi del soggetto (tutte le chiavi) + + + Contenuto non valido + + + Proiezione non riuscita + + + Rifiutato dalla destinazione + + + Risposta troppo grande + + + Bloccato: nessun consenso Part 2 + + + Il contenuto generato non è valido per il suo tipo di contenuto. Non è stato inviato nulla. + + + Non è stato possibile leggere gli identificativi del soggetto della chiamata. Non è stato inviato nulla. + + + La destinazione ha ricevuto la richiesta ma l'ha rifiutata. + + + La risposta della destinazione superava la dimensione consentita, quindi non è stato possibile verificarla. + + + La chiamata non ha un consenso 42 CFR Part 2 registrato, quindi i suoi campi Part 2 non sono stati inviati. + + + La richiesta è stata consegnata, ma non è stato possibile salvare sulla chiamata i valori restituiti dalla destinazione. + + + L'endpoint dei token OAuth2 non ha emesso un token. + + + La credenziale non si autentica più nel modo approvato per questa autorizzazione. + + + La credenziale non ha una chiave di firma attuale. + + + Autorizza l'intero campo degli identificativi del soggetto oppure singole chiavi, non entrambi. + + + Un campo selezionato è riservato. Seleziona la dichiarazione per i campi riservati. + + + Un campo selezionato contiene informazioni 42 CFR Part 2. Seleziona la dichiarazione Part 2. + + + Un passaggio salva valori della risposta negli identificativi del soggetto, quindi l'autorizzazione deve includerli (l'intero campo o almeno una chiave). + + + Un passaggio dichiara un tipo di contenuto che i passaggi protetti non possono usare. + + + La regola di successo di un passaggio è incompleta o sconosciuta. + + + Una voce di acquisizione della risposta di un passaggio non è valida (origine, espressione o chiave). + + + Un passaggio acquisisce più valori della risposta di quelli consentiti. + + + Il nome dell'intestazione di idempotenza di un passaggio non è valido o è riservato. + + + Il valore If-None-Exist di un passaggio è troppo lungo o contiene un'interruzione di riga. + + + La credenziale OAuth2 usa private_key_jwt ma non ha ancora una chiave di firma. Salva la credenziale per generarne una. + + + Un modello inserisce un valore protetto senza json_escape, xml_escape o hl7_escape. Una virgoletta o un'interruzione di riga nel valore può danneggiare il contenuto, che allora non supera la convalida invece di essere inviato. + + + Autenticazione del client + + + Chiavi degli identificativi del soggetto + + + Autorizza singoli identificativi invece dell'intero insieme: solo le chiavi selezionate raggiungono protected.call.subject_ids. + + + Altre chiavi (separate da virgole) + + + Campi personalizzati della chiamata + + + Ogni campo personalizzato selezionato viene decifrato separatamente e viene reso come protected.call.udf.<nome>. + + + Riservato + + + 42 CFR Part 2 + + + Confermo che questo destinatario è autorizzato a ricevere campi riservati ({0}). + + + 42 CFR Part 2: i campi selezionati contengono informazioni su disturbi da uso di sostanze. La loro ridivulgazione richiede il consenso scritto del paziente o un'altra base consentita da 42 CFR Part 2, e il destinatario deve essere informato che ogni ulteriore ridivulgazione è vietata. + + + Dichiaro che il dipartimento dispone del consenso o di un'altra base 42 CFR Part 2 necessaria per questa ridivulgazione ({0}). + + + Una chiamata viene inviata solo quando è selezionato "Consenso Part 2 registrato"; altrimenti il passaggio viene bloccato e registrato. + + + Opzioni di consegna protetta + + + Come la destinazione conferma la ricezione, quali valori restituiti vengono salvati sulla chiamata e come vengono riconosciuti i nuovi tentativi. Ogni opzione fa parte dell'approvazione dell'autorizzazione. + + + Regola di successo + + + Percorso + + + Valore atteso + + + Salva i valori della risposta + + + Uno per riga: origine | espressione | chiave. Origini: json_path, xpath, hl7_field, header, fhir_location_id. I valori vengono salvati cifrati negli identificativi del soggetto della chiamata. + + + Intestazione di idempotenza + + + FHIR If-None-Exist + + + Qualsiasi stato 2xx + + + Il percorso JSON è uguale a + + + XPath è uguale a + + + Conferma HL7 (AA / CA) + + + Nessun errore nell'OperationOutcome FHIR + + + Tipo di contenuto + + + Chiavi salvate + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.pl.resx b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.pl.resx new file mode 100644 index 000000000..962ad78e7 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.pl.resx @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Chronione przepływy pracy + + + Chronione udostępnienia przepływów pracy + + + Dziennik ujawnień chronionych przepływów pracy + + + Przepływy pracy + + + Ochrona danych + + + Chronione przepływy pracy + + + Domyślnie przepływy pracy otrzymują wartości REDACTED. Chronione przepływy pracy pozwalają administratorowi zatwierdzić jeden konkretny przepływ pracy do wysyłania wybranych chronionych pól do jednego przypiętego miejsca docelowego HTTPS. Wszystkie pozostałe przepływy pracy nadal otrzymują wartości REDACTED. + + + Włącz chronione przepływy pracy + + + Wymagaj zatwierdzenia chronionych przepływów pracy przez drugiego administratora + + + Ostrzeżenie + + + Chronione przepływy pracy wysyłają odszyfrowane dane chronione, które mogą zawierać chronione informacje o stanie zdrowia, do skonfigurowanych przez Ciebie systemów zewnętrznych. Resgrid nie ma kontroli nad tym, jak system odbierający przechowuje lub wykorzystuje te dane. Włączaj przepływ pracy tylko wtedy, gdy odbiorcą jest Twoja organizacja lub strona objęta umową z partnerem biznesowym zawartą z Twoją organizacją, i udostępniaj tylko te pola, których odbiorca potrzebuje. + + + Potwierdzam zapoznanie się z tym ostrzeżeniem i przyjmuję je do wiadomości (wersja {0}). + + + Ostrzeżenie przyjęte do wiadomości dnia {0} (wersja {1}). + + + Zapisz ustawienia chronionych przepływów pracy + + + Zapisano ustawienia chronionych przepływów pracy. + + + Chronione przepływy pracy są dostępne tylko wtedy, gdy dla Twojego departamentu jest włączona zaawansowana ochrona danych. + + + Tylko administratorzy z uprawnieniem „Konfigurowanie przekazywania danych chronionych” mogą zmieniać te ustawienia. + + + Zarządzaj chronionymi przepływami pracy + + + Włączenie lub wyłączenie tego ustawienia, a także zatwierdzenie lub odnowienie udostępnienia, wymaga świeżej weryfikacji wieloskładnikowej. + + + Ta czynność wymaga świeżej weryfikacji wieloskładnikowej. Nastąpi przekierowanie do weryfikacji, a następnie powrót na tę stronę; potem powtórz czynność. + + + Chronione udostępnienie + + + Wysyłaj wybrane chronione pola z tego przepływu pracy do jego przypiętego miejsca docelowego HTTPS. Odszyfrowywane są tylko zaznaczone pola; wszystkie pozostałe wartości chronione pozostają REDACTED. + + + Status + + + Niechroniony + + + Wersja robocza + + + Oczekuje na zatwierdzenie + + + Aktywne + + + Zawieszone + + + Wygasłe + + + Odwołane + + + Wkrótce wygasa + + + Odnowienie oczekuje na zatwierdzenie + + + Chronione: {0} + + + zmieniono konfigurację + + + wyłączono chronione przepływy pracy + + + zaawansowana ochrona danych nie jest włączona + + + zmieniono poświadczenie + + + zawieszone przez administratora + + + odwołane przez administratora + + + wyłączanie zaawansowanej ochrony danych + + + usunięto przepływ pracy + + + Pola do udostępnienia + + + Domyślnie nic nie jest zaznaczone. W szablonie wyjściowym odwołuj się do udostępnionych pól jako protected.call.<name>; wartości call.* pozostają REDACTED. + + + Dane formularza są również dostępne w postaci przetworzonej jako protected.call.form. + + + Nazwa zgłoszenia + + + Typ zgłoszenia + + + Charakter zgłoszenia + + + Notatki zgłoszenia + + + Notatki z zakończenia + + + Adres + + + Geolokalizacja + + + Adres what3words + + + Nazwa kontaktu + + + Numer kontaktowy + + + Identyfikator źródła + + + Numer zdarzenia + + + Identyfikator zewnętrzny + + + Numer referencyjny + + + Dane formularza zgłoszenia + + + Powód usunięcia + + + Miejsce docelowe + + + Przypięty host + + + Przypięte poświadczenie + + + Przypięty host tokenu + + + Brak jeszcze prawidłowego miejsca docelowego + + + Tego przepływu pracy nie można jeszcze objąć ochroną + + + Dodaj co najmniej jeden włączony krok. + + + Chronione przepływy pracy mogą zawierać tylko kroki API POST lub PUT. + + + Każdy krok wymaga poświadczenia. + + + Wszystkie kroki muszą używać tego samego poświadczenia. + + + Użyj poświadczenia typu token Bearer, klucz API lub poświadczenia klienta OAuth2. + + + Jeden z kroków używa poświadczenia, które już nie istnieje. + + + Każdy krok wymaga adresu URL. + + + Adres URL każdego kroku musi używać HTTPS. + + + Host w adresie URL musi być wpisany wprost, a nie budowany z szablonu. + + + Wszystkie kroki muszą wysyłać do tego samego hosta. + + + Wartości protected.* nie mogą być używane w adresie URL ani w nagłówku. + + + Wartości protected.* nie mogą być używane w warunku kroku. + + + W tej wersji ochroną można objąć tylko wyzwalacze zgłoszeń. + + + Poświadczenie OAuth2 wymaga adresu URL tokenu HTTPS. + + + Ten przepływ pracy nie ma chronionego udostępnienia, więc wartości protected.* byłyby puste. Najpierw skonfiguruj chronione udostępnienie. + + + Odbiorca + + + Typ odbiorcy + + + Podmiot objęty + + + Partner biznesowy + + + Nazwa odbiorcy + + + Na przykład: DMH hrabstwa, obsługa spraw w Dynamics 365 + + + Cel + + + Dlaczego odbiorca potrzebuje tych pól + + + Oświadczam, że odbiorcą jest nasza organizacja lub strona objęta umową z partnerem biznesowym zawartą z naszą organizacją oraz że potrzebuje on wyłącznie pól wybranych powyżej (wersja ostrzeżenia {0}). + + + Zapisz wersję roboczą + + + Zatwierdź i aktywuj + + + Poproś o zatwierdzenie + + + Zatwierdź + + + Odnów + + + Zawieś + + + Odwołaj + + + Odrzuć wersję roboczą + + + Wyślij test z przykładowymi danymi + + + Wysłano test z danymi syntetycznymi. Każdy krok zwrócił status powodzenia. + + + Test nie zakończył się pełnym powodzeniem. Sprawdź poniżej wyniki kroków oraz dziennik ujawnień. + + + Krok {0}: {1} (HTTP {2}) + + + Odwołać to udostępnienie? Tej operacji nie można cofnąć. Przepływ pracy nie zostanie uruchomiony, dopóki nie zostanie zatwierdzone nowe udostępnienie. + + + Zawiesić to udostępnienie? Przepływ pracy nie zostanie uruchomiony, dopóki administrator ponownie go nie zatwierdzi. + + + Odrzucić tę wersję roboczą? Przepływ pracy znów będzie uruchamiany z wartościami REDACTED. + + + Ten przepływ pracy zmienił się od czasu zatwierdzenia. Nie wyśle niczego, dopóki administrator nie zatwierdzi bieżącej konfiguracji. + + + Data prośby: {0} + + + Data zatwierdzenia: {0} + + + Wygasa: {0} + + + Oczekiwanie na drugiego administratora. Osoba, która poprosiła o udostępnienie, nie może go zatwierdzić. + + + Tylko administratorzy z uprawnieniem „Konfigurowanie przekazywania danych chronionych” mogą zmieniać chronione udostępnienie. Edycja tego przepływu pracy zawiesza zatwierdzone udostępnienie do czasu ponownego zatwierdzenia. + + + Dopóki ten przepływ pracy ma chronione udostępnienie, które nie jest aktywne, jego uruchomienia są pomijane. Nigdy nie jest uruchamiany z wartościami REDACTED w miejscu wartości chronionych. + + + Chronione przepływy pracy są wyłączone dla Twojego departamentu. Włącz je w sekcji Ochrona danych, aby skonfigurować chronione udostępnienie. + + + Nie masz uprawnień do wykonania tej czynności. + + + Wymagana jest świeża weryfikacja wieloskładnikowa. + + + Tę czynność musi wykonać zalogowany administrator, a nie klucz API ani konto usługi. + + + Zaznacz oświadczenie, aby kontynuować. + + + Treść ostrzeżenia uległa zmianie. Odśwież stronę, przeczytaj ostrzeżenie i ponownie przyjmij je do wiadomości. + + + Prośba o to udostępnienie pochodzi od Ciebie, więc musi je zatwierdzić inny administrator. + + + Tej czynności nie można wykonać w bieżącym stanie udostępnienia. + + + Przepływ pracy nie spełnia warunków objęcia ochroną. Popraw wymienione problemy i spróbuj ponownie. + + + Nie znaleziono przepływu pracy ani udostępnienia. + + + W tej wersji ochroną można objąć tylko wyzwalacze zgłoszeń. + + + Wybrano zbyt wiele pól. + + + Wybierz co najmniej jedno pole do udostępnienia. + + + Wybrano nieznane pole. + + + Podaj typ i nazwę odbiorcy. + + + Podaj cel ujawnienia. + + + Najpierw należy włączyć zaawansowaną ochronę danych. + + + Chronione przepływy pracy są wyłączone dla Twojego departamentu. + + + Przepływ pracy zmienił się po złożeniu prośby. Ponownie poproś o zatwierdzenie bieżącej konfiguracji. + + + Miejsce docelowe nie przyjęło żądania. + + + Nie udało się ukończyć czynności. Spróbuj ponownie. + + + Przepływ pracy + + + Status + + + Pola + + + Host + + + Odbiorca + + + Zatwierdzone przez + + + Wygasa + + + Wysyłki (30 dni) + + + Akcje + + + Żaden przepływ pracy nie ma chronionego udostępnienia. + + + Otwórz przepływ pracy + + + Dziennik ujawnień + + + Łańcuch audytu zweryfikowany (rekordów: {0}). + + + Weryfikacja łańcucha audytu nie powiodła się przy rekordzie {0}. Skontaktuj się z pomocą techniczną Resgrid. + + + Odnów w edytorze przepływu pracy, w którym wyświetlane jest oświadczenie. + + + Przepływ pracy + + + Wszystkie przepływy pracy + + + Identyfikator zgłoszenia + + + Od (UTC) + + + Do (UTC) + + + Filtruj + + + Eksportuj CSV + + + Tylko metadane. Ten dziennik nigdy nie zawiera wartości pól, ładunków ani treści odpowiedzi. + + + # + + + Czas zdarzenia (UTC) + + + Typ + + + Zdarzenie lub wynik + + + Zgłoszenie + + + HTTP + + + Bajty + + + Wykonawca + + + Szczegóły + + + Brak pasujących rekordów. + + + test + + + Ujawnienie + + + Administracyjne + + + Wysłano + + + Błąd HTTP + + + Błąd odszyfrowania + + + Zablokowano: host + + + Zablokowano: udostępnienie + + + Zablokowano: departament + + + Zablokowano: szyfrogram w ładunku + + + Błąd renderowania + + + Włączono chronione przepływy pracy + + + Wyłączono chronione przepływy pracy + + + Poproszono o udostępnienie + + + Zatwierdzono udostępnienie + + + Zawieszono udostępnienie + + + Odwołano udostępnienie + + + Udostępnienie wygasło + + + Zmieniono poświadczenie + + + Chroniony przepływ pracy wkrótce wygaśnie + + + Chronione udostępnienie dla przepływu pracy „{0}” wygasa dnia {1}. Administrator musi je odnowić, w przeciwnym razie przepływ pracy przestanie wysyłać dane. + + + Chronione udostępnienie dla przepływu pracy „{0}” wygasło i przepływ pracy przestał wysyłać dane. Administrator może je odnowić w edytorze przepływu pracy. + + + Błąd chronionego przepływu pracy + + + Chroniony przepływ pracy „{0}” zakończył się błędem przy ostatniej próbie (uruchomienie {1}, błąd {2}). + + + Próba (zapisana przed wysłaniem) + + + Zgłoszono wniosek o rezygnację z drugiego zatwierdzającego + + + Drugi zatwierdzający nie jest już wymagany + + + Dnia {0} administrator poprosił o rezygnację z wymogu drugiego zatwierdzającego. Inny administrator musi to potwierdzić w ciągu 7 dni, odznaczając pole i zapisując. + + + Twój wniosek o rezygnację z wymogu drugiego zatwierdzającego został zapisany. Inny administrator musi go potwierdzić, zanim zacznie obowiązywać. + + + Ktoś inny zmienił to zezwolenie w tym samym czasie. Odśwież stronę i spróbuj ponownie. + + + Nie udało się zapisać dziennika ujawnień, więc nic nie zostało wysłane. Spróbuj ponownie. + + + Ten przepływ pracy nie ma aktywnego zezwolenia. + + + Zezwolenie wygasło. Odnów je, aby wznowić wysyłanie. + + + Tylko kroki wywołania API (POST lub PUT) mogą wysyłać dane chronione. + + + Poświadczenie kroku nie jest tym zatwierdzonym dla tego zezwolenia. + + + Host docelowy nie jest tym zatwierdzonym dla tego zezwolenia. + + + Miejsce docelowe musi używać HTTPS. + + + Miejsce docelowe odpowiedziało przekierowaniem. Przekierowania nigdy nie są wykonywane dla danych chronionych. + + + Host tokenów OAuth2 nie jest tym zatwierdzonym dla tego zezwolenia. + + + Nie udało się odszyfrować wartości chronionych. Nic nie zostało wysłane. + + + Nie udało się wczytać zgłoszenia do odszyfrowania. Nic nie zostało wysłane. + + + Nie udało się wygenerować szablonu. Nic nie zostało wysłane. + + + Wygenerowana treść jest zbyt duża. Nic nie zostało wysłane. + + + Wygenerowana treść zawierała zaszyfrowane dane i została zablokowana. + + + Miejsce docelowe nie odpowiedziało na czas. + + + Krok zakończył się niepowodzeniem. Przyczyna nie jest zapisywana, aby wartości chronione nie trafiły do dzienników. + + + Identyfikatory osoby (wszystkie klucze) + + + Nieprawidłowa treść + + + Błąd projekcji + + + Odrzucone przez miejsce docelowe + + + Za duża odpowiedź + + + Zablokowano: brak zgody Part 2 + + + Wygenerowana treść jest nieprawidłowa dla swojego typu treści. Nic nie zostało wysłane. + + + Nie udało się odczytać identyfikatorów osoby w zgłoszeniu. Nic nie zostało wysłane. + + + Miejsce docelowe odebrało żądanie, ale je odrzuciło. + + + Odpowiedź miejsca docelowego przekroczyła dozwolony rozmiar, więc nie można było jej sprawdzić. + + + Zgłoszenie nie ma zarejestrowanej zgody zgodnej z 42 CFR Part 2, więc jego pola Part 2 nie zostały wysłane. + + + Żądanie zostało dostarczone, ale nie udało się zapisać w zgłoszeniu wartości zwróconych przez miejsce docelowe. + + + Punkt końcowy tokenów OAuth2 nie wydał tokenu. + + + Poświadczenie nie uwierzytelnia się już w sposób zatwierdzony dla tego zezwolenia. + + + Poświadczenie nie ma aktualnego klucza podpisu. + + + Zezwól na całe pole identyfikatorów osoby albo na pojedyncze klucze, nie na oba jednocześnie. + + + Wybrane pole jest zastrzeżone. Zaznacz oświadczenie dotyczące pól zastrzeżonych. + + + Wybrane pole zawiera informacje objęte 42 CFR Part 2. Zaznacz oświadczenie Part 2. + + + Krok zapisuje wartości z odpowiedzi w identyfikatorach osoby, więc zezwolenie musi je obejmować (całe pole lub co najmniej jeden klucz). + + + Krok deklaruje typ treści, którego kroki chronione nie mogą używać. + + + Reguła powodzenia kroku jest niekompletna lub nieznana. + + + Wpis przechwytywania odpowiedzi w kroku jest nieprawidłowy (źródło, wyrażenie lub klucz). + + + Krok przechwytuje więcej wartości odpowiedzi, niż jest dozwolone. + + + Nazwa nagłówka idempotencji w kroku jest nieprawidłowa lub zastrzeżona. + + + Wartość If-None-Exist w kroku jest za długa lub zawiera znak nowego wiersza. + + + Poświadczenie OAuth2 używa private_key_jwt, ale nie ma jeszcze klucza podpisu. Zapisz poświadczenie, aby go wygenerować. + + + Szablon umieszcza wartość chronioną bez json_escape, xml_escape ani hl7_escape. Cudzysłów lub znak nowego wiersza w wartości może uszkodzić treść, która wtedy nie przejdzie walidacji zamiast zostać wysłana. + + + Uwierzytelnianie klienta + + + Klucze identyfikatorów osoby + + + Zezwól na pojedyncze identyfikatory zamiast całego zestawu: tylko zaznaczone klucze trafiają do protected.call.subject_ids. + + + Inne klucze (oddzielone przecinkami) + + + Pola niestandardowe zgłoszenia + + + Każde zaznaczone pole niestandardowe jest odszyfrowywane osobno i wyświetlane jako protected.call.udf.<nazwa>. + + + Zastrzeżone + + + 42 CFR Part 2 + + + Potwierdzam, że ten odbiorca jest upoważniony do otrzymywania pól zastrzeżonych ({0}). + + + 42 CFR Part 2: wybrane pola zawierają informacje o zaburzeniach związanych z używaniem substancji. Ich dalsze ujawnienie wymaga pisemnej zgody pacjenta lub innej podstawy dozwolonej przez 42 CFR Part 2, a odbiorca musi zostać poinformowany, że dalsze ujawnianie jest zabronione. + + + Oświadczam, że wydział posiada zgodę lub inną podstawę wymaganą przez 42 CFR Part 2 dla tego ujawnienia ({0}). + + + Zgłoszenie jest wysyłane tylko wtedy, gdy ma zaznaczone „Zgoda Part 2 zarejestrowana”; w przeciwnym razie krok jest blokowany i odnotowywany. + + + Opcje chronionego doręczenia + + + Jak miejsce docelowe potwierdza odbiór, które zwrócone wartości są zapisywane w zgłoszeniu i jak rozpoznawane są ponowne próby. Każda opcja jest częścią zatwierdzenia zezwolenia. + + + Reguła powodzenia + + + Ścieżka + + + Oczekiwana wartość + + + Zapisz wartości z odpowiedzi + + + Jeden na wiersz: źródło | wyrażenie | klucz. Źródła: json_path, xpath, hl7_field, header, fhir_location_id. Wartości są zapisywane w postaci zaszyfrowanej w identyfikatorach osoby zgłoszenia. + + + Nagłówek idempotencji + + + FHIR If-None-Exist + + + Dowolny status 2xx + + + Ścieżka JSON jest równa + + + XPath jest równe + + + Potwierdzenie HL7 (AA / CA) + + + Brak błędu w OperationOutcome FHIR + + + Typ treści + + + Zapisane klucze + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.sv.resx b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.sv.resx new file mode 100644 index 000000000..ff3a8bb6c --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.sv.resx @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Skyddade arbetsflöden + + + Skyddade utlämnanden för arbetsflöden + + + Utlämnandelogg för skyddade arbetsflöden + + + Arbetsflöden + + + Dataskydd + + + Skyddade arbetsflöden + + + Arbetsflöden får som standard REDACTED-värden. Med skyddade arbetsflöden kan en administratör godkänna att ett specifikt arbetsflöde skickar utvalda skyddade fält till ett enda låst HTTPS-mål. Alla andra arbetsflöden fortsätter att få REDACTED-värden. + + + Aktivera skyddade arbetsflöden + + + Kräv att en andra administratör godkänner skyddade arbetsflöden + + + Varning + + + Skyddade arbetsflöden skickar dekrypterade skyddade uppgifter, som kan innehålla skyddade hälsouppgifter, till externa system som du konfigurerar. Resgrid kan inte styra hur det mottagande systemet lagrar eller använder dessa uppgifter. Aktivera ett arbetsflöde endast om mottagaren är din organisation eller en part som omfattas av ett affärspartneravtal med din organisation, och lämna endast ut de fält som mottagaren behöver. + + + Jag har läst och bekräftar denna varning (version {0}). + + + Varningen bekräftades {0} (version {1}). + + + Spara inställningar för skyddade arbetsflöden + + + Inställningarna för skyddade arbetsflöden har sparats. + + + Skyddade arbetsflöden är endast tillgängliga medan avancerat dataskydd är aktiverat för din organisation. + + + Endast administratörer med behörigheten ”Konfigurera leverans av skyddade uppgifter” kan ändra dessa inställningar. + + + Hantera skyddade arbetsflöden + + + Att slå på eller av den här inställningen, samt att godkänna eller förnya ett utlämnande, kräver en färsk flerfaktorsverifiering. + + + Den här åtgärden kräver en färsk flerfaktorsverifiering. Du skickas vidare för att verifiera dig och förs sedan tillbaka till den här sidan; upprepa därefter åtgärden. + + + Skyddat utlämnande + + + Skicka utvalda skyddade fält från det här arbetsflödet till dess låsta HTTPS-mål. Endast de fält du markerar dekrypteras; alla andra skyddade värden förblir REDACTED. + + + Status + + + Inte skyddat + + + Utkast + + + Väntar på godkännande + + + Aktivt + + + Spärrat + + + Utgånget + + + Återkallat + + + Går snart ut + + + Förnyelse väntar på godkännande + + + Skyddat: {0} + + + konfigurationen ändrades + + + skyddade arbetsflöden avstängda + + + avancerat dataskydd är inte aktiverat + + + autentiseringsuppgiften ändrades + + + spärrat av en administratör + + + återkallat av en administratör + + + avregistrering av avancerat dataskydd + + + arbetsflödet togs bort + + + Fält att lämna ut + + + Inget är valt som standard. Referera till utlämnade fält i utdatamallen som protected.call.<name>; call.*-värden förblir REDACTED. + + + Formulärdata finns även tillgängliga i tolkad form som protected.call.form. + + + Larmnamn + + + Larmtyp + + + Larmets art + + + Larmanteckningar + + + Avslutsanteckningar + + + Adress + + + Geografisk position + + + what3words-adress + + + Kontaktnamn + + + Kontaktnummer + + + Källidentifierare + + + Insatsnummer + + + Extern identifierare + + + Referensnummer + + + Larmformulärdata + + + Orsak till borttagning + + + Mål + + + Låst värd + + + Låst autentiseringsuppgift + + + Låst tokenvärd + + + Inget giltigt mål ännu + + + Det här arbetsflödet kan inte skyddas ännu + + + Lägg till minst ett aktiverat steg. + + + Skyddade arbetsflöden får bara innehålla API-steg med POST eller PUT. + + + Varje steg behöver en autentiseringsuppgift. + + + Alla steg måste använda samma autentiseringsuppgift. + + + Använd en autentiseringsuppgift av typen Bearer-token, API-nyckel eller OAuth2-klientautentiseringsuppgifter. + + + Ett steg använder en autentiseringsuppgift som inte längre finns. + + + Varje steg behöver en URL. + + + Varje stegs URL måste använda HTTPS. + + + URL:ens värdnamn måste skrivas ut i klartext, inte byggas från en mall. + + + Alla steg måste skicka till samma värd. + + + protected.*-värden kan inte användas i en URL eller ett huvud. + + + protected.*-värden kan inte användas i ett stegvillkor. + + + I den här versionen kan endast larmutlösare skyddas. + + + OAuth2-autentiseringsuppgiften behöver en HTTPS-token-URL. + + + Det här arbetsflödet har inget skyddat utlämnande, så protected.*-värden skulle bli tomma. Konfigurera först ett skyddat utlämnande. + + + Mottagare + + + Mottagartyp + + + Omfattad aktör + + + Affärspartner + + + Mottagarens namn + + + Till exempel: länets DMH, ärendehantering i Dynamics 365 + + + Syfte + + + Varför mottagaren behöver dessa fält + + + Jag intygar att mottagaren är vår organisation eller en part som omfattas av ett affärspartneravtal med vår organisation, och att den endast behöver de fält som valts ovan (varningsversion {0}). + + + Spara utkast + + + Godkänn och aktivera + + + Begär godkännande + + + Godkänn + + + Förnya + + + Spärra + + + Återkalla + + + Förkasta utkast + + + Skicka test med exempeldata + + + Testet skickades med syntetiska data. Varje steg returnerade en lyckad status. + + + Testet lyckades inte helt. Kontrollera stegresultaten nedan och utlämnandeloggen. + + + Steg {0}: {1} (HTTP {2}) + + + Återkalla detta utlämnande? Det går inte att ångra. Arbetsflödet körs inte förrän ett nytt utlämnande har godkänts. + + + Spärra detta utlämnande? Arbetsflödet körs inte förrän en administratör godkänner det igen. + + + Förkasta detta utkast? Arbetsflödet kommer åter att köras med REDACTED-värden. + + + Det här arbetsflödet har ändrats sedan det godkändes. Det skickar ingenting förrän en administratör godkänner den aktuella konfigurationen. + + + Begärt {0} + + + Godkänt {0} + + + Går ut {0} + + + Väntar på en andra administratör. Den som begärde utlämnandet kan inte godkänna det. + + + Endast administratörer med behörigheten ”Konfigurera leverans av skyddade uppgifter” kan ändra det skyddade utlämnandet. Om du redigerar det här arbetsflödet spärras ett godkänt utlämnande tills det godkänns igen. + + + Så länge det här arbetsflödet har ett skyddat utlämnande som inte är aktivt hoppas dess körningar över. Det körs aldrig med REDACTED-värden i stället för de skyddade värdena. + + + Skyddade arbetsflöden är avstängda för din organisation. Slå på dem under Dataskydd för att konfigurera ett skyddat utlämnande. + + + Du har inte behörighet att göra detta. + + + En färsk flerfaktorsverifiering krävs. + + + Den här åtgärden måste utföras av en inloggad administratör, inte via en API-nyckel eller ett tjänstkonto. + + + Markera intyget för att fortsätta. + + + Varningstexten har ändrats. Läs in sidan igen, läs varningen och bekräfta den på nytt. + + + Du begärde detta utlämnande, så en annan administratör måste godkänna det. + + + Det här utlämnandet kan inte utföra den åtgärden i sitt nuvarande tillstånd. + + + Arbetsflödet uppfyller inte kraven för skydd. Åtgärda de listade problemen och försök igen. + + + Arbetsflödet eller utlämnandet hittades inte. + + + I den här versionen kan endast larmutlösare skyddas. + + + För många fält är valda. + + + Välj minst ett fält att lämna ut. + + + Ett okänt fält valdes. + + + Ange mottagartyp och namn. + + + Ange syftet med utlämnandet. + + + Avancerat dataskydd måste aktiveras först. + + + Skyddade arbetsflöden är avstängda för din organisation. + + + Arbetsflödet ändrades efter begäran. Begär godkännande igen för den aktuella konfigurationen. + + + Målet accepterade inte begäran. + + + Åtgärden kunde inte slutföras. Försök igen. + + + Arbetsflöde + + + Status + + + Fält + + + Värd + + + Mottagare + + + Godkänt av + + + Går ut + + + Sändningar (30 dagar) + + + Åtgärder + + + Inget arbetsflöde har ett skyddat utlämnande. + + + Öppna arbetsflöde + + + Utlämnandelogg + + + Granskningskedjan har verifierats ({0} poster). + + + Verifieringen av granskningskedjan misslyckades vid post {0}. Kontakta Resgrid-supporten. + + + Förnya från arbetsflödesredigeraren, där intyget visas. + + + Arbetsflöde + + + Alla arbetsflöden + + + Larm-ID + + + Från (UTC) + + + Till (UTC) + + + Filtrera + + + Exportera CSV + + + Endast metadata. Den här loggen innehåller aldrig fältvärden, nyttolaster eller svarsinnehåll. + + + # + + + Tidpunkt (UTC) + + + Typ + + + Händelse eller resultat + + + Larm + + + HTTP + + + Byte + + + Utförare + + + Detalj + + + Inga poster matchar. + + + test + + + Utlämnande + + + Administrativ + + + Skickat + + + HTTP misslyckades + + + Dekryptering misslyckades + + + Blockerat: värd + + + Blockerat: utlämnande + + + Blockerat: organisation + + + Blockerat: chiffertext i nyttolast + + + Rendering misslyckades + + + Skyddade arbetsflöden aktiverade + + + Skyddade arbetsflöden inaktiverade + + + Utlämnande begärt + + + Utlämnande godkänt + + + Utlämnande spärrat + + + Utlämnande återkallat + + + Utlämnande har gått ut + + + Autentiseringsuppgift roterad + + + Skyddat arbetsflöde går snart ut + + + Det skyddade utlämnandet för arbetsflödet ”{0}” går ut {1}. En administratör måste förnya det, annars slutar arbetsflödet att skicka. + + + Det skyddade utlämnandet för arbetsflödet ”{0}” har gått ut och arbetsflödet har slutat skicka. En administratör kan förnya det från arbetsflödesredigeraren. + + + Skyddat arbetsflöde misslyckades + + + Det skyddade arbetsflödet ”{0}” misslyckades vid sitt sista försök (körning {1}, fel {2}). + + + Försök (registrerat före sändning) + + + Borttagning av andra godkännare begärd + + + Andra godkännare krävs inte längre + + + Den {0} begärde en administratör att en andra godkännare inte längre ska krävas. En annan administratör måste bekräfta det inom 7 dagar genom att avmarkera rutan och spara. + + + Din begäran om att sluta kräva en andra godkännare har registrerats. En annan administratör måste bekräfta den innan den börjar gälla. + + + Någon annan ändrade den här frisläppningen samtidigt. Ladda om sidan och försök igen. + + + Utlämningsloggen kunde inte skrivas, så inget skickades. Försök igen. + + + Det här arbetsflödet har ingen aktiv frisläppning. + + + Frisläppningen har gått ut. Förnya den för att återuppta sändningen. + + + Endast steg av typen API-anrop (POST eller PUT) kan skicka skyddade data. + + + Stegets autentiseringsuppgift är inte den som godkänts för den här frisläppningen. + + + Målvärden är inte den som godkänts för den här frisläppningen. + + + Målet måste använda HTTPS. + + + Målet svarade med en omdirigering. Omdirigeringar följs aldrig för skyddade data. + + + OAuth2-tokenvärden är inte den som godkänts för den här frisläppningen. + + + De skyddade värdena kunde inte dekrypteras. Inget skickades. + + + Larmet kunde inte läsas in för dekryptering. Inget skickades. + + + Mallen kunde inte återges. Inget skickades. + + + Det återgivna innehållet är för stort. Inget skickades. + + + Det återgivna innehållet innehöll krypterade data och blockerades. + + + Målet svarade inte i tid. + + + Steget misslyckades. Orsaken registreras inte, så att skyddade värden hålls utanför loggarna. + + + Personidentifierare (alla nycklar) + + + Ogiltigt innehåll + + + Projektionen misslyckades + + + Avvisat av mottagaren + + + Svaret för stort + + + Blockerat: inget Part 2-samtycke + + + Det återgivna innehållet är inte giltigt för sin innehållstyp. Inget skickades. + + + Larmets personidentifierare kunde inte läsas. Inget skickades. + + + Mottagaren tog emot begäran men avvisade den. + + + Mottagarens svar var större än tillåtet och kunde därför inte kontrolleras. + + + Larmet har inget registrerat samtycke enligt 42 CFR Part 2, så dess Part 2-fält skickades inte. + + + Begäran levererades, men värdena som mottagaren returnerade kunde inte sparas på larmet. + + + OAuth2-tokenslutpunkten utfärdade ingen token. + + + Autentiseringsuppgiften autentiserar inte längre på det sätt som frisläppningen godkände. + + + Autentiseringsuppgiften har ingen aktuell signeringsnyckel. + + + Frisläpp antingen hela fältet med personidentifierare eller enskilda nycklar, inte båda. + + + Ett valt fält är begränsat. Markera intyget för begränsade fält. + + + Ett valt fält innehåller information enligt 42 CFR Part 2. Markera Part 2-intyget. + + + Ett steg sparar svarsvärden i personidentifierarna, så frisläppningen måste omfatta dem (hela fältet eller minst en nyckel). + + + Ett steg anger en innehållstyp som skyddade steg inte får använda. + + + Ett stegs framgångsregel är ofullständig eller okänd. + + + En svarsfångst i ett steg är ogiltig (källa, uttryck eller nyckel). + + + Ett steg fångar fler svarsvärden än tillåtet. + + + Ett stegs namn på idempotensrubrik är ogiltigt eller reserverat. + + + Ett stegs If-None-Exist-värde är för långt eller innehåller en radbrytning. + + + OAuth2-autentiseringsuppgiften använder private_key_jwt men har ännu ingen signeringsnyckel. Spara autentiseringsuppgiften för att skapa en. + + + En mall placerar ett skyddat värde utan json_escape, xml_escape eller hl7_escape. Ett citattecken eller en radbrytning i värdet kan bryta innehållet, som då underkänns i valideringen i stället för att skickas. + + + Klientautentisering + + + Nycklar för personidentifierare + + + Frisläpp enskilda identifierare i stället för hela uppsättningen: endast markerade nycklar når protected.call.subject_ids. + + + Andra nycklar (kommaseparerade) + + + Anpassade fält för larm + + + Varje markerat anpassat fält dekrypteras för sig och återges som protected.call.udf.<namn>. + + + Begränsat + + + 42 CFR Part 2 + + + Jag bekräftar att mottagaren har behörighet att ta emot begränsade fält ({0}). + + + 42 CFR Part 2: de valda fälten innehåller information om substansbrukssyndrom. För att lämna ut den vidare krävs patientens skriftliga samtycke eller annan grund som 42 CFR Part 2 tillåter, och mottagaren måste informeras om att ytterligare vidareutlämning är förbjuden. + + + Jag intygar att avdelningen har det samtycke eller den annan grund enligt 42 CFR Part 2 som krävs för detta vidareutlämnande ({0}). + + + Ett larm skickas endast när "Part 2-samtycke registrerat" är markerat på det; annars blockeras steget och registreras. + + + Alternativ för skyddad leverans + + + Hur mottagaren bekräftar mottagandet, vilka returnerade värden som sparas på larmet och hur omförsök känns igen. Varje alternativ ingår i godkännandet av frisläppningen. + + + Framgångsregel + + + Sökväg + + + Förväntat värde + + + Spara svarsvärden + + + En per rad: källa | uttryck | nyckel. Källor: json_path, xpath, hl7_field, header, fhir_location_id. Värdena sparas krypterade i larmets personidentifierare. + + + Idempotensrubrik + + + FHIR If-None-Exist + + + Valfri 2xx-status + + + JSON-sökväg är lika med + + + XPath är lika med + + + HL7-kvittens (AA / CA) + + + Inget fel i FHIR OperationOutcome + + + Innehållstyp + + + Sparade nycklar + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.uk.resx b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.uk.resx new file mode 100644 index 000000000..9a443ce6a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ProtectedWorkflows/ProtectedWorkflows.uk.resx @@ -0,0 +1,862 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Захищені робочі процеси + + + Дозволи на захищену передачу для робочих процесів + + + Журнал розкриття даних захищених робочих процесів + + + Робочі процеси + + + Захист даних + + + Захищені робочі процеси + + + За замовчуванням робочі процеси отримують значення REDACTED. Захищені робочі процеси дають змогу адміністратору схвалити один конкретний робочий процес для надсилання вибраних захищених полів до одного закріпленого HTTPS-призначення. Усі інші робочі процеси й надалі отримують значення REDACTED. + + + Увімкнути захищені робочі процеси + + + Вимагати схвалення захищених робочих процесів другим адміністратором + + + Попередження + + + Захищені робочі процеси надсилають розшифровані захищені дані, які можуть містити захищену медичну інформацію, до налаштованих вами зовнішніх систем. Resgrid не може контролювати, як система-отримувач зберігає або використовує ці дані. Вмикайте робочий процес лише тоді, коли отримувачем є ваша організація або сторона, на яку поширюється угода з бізнес-партнером, укладена з вашою організацією, і передавайте лише ті поля, які потрібні отримувачу. + + + Підтверджую ознайомлення з цим попередженням і беру його до відома (версія {0}). + + + Попередження взято до відома {0} (версія {1}). + + + Зберегти налаштування захищених робочих процесів + + + Налаштування захищених робочих процесів збережено. + + + Захищені робочі процеси доступні лише тоді, коли для вашого підрозділу ввімкнено розширений захист даних. + + + Змінювати ці налаштування можуть лише адміністратори з правом «Налаштування передачі захищених даних». + + + Керувати захищеними робочими процесами + + + Для ввімкнення чи вимкнення цього налаштування, а також для схвалення чи поновлення дозволу на передачу потрібне свіже багатофакторне підтвердження. + + + Для цієї дії потрібне свіже багатофакторне підтвердження. Вас буде перенаправлено на підтвердження, а потім повернуто на цю сторінку; після цього повторіть дію. + + + Дозвіл на захищену передачу + + + Надсилайте вибрані захищені поля з цього робочого процесу до його закріпленого HTTPS-призначення. Розшифровуються лише позначені вами поля; усі інші захищені значення залишаються REDACTED. + + + Статус + + + Не захищено + + + Чернетка + + + Очікує схвалення + + + Активний + + + Призупинений + + + Термін дії минув + + + Відкликаний + + + Термін дії спливає + + + Поновлення очікує схвалення + + + Захищено: {0} + + + змінено конфігурацію + + + захищені робочі процеси вимкнено + + + розширений захист даних не ввімкнено + + + змінено облікові дані + + + призупинено адміністратором + + + відкликано адміністратором + + + відключення розширеного захисту даних + + + робочий процес видалено + + + Поля для передачі + + + За замовчуванням нічого не вибрано. У вихідному шаблоні посилайтеся на передані поля як protected.call.<name>; значення call.* залишаються REDACTED. + + + Дані форми також доступні в розібраному вигляді як protected.call.form. + + + Назва виклику + + + Тип виклику + + + Характер виклику + + + Нотатки виклику + + + Нотатки про завершення + + + Адреса + + + Геолокація + + + Адреса what3words + + + Ім'я контакту + + + Номер контакту + + + Ідентифікатор джерела + + + Номер інциденту + + + Зовнішній ідентифікатор + + + Довідковий номер + + + Дані форми виклику + + + Причина видалення + + + Призначення + + + Закріплений хост + + + Закріплені облікові дані + + + Закріплений хост токена + + + Дійсного призначення ще немає + + + Цей робочий процес поки що не можна захистити + + + Додайте принаймні один увімкнений крок. + + + Захищені робочі процеси можуть містити лише кроки API POST або PUT. + + + Кожному кроку потрібні облікові дані. + + + Усі кроки мають використовувати ті самі облікові дані. + + + Використовуйте облікові дані типу «токен Bearer», «ключ API» або «облікові дані клієнта OAuth2». + + + Один із кроків використовує облікові дані, яких більше не існує. + + + Кожному кроку потрібна URL-адреса. + + + URL-адреса кожного кроку має використовувати HTTPS. + + + Хост в URL-адресі має бути записаний явно, а не сформований із шаблону. + + + Усі кроки мають надсилати дані на той самий хост. + + + Значення protected.* не можна використовувати в URL-адресі чи заголовку. + + + Значення protected.* не можна використовувати в умові кроку. + + + У цій версії можна захищати лише тригери викликів. + + + Обліковим даним OAuth2 потрібна HTTPS-адреса токена. + + + Цей робочий процес не має дозволу на захищену передачу, тому значення protected.* будуть порожніми. Спочатку налаштуйте дозвіл на захищену передачу. + + + Отримувач + + + Тип отримувача + + + Охоплений суб'єкт + + + Бізнес-партнер + + + Назва отримувача + + + Наприклад: DMH округу, ведення справ у Dynamics 365 + + + Мета + + + Навіщо отримувачу потрібні ці поля + + + Я засвідчую, що отримувачем є наша організація або сторона, на яку поширюється угода з бізнес-партнером, укладена з нашою організацією, і що йому потрібні лише вибрані вище поля (версія попередження {0}). + + + Зберегти чернетку + + + Схвалити й активувати + + + Запросити схвалення + + + Схвалити + + + Поновити + + + Призупинити + + + Відкликати + + + Скасувати чернетку + + + Надіслати тест зі зразковими даними + + + Тест надіслано із синтетичними даними. Кожен крок повернув статус успіху. + + + Тест не був повністю успішним. Перевірте результати кроків нижче та журнал розкриття даних. + + + Крок {0}: {1} (HTTP {2}) + + + Відкликати цей дозвіл? Цю дію неможливо скасувати. Робочий процес не запускатиметься, доки не буде схвалено новий дозвіл. + + + Призупинити цей дозвіл? Робочий процес не запускатиметься, доки адміністратор не схвалить його знову. + + + Скасувати цю чернетку? Робочий процес знову запускатиметься зі значеннями REDACTED. + + + Цей робочий процес змінився після схвалення. Він нічого не надсилатиме, доки адміністратор не схвалить поточну конфігурацію. + + + Запитано {0} + + + Схвалено {0} + + + Діє до {0} + + + Очікується другий адміністратор. Особа, яка запросила дозвіл, не може його схвалити. + + + Змінювати дозвіл на захищену передачу можуть лише адміністратори з правом «Налаштування передачі захищених даних». Редагування цього робочого процесу призупиняє схвалений дозвіл, доки його не буде схвалено знову. + + + Поки цей робочий процес має неактивний дозвіл на захищену передачу, його запуски пропускаються. Він ніколи не запускається зі значеннями REDACTED замість захищених. + + + Захищені робочі процеси вимкнено для вашого підрозділу. Увімкніть їх у розділі «Захист даних», щоб налаштувати дозвіл на захищену передачу. + + + У вас немає прав на цю дію. + + + Потрібне свіже багатофакторне підтвердження. + + + Цю дію має виконати адміністратор, який увійшов у систему, а не ключ API чи службовий обліковий запис. + + + Позначте засвідчення, щоб продовжити. + + + Текст попередження змінився. Перезавантажте сторінку, прочитайте його та знову візьміть до відома. + + + Цей дозвіл запросили ви, тому його має схвалити інший адміністратор. + + + У поточному стані цього дозволу така дія неможлива. + + + Робочий процес не відповідає вимогам для захисту. Виправте наведені проблеми та спробуйте ще раз. + + + Робочий процес або дозвіл не знайдено. + + + У цій версії можна захищати лише тригери викликів. + + + Вибрано забагато полів. + + + Виберіть принаймні одне поле для передачі. + + + Вибрано невідоме поле. + + + Введіть тип і назву отримувача. + + + Введіть мету розкриття даних. + + + Спочатку потрібно ввімкнути розширений захист даних. + + + Захищені робочі процеси вимкнено для вашого підрозділу. + + + Робочий процес змінився після запиту. Запросіть схвалення поточної конфігурації ще раз. + + + Призначення не прийняло запит. + + + Не вдалося виконати дію. Спробуйте ще раз. + + + Робочий процес + + + Статус + + + Поля + + + Хост + + + Отримувач + + + Ким схвалено + + + Діє до + + + Надсилання (30 днів) + + + Дії + + + Жоден робочий процес не має дозволу на захищену передачу. + + + Відкрити робочий процес + + + Журнал розкриття даних + + + Ланцюжок аудиту перевірено (записів: {0}). + + + Перевірка ланцюжка аудиту не пройшла на записі {0}. Зверніться до служби підтримки Resgrid. + + + Поновіть дозвіл у редакторі робочого процесу, де показано засвідчення. + + + Робочий процес + + + Усі робочі процеси + + + ID виклику + + + З (UTC) + + + По (UTC) + + + Фільтрувати + + + Експортувати CSV + + + Лише метадані. Цей журнал ніколи не містить значень полів, корисних навантажень чи тіл відповідей. + + + # + + + Час події (UTC) + + + Тип + + + Подія або результат + + + Виклик + + + HTTP + + + Байти + + + Виконавець + + + Подробиці + + + Немає відповідних записів. + + + тест + + + Розкриття + + + Адміністративний + + + Надіслано + + + Помилка HTTP + + + Помилка розшифрування + + + Заблоковано: хост + + + Заблоковано: дозвіл + + + Заблоковано: підрозділ + + + Заблоковано: шифротекст у корисному навантаженні + + + Помилка формування + + + Захищені робочі процеси ввімкнено + + + Захищені робочі процеси вимкнено + + + Дозвіл запитано + + + Дозвіл схвалено + + + Дозвіл призупинено + + + Дозвіл відкликано + + + Термін дії дозволу минув + + + Облікові дані змінено + + + Термін дії захищеного робочого процесу спливає + + + Термін дії дозволу на захищену передачу для робочого процесу «{0}» спливає {1}. Адміністратор має його поновити, інакше робочий процес припинить надсилання. + + + Термін дії дозволу на захищену передачу для робочого процесу «{0}» минув, і робочий процес припинив надсилання. Адміністратор може поновити його в редакторі робочого процесу. + + + Помилка захищеного робочого процесу + + + Захищений робочий процес «{0}» завершився помилкою під час останньої спроби (запуск {1}, помилка {2}). + + + Спроба (записано перед надсиланням) + + + Запит на скасування другого затверджувача + + + Другий затверджувач більше не потрібен + + + {0} адміністратор попросив більше не вимагати другого затверджувача. Інший адміністратор має підтвердити це протягом 7 днів, знявши позначку та зберігши. + + + Ваш запит більше не вимагати другого затверджувача записано. Інший адміністратор має підтвердити його, перш ніж він набуде чинності. + + + Хтось інший змінив цей дозвіл одночасно з вами. Перезавантажте сторінку та спробуйте ще раз. + + + Не вдалося записати журнал розкриттів, тому нічого не було надіслано. Спробуйте ще раз. + + + Цей робочий процес не має активного дозволу. + + + Термін дії дозволу минув. Поновіть його, щоб відновити надсилання. + + + Лише кроки виклику API (POST або PUT) можуть надсилати захищені дані. + + + Облікові дані кроку не є затвердженими для цього дозволу. + + + Цільовий хост не є затвердженим для цього дозволу. + + + Призначення має використовувати HTTPS. + + + Призначення відповіло перенаправленням. Для захищених даних перенаправлення ніколи не виконуються. + + + Хост токенів OAuth2 не є затвердженим для цього дозволу. + + + Не вдалося розшифрувати захищені значення. Нічого не надіслано. + + + Не вдалося завантажити виклик для розшифрування. Нічого не надіслано. + + + Не вдалося сформувати шаблон. Нічого не надіслано. + + + Сформований вміст завеликий. Нічого не надіслано. + + + Сформований вміст містив зашифровані дані й був заблокований. + + + Призначення не відповіло вчасно. + + + Крок завершився помилкою. Причина не записується, щоб захищені значення не потрапили до журналів. + + + Ідентифікатори особи (усі ключі) + + + Недійсний вміст + + + Помилка проєкції + + + Відхилено призначенням + + + Завелика відповідь + + + Заблоковано: немає згоди Part 2 + + + Сформований вміст недійсний для свого типу вмісту. Нічого не надіслано. + + + Не вдалося прочитати ідентифікатори особи виклику. Нічого не надіслано. + + + Призначення отримало запит, але відхилило його. + + + Відповідь призначення перевищила допустимий розмір, тому її не вдалося перевірити. + + + Для виклику не зареєстровано згоди згідно з 42 CFR Part 2, тому його поля Part 2 не надіслано. + + + Запит доставлено, але значення, повернуті призначенням, не вдалося зберегти у виклику. + + + Кінцева точка токенів OAuth2 не видала токен. + + + Облікові дані більше не автентифікуються у спосіб, затверджений для цього дозволу. + + + Облікові дані не мають чинного ключа підпису. + + + Дозвольте або все поле ідентифікаторів особи, або окремі ключі, але не обидва варіанти. + + + Вибране поле обмежене. Позначте підтвердження для обмежених полів. + + + Вибране поле містить інформацію згідно з 42 CFR Part 2. Позначте підтвердження Part 2. + + + Крок зберігає значення з відповіді в ідентифікаторах особи, тому дозвіл має їх охоплювати (усе поле або принаймні один ключ). + + + Крок оголошує тип вмісту, який захищені кроки не можуть використовувати. + + + Правило успіху кроку неповне або невідоме. + + + Запис збереження відповіді в кроці недійсний (джерело, вираз або ключ). + + + Крок зберігає більше значень відповіді, ніж дозволено. + + + Назва заголовка ідемпотентності кроку недійсна або зарезервована. + + + Значення If-None-Exist кроку задовге або містить розрив рядка. + + + Облікові дані OAuth2 використовують private_key_jwt, але ще не мають ключа підпису. Збережіть облікові дані, щоб його створити. + + + Шаблон вставляє захищене значення без json_escape, xml_escape або hl7_escape. Лапки чи розрив рядка у значенні можуть зламати вміст, який тоді не пройде перевірку замість надсилання. + + + Автентифікація клієнта + + + Ключі ідентифікаторів особи + + + Дозвольте окремі ідентифікатори замість усього набору: лише позначені ключі потрапляють до protected.call.subject_ids. + + + Інші ключі (через кому) + + + Користувацькі поля виклику + + + Кожне позначене користувацьке поле розшифровується окремо та відображається як protected.call.udf.<назва>. + + + Обмежене + + + 42 CFR Part 2 + + + Підтверджую, що цей одержувач має право отримувати обмежені поля ({0}). + + + 42 CFR Part 2: вибрані поля містять інформацію про розлади, пов'язані з уживанням психоактивних речовин. Її повторне розкриття вимагає письмової згоди пацієнта або іншої підстави, дозволеної 42 CFR Part 2, а одержувача слід повідомити, що подальше розкриття заборонене. + + + Засвідчую, що підрозділ має згоду або іншу підставу згідно з 42 CFR Part 2, необхідну для цього розкриття ({0}). + + + Виклик надсилається лише тоді, коли для нього позначено «Згоду Part 2 зареєстровано»; інакше крок блокується та реєструється. + + + Параметри захищеної доставки + + + Як призначення підтверджує отримання, які повернуті значення зберігаються у виклику та як розпізнаються повторні спроби. Кожен параметр є частиною затвердження дозволу. + + + Правило успіху + + + Шлях + + + Очікуване значення + + + Зберегти значення з відповіді + + + По одному в рядку: джерело | вираз | ключ. Джерела: json_path, xpath, hl7_field, header, fhir_location_id. Значення зберігаються зашифрованими в ідентифікаторах особи виклику. + + + Заголовок ідемпотентності + + + FHIR If-None-Exist + + + Будь-який статус 2xx + + + Шлях JSON дорівнює + + + XPath дорівнює + + + Підтвердження HL7 (AA / CA) + + + Немає помилки в OperationOutcome FHIR + + + Тип вмісту + + + Збережені ключі + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.ar.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.ar.resx index d8b70cdc2..3c6f4a801 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.ar.resx @@ -172,4 +172,111 @@ إزالة الدور إزالة هذا الدور من المجموعة عدد الدور مطلوب + نشطة الآن + إضافة إلى اليوم + إضافة شخص إلى هذا اليوم + يضع الشخص في هذا اليوم فقط؛ ويبقى باقي الجدول دون تغيير. + تتطلب موافقة + تحتاج التسجيلات والتبادلات في هذه الوردية إلى موافقة المشرف. + الموافقات + موافقات الورديات + التسجيلات والتبادلات التي تنتظر الموافقة في المجموعات التي تشرف عليها. لا يصبح أحد في الخدمة بطلب معلق حتى تتم الموافقة عليه. + موافقة + رفض + المبادلة بهذا اليوم + اكتمل هذا التبادل: {0} يعمل في هذا اليوم. + رفض المشرف الاختيار الأخير لهذا التبادل. يمكنك اختيار عرض آخر. + لقد اخترت {0}. التبادل بانتظار موافقة المشرف. + اختر عرضاً لقبوله. + مكتملة العدد + المجموعة + أنت غير مدرج في يوم الوردية هذا. + أنت مدرج في يوم الوردية هذا. + تسجيلك بانتظار موافقة المشرف. + لم يوافق المشرف على تسجيلك أو أزالك من هذا اليوم. + بدون مجموعة + لا توجد تسجيلات بانتظار الموافقة. + لا توجد تبادلات بانتظار الموافقة. + لا يوجد أحد في هذا اليوم بعد. + لا توجد ورديات جارية الآن. + لا أحد في الخدمة + في الخدمة حتى {0}. يتم تحديث هذه الصفحة كل دقيقة. + في الخدمة الآن + في الخدمة الآن + المقاعد الشاغرة: {0} + ملاحظة (اختياري) + أفراد آخرون + بانتظار الموافقة + تسجيلات بانتظار الموافقة + تبادلات بانتظار الموافقة + تمت إضافة الشخص إلى هذا اليوم. + الشخص + تمت إزالة الشخص من هذا اليوم. + تحديث + إزالة من هذا اليوم + هل تريد إزالة هذا الشخص من يوم الوردية هذا؟ يبقى باقي جدوله دون تغيير. + الأيام التي أُزلت منها + التسجيلات التي لم يوافق عليها المشرف والأيام التي أزالك منها المشرف. + مقدم الطلب + عند التحديد، لا يضع التسجيل أو التبادل أي شخص في الخدمة حتى يوافق عليه مشرف المجموعة. + تحتاج التسجيلات والتبادلات إلى موافقة المشرف + ملاحظة + قائمة المناوبين + اختر التواريخ... + اختر شخصاً لإضافته. + اختر المستخدمين... + تم حذف الوردية. + هذا الشخص موجود بالفعل في هذا اليوم ضمن تلك المجموعة. + أنت مسجل بالفعل في يوم الوردية هذا. + انتهى يوم الوردية هذا. + هذه المجموعة ليست جزءاً من هذه الوردية. + لم يعد هذا العرض متاحاً. + لا يمكن تنفيذ ذلك ليوم الوردية هذا. + اختر شخصاً واحداً على الأقل. + غير مسموح لك بذلك. + تعذر العثور على يوم الوردية أو التسجيل أو التبادل. + هذا الشخص غير مدرج في يوم الوردية هذا. + لم يعد هذا الطلب بانتظار الموافقة. + تم طلب تبادل لهذا اليوم بالفعل. + يمكنك استخدام المجموعات التي تديرها فقط، ويجب أن تتضمن الوردية واحدة منها على الأقل. + {0} في {1} + تتم إضافة كل من تم اختيارهم ممن لا يعملون بالفعل في ذلك اليوم. + تم حفظ التوزيع. عدد الأشخاص المضافين إلى اليوم: {0}. + تم حفظ التوزيع. عدد الأشخاص المضافين إلى اليوم: {0}. تعذرت إضافة: {1} + يجب اختيار يوم وردية صالح + يجب اختيار وردية + تمت الموافقة على التسجيل. + تم رفض التسجيل. + التسجيل في هذه الوردية + تم استلام تسجيلك وهو بانتظار موافقة المشرف. لن تكون في الخدمة في هذا اليوم حتى تتم الموافقة عليه. + تم تبادل هذا اليوم أو أن هناك تبادلاً بانتظار الموافقة، لذا لا يمكن سحبه. اطلب من المشرف تغيير اليوم بدلاً من ذلك. + تم سحب تسجيلك. + معيّن + تسجيل + أضافه المشرف + تبادل (من {0}) + التوزيع + اليوم المقابل: {0} + تم إكمال هذا التبادل بالفعل. + تمت الموافقة على التبادل. + اكتمل التبادل. + لقد رفضت طلب التبادل. + تم رفض التبادل. + يجب الموافقة على التسجيل قبل أن يمكن تبادله. + تم إرسال طلب التبادل الخاص بك. + مقدم الطلب: {0} + تم إرسال ردك. + مكتمل + رفضه المشرف + مطلوب + بانتظار موافقة المشرف + يجب تحديد المستخدمين الذين تطلب منهم التبادل. تعرض القائمة المستخدمين المؤهلين فقط. + تم حفظ اختيارك. التبادل بانتظار موافقة المشرف. + تم تبادله معك من قبل {0} + غير معروف + عرض اليوم + انسحاب + هل تريد الانسحاب من يوم الوردية هذا؟ + أنت + حالتك diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx index ebce5081f..8e0728c2a 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx @@ -569,4 +569,325 @@ Rollenanzahl ist erforderlich + + Jetzt aktiv + + + Zum Tag hinzufügen + + + Person zu diesem Tag hinzufügen + + + Setzt die Person nur für diesen Tag ein; der restliche Plan bleibt unverändert. + + + Genehmigung erforderlich + + + Anmeldungen und Tausche für diese Schicht müssen von Vorgesetzten genehmigt werden. + + + Genehmigungen + + + Schichtgenehmigungen + + + Anmeldungen und Tausche in den von Ihnen betreuten Gruppen, die auf eine Genehmigung warten. Bis zur Genehmigung ist niemand aufgrund einer offenen Anfrage im Dienst. + + + Genehmigen + + + Ablehnen + + + Gegen diesen Tag tauschen + + + Dieser Tausch ist abgeschlossen: {0} arbeitet an diesem Tag. + + + Vorgesetzte haben die letzte Auswahl für diesen Tausch abgelehnt. Sie können ein anderes Angebot wählen. + + + Sie haben {0} gewählt. Der Tausch wartet auf die Genehmigung durch Vorgesetzte. + + + Wählen Sie ein Angebot aus, das Sie annehmen möchten. + + + Voll besetzt + + + Gruppe + + + Sie sind an diesem Schichttag nicht eingeteilt. + + + Sie sind an diesem Schichttag eingeteilt. + + + Ihre Anmeldung wartet auf die Genehmigung durch Vorgesetzte. + + + Vorgesetzte haben Ihre Anmeldung nicht genehmigt oder Sie von diesem Tag abgezogen. + + + Keine Gruppe + + + Keine Anmeldungen warten auf Genehmigung. + + + Keine Tausche warten auf Genehmigung. + + + Für diesen Tag ist noch niemand eingeteilt. + + + Derzeit läuft keine Schicht. + + + Niemand im Dienst + + + Im Dienst, Stand {0}. Diese Seite wird jede Minute aktualisiert. + + + Jetzt im Dienst + + + Jetzt im Dienst + + + Offene Plätze: {0} + + + Hinweis (optional) + + + Weiteres Personal + + + Genehmigung ausstehend + + + Anmeldungen, die auf Genehmigung warten + + + Tausche, die auf Genehmigung warten + + + Die Person wurde diesem Tag hinzugefügt. + + + Person + + + Die Person wurde von diesem Tag abgezogen. + + + Aktualisieren + + + Von diesem Tag abziehen + + + Diese Person von diesem Schichttag abziehen? Ihr restlicher Plan bleibt unverändert. + + + Tage, von denen Sie abgezogen wurden + + + Anmeldungen, die Vorgesetzte nicht genehmigt haben, und Tage, von denen Vorgesetzte Sie abgezogen haben. + + + Angefragt von + + + Wenn aktiviert, setzt eine Anmeldung oder ein Tausch erst dann jemanden in den Dienst, wenn Vorgesetzte der Gruppe sie genehmigt haben. + + + Anmeldungen und Tausche müssen von Vorgesetzten genehmigt werden + + + Hinweis + + + Dienstplan + + + Tage auswählen... + + + Wählen Sie eine Person aus, die hinzugefügt werden soll. + + + Benutzer auswählen... + + + Die Schicht wurde gelöscht. + + + Diese Person ist an diesem Tag bereits in dieser Gruppe eingeteilt. + + + Sie sind für diesen Schichttag bereits angemeldet. + + + Dieser Schichttag ist vorbei. + + + Diese Gruppe gehört nicht zu dieser Schicht. + + + Dieses Angebot ist nicht mehr verfügbar. + + + Das ist für diesen Schichttag nicht möglich. + + + Wählen Sie mindestens eine Person aus. + + + Dazu sind Sie nicht berechtigt. + + + Der Schichttag, die Anmeldung oder der Tausch wurde nicht gefunden. + + + Diese Person ist an diesem Schichttag nicht eingeteilt. + + + Diese Anfrage wartet nicht mehr auf Genehmigung. + + + Für diesen Tag wurde bereits ein Tausch angefragt. + + + Sie können nur die von Ihnen verwalteten Gruppen verwenden, und eine Schicht benötigt mindestens eine davon. + + + {0} am {1} + + + Alle ausgewählten Personen, die an diesem Tag noch nicht arbeiten, werden hinzugefügt. + + + Besetzung gespeichert. Zum Tag hinzugefügte Personen: {0}. + + + Besetzung gespeichert. Zum Tag hinzugefügte Personen: {0}. Nicht hinzugefügt: {1} + + + Sie müssen einen gültigen Schichttag auswählen + + + Sie müssen eine Schicht auswählen + + + Die Anmeldung wurde genehmigt. + + + Die Anmeldung wurde abgelehnt. + + + Für diese Schicht anmelden + + + Ihre Anmeldung ist eingegangen und wartet auf die Genehmigung durch Vorgesetzte. Bis zur Genehmigung sind Sie an diesem Tag nicht im Dienst. + + + Dieser Tag wurde getauscht oder ein Tausch wartet auf Genehmigung, daher kann er nicht zurückgezogen werden. Bitten Sie stattdessen Vorgesetzte, den Tag zu ändern. + + + Ihre Anmeldung wurde zurückgezogen. + + + Eingeteilt + + + Anmeldung + + + Von Vorgesetzten hinzugefügt + + + Tausch (von {0}) + + + Besetzung + + + Gegentausch: {0} + + + Dieser Tausch ist bereits besetzt. + + + Der Tausch wurde genehmigt. + + + Der Tausch ist abgeschlossen. + + + Sie haben die Tauschanfrage abgelehnt. + + + Der Tausch wurde abgelehnt. + + + Eine Anmeldung muss genehmigt sein, bevor sie getauscht werden kann. + + + Ihre Tauschanfrage wurde gesendet. + + + Angefragt von {0} + + + Ihre Antwort wurde gesendet. + + + Abgeschlossen + + + Von Vorgesetzten abgelehnt + + + Angefragt + + + Wartet auf Genehmigung durch Vorgesetzte + + + Sie müssen Benutzer angeben, bei denen ein Tausch angefragt werden soll. Die Liste enthält nur qualifizierte Benutzer. + + + Ihre Auswahl wurde gespeichert. Der Tausch wartet auf die Genehmigung durch Vorgesetzte. + + + Von {0} an Sie getauscht + + + Unbekannt + + + Tag anzeigen + + + Zurückziehen + + + Von diesem Schichttag zurückziehen? + + + Sie + + + Ihr Status + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.el.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.el.resx index 93eb62211..e71603d77 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.el.resx @@ -633,4 +633,325 @@ Το πλήθος ρόλων είναι υποχρεωτικό + + Σε εξέλιξη + + + Προσθήκη στην ημέρα + + + Προσθήκη ατόμου σε αυτή την ημέρα + + + Τοποθετεί το άτομο μόνο σε αυτή την ημέρα· το υπόλοιπο πρόγραμμα δεν αλλάζει. + + + Απαιτείται έγκριση + + + Οι δηλώσεις συμμετοχής και οι ανταλλαγές σε αυτή τη βάρδια χρειάζονται έγκριση προϊσταμένου. + + + Εγκρίσεις + + + Εγκρίσεις βαρδιών + + + Δηλώσεις συμμετοχής και ανταλλαγές που περιμένουν έγκριση στις ομάδες που εποπτεύετε. Ένα εκκρεμές αίτημα δεν βάζει κανέναν σε υπηρεσία μέχρι να εγκριθεί. + + + Έγκριση + + + Απόρριψη + + + Ανταλλαγή με αυτή την ημέρα + + + Η ανταλλαγή ολοκληρώθηκε: την ημέρα εργάζεται ο/η {0}. + + + Ένας προϊστάμενος απέρριψε την τελευταία επιλογή για αυτή την ανταλλαγή. Μπορείτε να διαλέξετε άλλη προσφορά. + + + Επιλέξατε τον/την {0}. Η ανταλλαγή περιμένει έγκριση προϊσταμένου. + + + Επιλέξτε μια προσφορά για αποδοχή. + + + Πλήρως στελεχωμένη + + + Ομάδα + + + Δεν είστε σε αυτή την ημέρα βάρδιας. + + + Είστε σε αυτή την ημέρα βάρδιας. + + + Η δήλωση συμμετοχής σας περιμένει έγκριση προϊσταμένου. + + + Ένας προϊστάμενος δεν ενέκρινε τη δήλωσή σας ή σας αφαίρεσε από αυτή την ημέρα. + + + Χωρίς ομάδα + + + Καμία δήλωση συμμετοχής δεν περιμένει έγκριση. + + + Καμία ανταλλαγή δεν περιμένει έγκριση. + + + Δεν υπάρχει ακόμη κανείς σε αυτή την ημέρα. + + + Καμία βάρδια δεν είναι σε εξέλιξη αυτή τη στιγμή. + + + Κανείς σε υπηρεσία + + + Σε υπηρεσία στις {0}. Η σελίδα ανανεώνεται κάθε λεπτό. + + + Σε υπηρεσία τώρα + + + Σε υπηρεσία τώρα + + + Κενές θέσεις: {0} + + + Σημείωση (προαιρετικά) + + + Λοιπό προσωπικό + + + Εκκρεμεί έγκριση + + + Δηλώσεις συμμετοχής σε αναμονή έγκρισης + + + Ανταλλαγές σε αναμονή έγκρισης + + + Το άτομο προστέθηκε σε αυτή την ημέρα. + + + Άτομο + + + Το άτομο αφαιρέθηκε από αυτή την ημέρα. + + + Ανανέωση + + + Αφαίρεση από την ημέρα + + + Να αφαιρεθεί αυτό το άτομο από αυτή την ημέρα βάρδιας; Το υπόλοιπο πρόγραμμά του δεν αλλάζει. + + + Ημέρες από τις οποίες αφαιρεθήκατε + + + Δηλώσεις συμμετοχής που δεν ενέκρινε προϊστάμενος και ημέρες από τις οποίες σας αφαίρεσε προϊστάμενος. + + + Αίτημα από + + + Όταν είναι επιλεγμένο, μια δήλωση συμμετοχής ή μια ανταλλαγή δεν βάζει κανέναν σε υπηρεσία μέχρι να την εγκρίνει προϊστάμενος της ομάδας. + + + Οι δηλώσεις συμμετοχής και οι ανταλλαγές χρειάζονται έγκριση προϊσταμένου + + + Σημείωση + + + Σύνθεση + + + Επιλέξτε ημερομηνίες... + + + Επιλέξτε ένα άτομο για προσθήκη. + + + Επιλέξτε χρήστες... + + + Η βάρδια διαγράφηκε. + + + Αυτό το άτομο είναι ήδη σε αυτή την ημέρα σε αυτή την ομάδα. + + + Έχετε ήδη δηλώσει συμμετοχή σε αυτή την ημέρα βάρδιας. + + + Αυτή η ημέρα βάρδιας έχει τελειώσει. + + + Αυτή η ομάδα δεν ανήκει σε αυτή τη βάρδια. + + + Αυτή η προσφορά δεν είναι πλέον διαθέσιμη. + + + Αυτό δεν μπορεί να γίνει για αυτή την ημέρα βάρδιας. + + + Επιλέξτε τουλάχιστον ένα άτομο. + + + Δεν επιτρέπεται να το κάνετε αυτό. + + + Δεν βρέθηκε η ημέρα βάρδιας, η δήλωση συμμετοχής ή η ανταλλαγή. + + + Αυτό το άτομο δεν είναι σε αυτή την ημέρα βάρδιας. + + + Αυτό το αίτημα δεν περιμένει πλέον έγκριση. + + + Έχει ήδη ζητηθεί ανταλλαγή για αυτή την ημέρα. + + + Μπορείτε να χρησιμοποιήσετε μόνο τις ομάδες που διαχειρίζεστε, και μια βάρδια χρειάζεται τουλάχιστον μία από αυτές. + + + {0} στις {1} + + + Όλοι οι επιλεγμένοι που δεν εργάζονται ήδη εκείνη την ημέρα προστίθενται σε αυτήν. + + + Η στελέχωση αποθηκεύτηκε. Άτομα που προστέθηκαν στην ημέρα: {0}. + + + Η στελέχωση αποθηκεύτηκε. Άτομα που προστέθηκαν στην ημέρα: {0}. Δεν ήταν δυνατή η προσθήκη: {1} + + + Πρέπει να επιλέξετε μια έγκυρη ημέρα βάρδιας + + + Πρέπει να επιλέξετε μια βάρδια + + + Η δήλωση συμμετοχής εγκρίθηκε. + + + Η δήλωση συμμετοχής απορρίφθηκε. + + + Δήλωση συμμετοχής σε αυτή τη βάρδια + + + Η δήλωση συμμετοχής σας παρελήφθη και περιμένει έγκριση προϊσταμένου. Δεν είστε σε υπηρεσία εκείνη την ημέρα μέχρι να εγκριθεί. + + + Αυτή η ημέρα έχει ανταλλαχθεί ή μια ανταλλαγή περιμένει έγκριση, οπότε δεν μπορεί να αποσυρθεί. Ζητήστε από έναν προϊστάμενο να αλλάξει την ημέρα. + + + Η δήλωση συμμετοχής σας αποσύρθηκε. + + + Ανατεθειμένος + + + Δήλωση συμμετοχής + + + Προστέθηκε από προϊστάμενο + + + Ανταλλαγή (από {0}) + + + Στελέχωση + + + Ημέρα σε αντάλλαγμα: {0} + + + Αυτή η ανταλλαγή έχει ήδη καλυφθεί. + + + Η ανταλλαγή εγκρίθηκε. + + + Η ανταλλαγή ολοκληρώθηκε. + + + Απορρίψατε το αίτημα ανταλλαγής. + + + Η ανταλλαγή απορρίφθηκε. + + + Μια δήλωση συμμετοχής πρέπει να εγκριθεί πριν ανταλλαχθεί. + + + Το αίτημα ανταλλαγής σας στάλθηκε. + + + Αίτημα από {0} + + + Η απάντησή σας στάλθηκε. + + + Ολοκληρώθηκε + + + Απορρίφθηκε από προϊστάμενο + + + Ζητήθηκε + + + Αναμένει έγκριση προϊσταμένου + + + Πρέπει να ορίσετε χρήστες στους οποίους θα ζητήσετε ανταλλαγή. Η λίστα περιλαμβάνει μόνο κατάλληλους χρήστες. + + + Η επιλογή σας αποθηκεύτηκε. Η ανταλλαγή περιμένει έγκριση προϊσταμένου. + + + Σας παραχωρήθηκε από {0} + + + Άγνωστος + + + Προβολή ημέρας + + + Απόσυρση + + + Απόσυρση από αυτή την ημέρα βάρδιας; + + + Εσείς + + + Η κατάστασή σας + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.en.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.en.resx index 8716e849a..f00b53f4f 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.en.resx @@ -633,4 +633,325 @@ Role count is required + + Active now + + + Add to day + + + Add person to this day + + + Puts the person on this day only; the rest of the schedule is unchanged. + + + Approval required + + + Sign-ups and trades on this shift need supervisor approval. + + + Approvals + + + Shift Approvals + + + Sign-ups and trades waiting for approval in the groups you supervise. Nobody is on duty for a pending request until it is approved. + + + Approve + + + Deny + + + Swap for this day + + + This trade is complete: {0} is working the day. + + + A supervisor denied the last choice for this trade. You can pick another offer. + + + You picked {0}. The trade is waiting for supervisor approval. + + + Select an offer to accept. + + + Fully staffed + + + Group + + + You are not on this shift day. + + + You are on this shift day. + + + Your sign-up is waiting for supervisor approval. + + + A supervisor did not approve your sign-up or took you off this day. + + + No group + + + No sign-ups are waiting for approval. + + + No trades are waiting for approval. + + + Nobody is on this day yet. + + + No shifts are running right now. + + + Nobody on duty + + + On duty as of {0}. This page refreshes every minute. + + + On Duty Now + + + On Duty Now + + + Open slots: {0} + + + Note (optional) + + + Other personnel + + + Pending approval + + + Sign-ups waiting for approval + + + Trades waiting for approval + + + The person was added to this day. + + + Person + + + The person was taken off this day. + + + Refresh + + + Remove from this day + + + Take this person off this shift day? The rest of their schedule is unchanged. + + + Days you were taken off + + + Sign-ups a supervisor did not approve, and days a supervisor took you off. + + + Requested By + + + When checked, a sign-up or a trade does not put anyone on duty until a supervisor of the group approves it. + + + Signups and trades need supervisor approval + + + Note + + + Roster + + + Select dates... + + + Select a person to add. + + + Select users... + + + The shift was deleted. + + + That person is already on this day in that group. + + + You are already signed up for this shift day. + + + This shift day is over. + + + That group is not part of this shift. + + + That offer is no longer available. + + + That cannot be done for this shift day. + + + Select at least one person. + + + You are not allowed to do that. + + + The shift day, sign-up or trade could not be found. + + + That person is not on this shift day. + + + This request is no longer waiting for approval. + + + A trade has already been requested for this day. + + + You can only use the groups you manage, and a shift needs at least one of them. + + + {0} on {1} + + + Everyone selected who is not already working the day is added to it. + + + Staffing saved. People added to the day: {0}. + + + Staffing saved. People added to the day: {0}. Could not add: {1} + + + You must select a valid shift day + + + You must select a shift + + + The sign-up was approved. + + + The sign-up was denied. + + + Sign up for this shift + + + Your sign-up was received and is waiting for supervisor approval. You are not on duty for this day until it is approved. + + + This day has been traded, or a trade is waiting for approval, so it cannot be withdrawn. Ask a supervisor to change the day instead. + + + Your sign-up was withdrawn. + + + Assigned + + + Sign-up + + + Added by supervisor + + + Trade (from {0}) + + + Staffing + + + Swap back: {0} + + + This trade has already been filled. + + + The trade was approved. + + + The trade is complete. + + + You declined the trade request. + + + The trade was denied. + + + A sign-up has to be approved before it can be traded. + + + Your trade request was sent. + + + Requested by {0} + + + Your response was sent. + + + Complete + + + Denied by supervisor + + + Requested + + + Waiting for supervisor approval + + + You must specify users to request a trade from. Only qualified users will populate the list. + + + Your choice was saved. The trade is waiting for supervisor approval. + + + Traded to you by {0} + + + Unknown + + + View Day + + + Withdraw + + + Withdraw from this shift day? + + + You + + + Your Status + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.es.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.es.resx index 868e0a97b..aaefcd2f5 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.es.resx @@ -599,4 +599,325 @@ La cantidad de roles es obligatoria + + Activo ahora + + + Añadir al día + + + Añadir una persona a este día + + + Asigna a la persona solo a este día; el resto del calendario no cambia. + + + Requiere aprobación + + + Las inscripciones y los intercambios de este turno requieren la aprobación de un supervisor. + + + Aprobaciones + + + Aprobaciones de turnos + + + Inscripciones e intercambios pendientes de aprobación en los grupos que supervisa. Una solicitud pendiente no pone a nadie de servicio hasta que se aprueba. + + + Aprobar + + + Denegar + + + Intercambiar por este día + + + Este intercambio está completado: {0} trabaja ese día. + + + Un supervisor denegó la última elección para este intercambio. Puede elegir otra oferta. + + + Eligió a {0}. El intercambio está pendiente de la aprobación de un supervisor. + + + Seleccione una oferta para aceptar. + + + Dotación completa + + + Grupo + + + No está en este día de turno. + + + Está en este día de turno. + + + Su inscripción está pendiente de la aprobación de un supervisor. + + + Un supervisor no aprobó su inscripción o lo retiró de este día. + + + Sin grupo + + + No hay inscripciones pendientes de aprobación. + + + No hay intercambios pendientes de aprobación. + + + Todavía no hay nadie en este día. + + + No hay turnos en curso ahora mismo. + + + Nadie de servicio + + + De servicio a las {0}. Esta página se actualiza cada minuto. + + + De servicio ahora + + + De servicio ahora + + + Plazas libres: {0} + + + Nota (opcional) + + + Otro personal + + + Pendiente de aprobación + + + Inscripciones pendientes de aprobación + + + Intercambios pendientes de aprobación + + + La persona se añadió a este día. + + + Persona + + + La persona se retiró de este día. + + + Actualizar + + + Retirar de este día + + + ¿Retirar a esta persona de este día de turno? El resto de su calendario no cambia. + + + Días de los que se le retiró + + + Inscripciones que un supervisor no aprobó y días de los que un supervisor le retiró. + + + Solicitado por + + + Si se marca, una inscripción o un intercambio no pone a nadie de servicio hasta que un supervisor del grupo lo apruebe. + + + Las inscripciones y los intercambios requieren la aprobación de un supervisor + + + Nota + + + Plantilla + + + Seleccionar fechas... + + + Seleccione una persona para añadir. + + + Seleccionar usuarios... + + + El turno se eliminó. + + + Esa persona ya está en este día en ese grupo. + + + Ya está inscrito en este día de turno. + + + Este día de turno ya terminó. + + + Ese grupo no forma parte de este turno. + + + Esa oferta ya no está disponible. + + + No se puede hacer eso para este día de turno. + + + Seleccione al menos una persona. + + + No tiene permiso para hacer eso. + + + No se encontró el día de turno, la inscripción o el intercambio. + + + Esa persona no está en este día de turno. + + + Esta solicitud ya no está pendiente de aprobación. + + + Ya se solicitó un intercambio para este día. + + + Solo puede usar los grupos que administra, y un turno necesita al menos uno de ellos. + + + {0} el {1} + + + Se añade al día a todas las personas seleccionadas que aún no trabajan ese día. + + + Dotación guardada. Personas añadidas al día: {0}. + + + Dotación guardada. Personas añadidas al día: {0}. No se pudo añadir: {1} + + + Debe seleccionar un día de turno válido + + + Debe seleccionar un turno + + + La inscripción se aprobó. + + + La inscripción se denegó. + + + Inscribirse en este turno + + + Recibimos su inscripción y está pendiente de la aprobación de un supervisor. No estará de servicio ese día hasta que se apruebe. + + + Este día se intercambió o hay un intercambio pendiente de aprobación, así que no se puede retirar. Pida a un supervisor que cambie el día. + + + Su inscripción se retiró. + + + Asignado + + + Inscripción + + + Añadido por un supervisor + + + Intercambio (de {0}) + + + Dotación + + + Día a cambio: {0} + + + Este intercambio ya está cubierto. + + + El intercambio se aprobó. + + + El intercambio está completado. + + + Rechazó la solicitud de intercambio. + + + El intercambio se denegó. + + + Una inscripción debe aprobarse antes de poder intercambiarse. + + + Se envió su solicitud de intercambio. + + + Solicitado por {0} + + + Se envió su respuesta. + + + Completado + + + Denegado por un supervisor + + + Solicitado + + + Pendiente de la aprobación de un supervisor + + + Debe indicar los usuarios a quienes solicitar un intercambio. La lista solo muestra usuarios cualificados. + + + Se guardó su elección. El intercambio está pendiente de la aprobación de un supervisor. + + + Intercambiado con usted por {0} + + + Desconocido + + + Ver día + + + Retirarse + + + ¿Retirarse de este día de turno? + + + Usted + + + Su estado + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx index b7564e228..bf75dc559 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx @@ -569,4 +569,325 @@ Le nombre de rôles est obligatoire + + En cours + + + Ajouter au jour + + + Ajouter une personne à ce jour + + + Place la personne sur ce jour uniquement ; le reste du planning ne change pas. + + + Approbation requise + + + Les inscriptions et les échanges de ce quart doivent être approuvés par un superviseur. + + + Approbations + + + Approbations des quarts + + + Inscriptions et échanges en attente d'approbation dans les groupes que vous supervisez. Une demande en attente ne met personne en service tant qu'elle n'est pas approuvée. + + + Approuver + + + Refuser + + + Échanger contre ce jour + + + Cet échange est terminé : {0} travaille ce jour-là. + + + Un superviseur a refusé le dernier choix pour cet échange. Vous pouvez choisir une autre offre. + + + Vous avez choisi {0}. L'échange attend l'approbation d'un superviseur. + + + Choisissez une offre à accepter. + + + Effectif complet + + + Groupe + + + Vous n'êtes pas sur ce jour de quart. + + + Vous êtes sur ce jour de quart. + + + Votre inscription attend l'approbation d'un superviseur. + + + Un superviseur n'a pas approuvé votre inscription ou vous a retiré de ce jour. + + + Aucun groupe + + + Aucune inscription n'attend d'approbation. + + + Aucun échange n'attend d'approbation. + + + Personne n'est encore sur ce jour. + + + Aucun quart n'est en cours actuellement. + + + Personne en service + + + En service au {0}. Cette page se met à jour chaque minute. + + + En service maintenant + + + En service maintenant + + + Places libres : {0} + + + Note (facultatif) + + + Autre personnel + + + En attente d'approbation + + + Inscriptions en attente d'approbation + + + Échanges en attente d'approbation + + + La personne a été ajoutée à ce jour. + + + Personne + + + La personne a été retirée de ce jour. + + + Actualiser + + + Retirer de ce jour + + + Retirer cette personne de ce jour de quart ? Le reste de son planning ne change pas. + + + Jours dont vous avez été retiré + + + Inscriptions qu'un superviseur n'a pas approuvées et jours dont un superviseur vous a retiré. + + + Demandé par + + + Si coché, une inscription ou un échange ne met personne en service tant qu'un superviseur du groupe ne l'a pas approuvé. + + + Les inscriptions et les échanges nécessitent l'approbation d'un superviseur + + + Remarque + + + Liste d'effectif + + + Sélectionner des dates... + + + Sélectionnez une personne à ajouter. + + + Sélectionner des utilisateurs... + + + Le quart a été supprimé. + + + Cette personne est déjà sur ce jour dans ce groupe. + + + Vous êtes déjà inscrit à ce jour de quart. + + + Ce jour de quart est terminé. + + + Ce groupe ne fait pas partie de ce quart. + + + Cette offre n'est plus disponible. + + + Cette action n'est pas possible pour ce jour de quart. + + + Sélectionnez au moins une personne. + + + Vous n'êtes pas autorisé à faire cela. + + + Le jour de quart, l'inscription ou l'échange est introuvable. + + + Cette personne n'est pas sur ce jour de quart. + + + Cette demande n'attend plus d'approbation. + + + Un échange a déjà été demandé pour ce jour. + + + Vous ne pouvez utiliser que les groupes que vous gérez, et un quart doit en comporter au moins un. + + + {0} le {1} + + + Toutes les personnes sélectionnées qui ne travaillent pas encore ce jour-là y sont ajoutées. + + + Effectif enregistré. Personnes ajoutées au jour : {0}. + + + Effectif enregistré. Personnes ajoutées au jour : {0}. Impossible d'ajouter : {1} + + + Vous devez sélectionner un jour de quart valide + + + Vous devez sélectionner un quart + + + L'inscription a été approuvée. + + + L'inscription a été refusée. + + + S'inscrire à ce quart + + + Votre inscription a été reçue et attend l'approbation d'un superviseur. Vous n'êtes pas en service ce jour-là tant qu'elle n'est pas approuvée. + + + Ce jour a été échangé, ou un échange attend une approbation, il ne peut donc pas être retiré. Demandez plutôt à un superviseur de modifier le jour. + + + Votre inscription a été retirée. + + + Affecté + + + Inscription + + + Ajouté par un superviseur + + + Échange (de {0}) + + + Effectif + + + Jour rendu en échange : {0} + + + Cet échange a déjà été pourvu. + + + L'échange a été approuvé. + + + L'échange est terminé. + + + Vous avez refusé la demande d'échange. + + + L'échange a été refusé. + + + Une inscription doit être approuvée avant de pouvoir être échangée. + + + Votre demande d'échange a été envoyée. + + + Demandé par {0} + + + Votre réponse a été envoyée. + + + Terminé + + + Refusé par un superviseur + + + Demandé + + + En attente de l'approbation d'un superviseur + + + Vous devez indiquer les utilisateurs à qui demander un échange. Seuls les utilisateurs qualifiés apparaissent dans la liste. + + + Votre choix a été enregistré. L'échange attend l'approbation d'un superviseur. + + + Échangé avec vous par {0} + + + Inconnu + + + Voir le jour + + + Se retirer + + + Se retirer de ce jour de quart ? + + + Vous + + + Votre statut + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx index fffd13843..42b78150c 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx @@ -569,4 +569,325 @@ Il numero di ruoli è obbligatorio + + Attivo ora + + + Aggiungi al giorno + + + Aggiungi una persona a questo giorno + + + Assegna la persona solo a questo giorno; il resto della pianificazione resta invariato. + + + Approvazione richiesta + + + Le iscrizioni e gli scambi di questo turno richiedono l'approvazione di un supervisore. + + + Approvazioni + + + Approvazioni dei turni + + + Iscrizioni e scambi in attesa di approvazione nei gruppi che supervisioni. Una richiesta in sospeso non mette nessuno in servizio finché non viene approvata. + + + Approva + + + Nega + + + Scambia con questo giorno + + + Questo scambio è completato: {0} lavora quel giorno. + + + Un supervisore ha negato l'ultima scelta per questo scambio. Puoi scegliere un'altra offerta. + + + Hai scelto {0}. Lo scambio è in attesa dell'approvazione di un supervisore. + + + Seleziona un'offerta da accettare. + + + Personale al completo + + + Gruppo + + + Non sei in questo giorno di turno. + + + Sei in questo giorno di turno. + + + La tua iscrizione è in attesa dell'approvazione di un supervisore. + + + Un supervisore non ha approvato la tua iscrizione o ti ha tolto da questo giorno. + + + Nessun gruppo + + + Nessuna iscrizione è in attesa di approvazione. + + + Nessuno scambio è in attesa di approvazione. + + + Nessuno è ancora in questo giorno. + + + Nessun turno è in corso in questo momento. + + + Nessuno in servizio + + + In servizio alle {0}. Questa pagina si aggiorna ogni minuto. + + + In servizio ora + + + In servizio ora + + + Posti liberi: {0} + + + Nota (facoltativa) + + + Altro personale + + + In attesa di approvazione + + + Iscrizioni in attesa di approvazione + + + Scambi in attesa di approvazione + + + La persona è stata aggiunta a questo giorno. + + + Persona + + + La persona è stata tolta da questo giorno. + + + Aggiorna + + + Togli da questo giorno + + + Togliere questa persona da questo giorno di turno? Il resto della sua pianificazione resta invariato. + + + Giorni da cui sei stato tolto + + + Iscrizioni non approvate da un supervisore e giorni da cui un supervisore ti ha tolto. + + + Richiesto da + + + Se selezionato, un'iscrizione o uno scambio non mette nessuno in servizio finché un supervisore del gruppo non lo approva. + + + Iscrizioni e scambi richiedono l'approvazione di un supervisore + + + Nota + + + Organico + + + Seleziona le date... + + + Seleziona una persona da aggiungere. + + + Seleziona gli utenti... + + + Il turno è stato eliminato. + + + Quella persona è già in questo giorno in quel gruppo. + + + Sei già iscritto a questo giorno di turno. + + + Questo giorno di turno è terminato. + + + Quel gruppo non fa parte di questo turno. + + + Quell'offerta non è più disponibile. + + + Non è possibile farlo per questo giorno di turno. + + + Seleziona almeno una persona. + + + Non sei autorizzato a farlo. + + + Impossibile trovare il giorno di turno, l'iscrizione o lo scambio. + + + Quella persona non è in questo giorno di turno. + + + Questa richiesta non è più in attesa di approvazione. + + + È già stato richiesto uno scambio per questo giorno. + + + Puoi usare solo i gruppi che gestisci e un turno deve includerne almeno uno. + + + {0} il {1} + + + Tutte le persone selezionate che non lavorano già quel giorno vengono aggiunte. + + + Personale salvato. Persone aggiunte al giorno: {0}. + + + Personale salvato. Persone aggiunte al giorno: {0}. Impossibile aggiungere: {1} + + + Devi selezionare un giorno di turno valido + + + Devi selezionare un turno + + + L'iscrizione è stata approvata. + + + L'iscrizione è stata negata. + + + Iscriviti a questo turno + + + La tua iscrizione è stata ricevuta ed è in attesa dell'approvazione di un supervisore. Non sei in servizio quel giorno finché non viene approvata. + + + Questo giorno è stato scambiato, oppure uno scambio è in attesa di approvazione, quindi non può essere ritirato. Chiedi invece a un supervisore di modificare il giorno. + + + La tua iscrizione è stata ritirata. + + + Assegnato + + + Iscrizione + + + Aggiunto da un supervisore + + + Scambio (da {0}) + + + Personale + + + Giorno in cambio: {0} + + + Questo scambio è già stato completato. + + + Lo scambio è stato approvato. + + + Lo scambio è completato. + + + Hai rifiutato la richiesta di scambio. + + + Lo scambio è stato negato. + + + Un'iscrizione deve essere approvata prima di poter essere scambiata. + + + La tua richiesta di scambio è stata inviata. + + + Richiesto da {0} + + + La tua risposta è stata inviata. + + + Completato + + + Negato da un supervisore + + + Richiesto + + + In attesa dell'approvazione di un supervisore + + + Devi indicare gli utenti a cui chiedere uno scambio. L'elenco mostra solo gli utenti qualificati. + + + La tua scelta è stata salvata. Lo scambio è in attesa dell'approvazione di un supervisore. + + + Scambiato con te da {0} + + + Sconosciuto + + + Vedi giorno + + + Ritirati + + + Ritirarti da questo giorno di turno? + + + Tu + + + Il tuo stato + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx index b64185e79..fe8859a31 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx @@ -569,4 +569,325 @@ Liczba ról jest wymagana + + Trwa teraz + + + Dodaj do dnia + + + Dodaj osobę do tego dnia + + + Przydziela osobę tylko do tego dnia; reszta grafiku pozostaje bez zmian. + + + Wymaga zatwierdzenia + + + Zapisy i wymiany na tej zmianie wymagają zatwierdzenia przez przełożonego. + + + Zatwierdzenia + + + Zatwierdzenia zmian + + + Zapisy i wymiany oczekujące na zatwierdzenie w grupach, które nadzorujesz. Oczekujący wniosek nie kieruje nikogo na służbę, dopóki nie zostanie zatwierdzony. + + + Zatwierdź + + + Odrzuć + + + Zamień na ten dzień + + + Ta wymiana jest zakończona: {0} pracuje w tym dniu. + + + Przełożony odrzucił ostatni wybór dla tej wymiany. Możesz wybrać inną ofertę. + + + Wybrałeś: {0}. Wymiana czeka na zatwierdzenie przez przełożonego. + + + Wybierz ofertę do przyjęcia. + + + Pełna obsada + + + Grupa + + + Nie jesteś w grafiku tego dnia zmiany. + + + Jesteś w grafiku tego dnia zmiany. + + + Twój zapis czeka na zatwierdzenie przez przełożonego. + + + Przełożony nie zatwierdził Twojego zapisu lub usunął Cię z tego dnia. + + + Bez grupy + + + Żadne zapisy nie czekają na zatwierdzenie. + + + Żadne wymiany nie czekają na zatwierdzenie. + + + Nikt nie jest jeszcze przydzielony do tego dnia. + + + Obecnie nie trwa żadna zmiana. + + + Nikt na służbie + + + Na służbie, stan na {0}. Ta strona odświeża się co minutę. + + + Teraz na służbie + + + Teraz na służbie + + + Wolne miejsca: {0} + + + Notatka (opcjonalnie) + + + Pozostały personel + + + Oczekuje na zatwierdzenie + + + Zapisy oczekujące na zatwierdzenie + + + Wymiany oczekujące na zatwierdzenie + + + Osoba została dodana do tego dnia. + + + Osoba + + + Osoba została usunięta z tego dnia. + + + Odśwież + + + Usuń z tego dnia + + + Usunąć tę osobę z tego dnia zmiany? Reszta jej grafiku pozostaje bez zmian. + + + Dni, z których Cię usunięto + + + Zapisy, których przełożony nie zatwierdził, oraz dni, z których przełożony Cię usunął. + + + Zgłoszone przez + + + Po zaznaczeniu zapis ani wymiana nie kierują nikogo na służbę, dopóki przełożony grupy ich nie zatwierdzi. + + + Zapisy i wymiany wymagają zatwierdzenia przez przełożonego + + + Notatka + + + Skład + + + Wybierz daty... + + + Wybierz osobę do dodania. + + + Wybierz użytkowników... + + + Zmiana została usunięta. + + + Ta osoba jest już przydzielona do tego dnia w tej grupie. + + + Jesteś już zapisany na ten dzień zmiany. + + + Ten dzień zmiany już się zakończył. + + + Ta grupa nie należy do tej zmiany. + + + Ta oferta nie jest już dostępna. + + + Nie można tego zrobić dla tego dnia zmiany. + + + Wybierz co najmniej jedną osobę. + + + Nie masz uprawnień, aby to zrobić. + + + Nie znaleziono dnia zmiany, zapisu ani wymiany. + + + Ta osoba nie jest przydzielona do tego dnia zmiany. + + + Ten wniosek nie czeka już na zatwierdzenie. + + + Dla tego dnia już poproszono o wymianę. + + + Możesz używać tylko grup, którymi zarządzasz, a zmiana musi zawierać co najmniej jedną z nich. + + + {0} w dniu {1} + + + Wszystkie wybrane osoby, które jeszcze nie pracują w tym dniu, zostaną do niego dodane. + + + Obsada zapisana. Osoby dodane do dnia: {0}. + + + Obsada zapisana. Osoby dodane do dnia: {0}. Nie udało się dodać: {1} + + + Musisz wybrać prawidłowy dzień zmiany + + + Musisz wybrać zmianę + + + Zapis został zatwierdzony. + + + Zapis został odrzucony. + + + Zapisz się na tę zmianę + + + Twój zapis został przyjęty i czeka na zatwierdzenie przez przełożonego. Do czasu zatwierdzenia nie jesteś na służbie w tym dniu. + + + Ten dzień został wymieniony lub wymiana czeka na zatwierdzenie, więc nie można z niego zrezygnować. Poproś przełożonego o zmianę dnia. + + + Twój zapis został wycofany. + + + Przydzielony + + + Zapis + + + Dodany przez przełożonego + + + Wymiana (od {0}) + + + Obsada + + + Dzień w zamian: {0} + + + Ta wymiana została już obsadzona. + + + Wymiana została zatwierdzona. + + + Wymiana została zakończona. + + + Odrzuciłeś prośbę o wymianę. + + + Wymiana została odrzucona. + + + Zapis musi zostać zatwierdzony, zanim będzie można go wymienić. + + + Twoja prośba o wymianę została wysłana. + + + Zgłoszone przez: {0} + + + Twoja odpowiedź została wysłana. + + + Zakończona + + + Odrzucona przez przełożonego + + + Zgłoszona + + + Czeka na zatwierdzenie przez przełożonego + + + Musisz wskazać użytkowników, których chcesz poprosić o wymianę. Lista zawiera tylko uprawnionych użytkowników. + + + Twój wybór został zapisany. Wymiana czeka na zatwierdzenie przez przełożonego. + + + Przekazany Tobie przez: {0} + + + Nieznany + + + Pokaż dzień + + + Zrezygnuj + + + Zrezygnować z tego dnia zmiany? + + + Ty + + + Twój status + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx index 1ab300680..95a766f01 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx @@ -569,4 +569,325 @@ Antal roller krävs + + Pågår nu + + + Lägg till på dagen + + + Lägg till en person på den här dagen + + + Sätter personen på endast den här dagen; resten av schemat är oförändrat. + + + Kräver godkännande + + + Anmälningar och byten för det här skiftet måste godkännas av en arbetsledare. + + + Godkännanden + + + Skiftgodkännanden + + + Anmälningar och byten som väntar på godkännande i de grupper du leder. En väntande begäran sätter ingen i tjänst förrän den har godkänts. + + + Godkänn + + + Neka + + + Byt mot den här dagen + + + Bytet är slutfört: {0} arbetar den dagen. + + + En arbetsledare nekade det senaste valet för bytet. Du kan välja ett annat erbjudande. + + + Du valde {0}. Bytet väntar på godkännande av en arbetsledare. + + + Välj ett erbjudande att acceptera. + + + Fullt bemannat + + + Grupp + + + Du är inte med på den här skiftdagen. + + + Du är med på den här skiftdagen. + + + Din anmälan väntar på godkännande av en arbetsledare. + + + En arbetsledare godkände inte din anmälan eller tog bort dig från den här dagen. + + + Ingen grupp + + + Inga anmälningar väntar på godkännande. + + + Inga byten väntar på godkännande. + + + Ingen är med på den här dagen än. + + + Inga skift pågår just nu. + + + Ingen i tjänst + + + I tjänst per {0}. Sidan uppdateras varje minut. + + + I tjänst nu + + + I tjänst nu + + + Lediga platser: {0} + + + Notering (valfritt) + + + Övrig personal + + + Väntar på godkännande + + + Anmälningar som väntar på godkännande + + + Byten som väntar på godkännande + + + Personen lades till på den här dagen. + + + Person + + + Personen togs bort från den här dagen. + + + Uppdatera + + + Ta bort från dagen + + + Ta bort personen från den här skiftdagen? Resten av personens schema är oförändrat. + + + Dagar du togs bort från + + + Anmälningar som en arbetsledare inte godkände och dagar som en arbetsledare tog bort dig från. + + + Begärt av + + + När rutan är markerad sätter en anmälan eller ett byte ingen i tjänst förrän en arbetsledare för gruppen har godkänt det. + + + Anmälningar och byten kräver godkännande av en arbetsledare + + + Notering + + + Bemanning + + + Välj datum... + + + Välj en person att lägga till. + + + Välj användare... + + + Skiftet togs bort. + + + Den personen är redan med den här dagen i den gruppen. + + + Du är redan anmäld till den här skiftdagen. + + + Den här skiftdagen är över. + + + Den gruppen ingår inte i det här skiftet. + + + Det erbjudandet är inte längre tillgängligt. + + + Det går inte att göra för den här skiftdagen. + + + Välj minst en person. + + + Du har inte behörighet att göra det. + + + Skiftdagen, anmälan eller bytet hittades inte. + + + Den personen är inte med på den här skiftdagen. + + + Den här begäran väntar inte längre på godkännande. + + + Ett byte har redan begärts för den här dagen. + + + Du kan bara använda grupper som du hanterar, och ett skift behöver minst en av dem. + + + {0} den {1} + + + Alla valda som inte redan arbetar den dagen läggs till. + + + Bemanningen sparades. Personer som lades till på dagen: {0}. + + + Bemanningen sparades. Personer som lades till på dagen: {0}. Kunde inte läggas till: {1} + + + Du måste välja en giltig skiftdag + + + Du måste välja ett skift + + + Anmälan godkändes. + + + Anmälan nekades. + + + Anmäl dig till det här skiftet + + + Din anmälan har tagits emot och väntar på godkännande av en arbetsledare. Du är inte i tjänst den dagen förrän den har godkänts. + + + Den här dagen har bytts bort, eller så väntar ett byte på godkännande, så den kan inte dras tillbaka. Be en arbetsledare att ändra dagen i stället. + + + Din anmälan drogs tillbaka. + + + Tilldelad + + + Anmälan + + + Tillagd av arbetsledare + + + Byte (från {0}) + + + Bemanning + + + Dag i utbyte: {0} + + + Det här bytet är redan tillsatt. + + + Bytet godkändes. + + + Bytet är slutfört. + + + Du avböjde bytesbegäran. + + + Bytet nekades. + + + En anmälan måste godkännas innan den kan bytas. + + + Din bytesbegäran har skickats. + + + Begärt av {0} + + + Ditt svar har skickats. + + + Slutförd + + + Nekad av arbetsledare + + + Begärd + + + Väntar på godkännande av arbetsledare + + + Du måste ange användare att begära ett byte från. Listan visar bara behöriga användare. + + + Ditt val har sparats. Bytet väntar på godkännande av en arbetsledare. + + + Bytt till dig av {0} + + + Okänd + + + Visa dag + + + Dra tillbaka + + + Dra dig ur den här skiftdagen? + + + Du + + + Din status + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx index 120745f96..f43451251 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx @@ -569,4 +569,325 @@ Кількість ролей є обов'язковою + + Триває зараз + + + Додати до дня + + + Додати особу до цього дня + + + Призначає особу лише на цей день; решта розкладу не змінюється. + + + Потрібне схвалення + + + Реєстрації та обміни на цій зміні потребують схвалення керівника. + + + Схвалення + + + Схвалення змін + + + Реєстрації та обміни, що очікують схвалення, у групах, якими ви керуєте. Запит, що очікує, нікого не ставить на чергування, доки його не схвалено. + + + Схвалити + + + Відхилити + + + Обміняти на цей день + + + Цей обмін завершено: у цей день працює {0}. + + + Керівник відхилив останній вибір для цього обміну. Ви можете вибрати іншу пропозицію. + + + Ви вибрали {0}. Обмін очікує схвалення керівника. + + + Виберіть пропозицію, яку хочете прийняти. + + + Повністю укомплектовано + + + Група + + + Вас немає в цьому дні зміни. + + + Ви в цьому дні зміни. + + + Ваша реєстрація очікує схвалення керівника. + + + Керівник не схвалив вашу реєстрацію або зняв вас із цього дня. + + + Без групи + + + Немає реєстрацій, що очікують схвалення. + + + Немає обмінів, що очікують схвалення. + + + На цей день ще нікого немає. + + + Зараз немає жодної зміни. + + + Ніхто не чергує + + + На чергуванні станом на {0}. Сторінка оновлюється щохвилини. + + + Зараз на чергуванні + + + Зараз на чергуванні + + + Вільні місця: {0} + + + Примітка (необов'язково) + + + Інший персонал + + + Очікує схвалення + + + Реєстрації, що очікують схвалення + + + Обміни, що очікують схвалення + + + Особу додано до цього дня. + + + Особа + + + Особу знято з цього дня. + + + Оновити + + + Зняти з цього дня + + + Зняти цю особу з цього дня зміни? Решта її розкладу не зміниться. + + + Дні, з яких вас знято + + + Реєстрації, які керівник не схвалив, і дні, з яких керівник вас зняв. + + + Запитав + + + Якщо позначено, реєстрація чи обмін нікого не ставить на чергування, доки керівник групи їх не схвалить. + + + Реєстрації та обміни потребують схвалення керівника + + + Примітка + + + Склад + + + Виберіть дати... + + + Виберіть особу, яку потрібно додати. + + + Виберіть користувачів... + + + Зміну видалено. + + + Ця особа вже є в цьому дні в цій групі. + + + Ви вже зареєстровані на цей день зміни. + + + Цей день зміни вже минув. + + + Ця група не входить до цієї зміни. + + + Ця пропозиція вже недоступна. + + + Це неможливо зробити для цього дня зміни. + + + Виберіть принаймні одну особу. + + + Вам не дозволено це робити. + + + Не вдалося знайти день зміни, реєстрацію або обмін. + + + Цієї особи немає в цьому дні зміни. + + + Цей запит більше не очікує схвалення. + + + Для цього дня вже запитано обмін. + + + Ви можете використовувати лише групи, якими керуєте, і зміна повинна містити принаймні одну з них. + + + {0}, {1} + + + Усіх вибраних, хто ще не працює в цей день, буде додано до нього. + + + Укомплектування збережено. Осіб додано до дня: {0}. + + + Укомплектування збережено. Осіб додано до дня: {0}. Не вдалося додати: {1} + + + Потрібно вибрати дійсний день зміни + + + Потрібно вибрати зміну + + + Реєстрацію схвалено. + + + Реєстрацію відхилено. + + + Зареєструватися на цю зміну + + + Вашу реєстрацію отримано, вона очікує схвалення керівника. Доки її не схвалено, ви не на чергуванні в цей день. + + + Цей день обміняно або обмін очікує схвалення, тому від нього не можна відмовитися. Натомість попросіть керівника змінити день. + + + Вашу реєстрацію скасовано. + + + Призначено + + + Реєстрація + + + Додано керівником + + + Обмін (від {0}) + + + Укомплектування + + + День навзаєм: {0} + + + Цей обмін уже виконано. + + + Обмін схвалено. + + + Обмін завершено. + + + Ви відхилили запит на обмін. + + + Обмін відхилено. + + + Реєстрацію потрібно схвалити, перш ніж її можна буде обміняти. + + + Ваш запит на обмін надіслано. + + + Запитав(ла): {0} + + + Вашу відповідь надіслано. + + + Завершено + + + Відхилено керівником + + + Запитано + + + Очікує схвалення керівника + + + Потрібно вказати користувачів, у яких запитати обмін. У списку показано лише кваліфікованих користувачів. + + + Ваш вибір збережено. Обмін очікує схвалення керівника. + + + Передано вам від {0} + + + Невідомо + + + Переглянути день + + + Відмовитися + + + Відмовитися від цього дня зміни? + + + Ви + + + Ваш статус + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.ar.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.ar.resx index 3a13c4602..b568fe276 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.ar.resx @@ -110,4 +110,19 @@ تقرير حادث NERIS + + حساسية الحقل في سير العمل المحمي + + + تحتاج الحقول المقيّدة وحقول 42 CFR Part 2 إلى إقرار إضافي قبل أن يتمكن سير عمل محمي من السماح بها. يؤدي تغيير هذه القيمة إلى إعادة كل إذن يستخدم الحقل إلى الاعتماد. + + + لا شيء + + + مقيّد + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.de.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.de.resx index 50f539c2a..e59c8ff38 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.de.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.de.resx @@ -113,4 +113,19 @@ NERIS-Ereignisbericht + + Sensibilität für geschützte Workflows + + + Eingeschränkte Felder und Felder nach 42 CFR Part 2 benötigen eine zusätzliche Erklärung, bevor ein geschützter Workflow sie freigeben kann. Eine Änderung schickt jede Freigabe, die das Feld verwendet, zurück zur Genehmigung. + + + Keine + + + Eingeschränkt + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.el.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.el.resx index 4fd7410fe..81854531b 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.el.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.el.resx @@ -321,5 +321,20 @@ Αναφορά συμβάντος NERIS + + Ευαισθησία για προστατευμένες ροές εργασιών + + + Τα περιορισμένα πεδία και τα πεδία 42 CFR Part 2 χρειάζονται πρόσθετη βεβαίωση πριν μια προστατευμένη ροή εργασιών μπορέσει να τα κοινοποιήσει. Η αλλαγή αυτής της τιμής επιστρέφει για έγκριση κάθε έγκριση που χρησιμοποιεί το πεδίο. + + + Καμία + + + Περιορισμένο + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.en.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.en.resx index e9746b44f..504a5090f 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.en.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.en.resx @@ -321,5 +321,20 @@ NERIS incident report + + Protected Workflows release sensitivity + + + Restricted and 42 CFR Part 2 fields need an extra attestation before a Protected Workflow can release them. Changing this sends every release that uses the field back for approval. + + + None + + + Restricted + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.es.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.es.resx index 0612ff93b..ae5daa118 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.es.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.es.resx @@ -113,4 +113,19 @@ Informe de incidente NERIS + + Sensibilidad para flujos de trabajo protegidos + + + Los campos restringidos y los de 42 CFR Part 2 necesitan una declaración adicional antes de que un flujo de trabajo protegido pueda autorizarlos. Cambiar este valor devuelve a aprobación toda autorización que use el campo. + + + Ninguna + + + Restringido + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.fr.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.fr.resx index 9b3a89d70..935db8d6d 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.fr.resx @@ -113,4 +113,19 @@ Rapport d’incident NERIS + + Sensibilité pour les flux de travail protégés + + + Les champs restreints et 42 CFR Part 2 nécessitent une attestation supplémentaire avant qu'un flux de travail protégé puisse les autoriser. Modifier cette valeur renvoie en approbation toute autorisation qui utilise le champ. + + + Aucune + + + Restreint + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.it.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.it.resx index efeb4f923..1d1116ded 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.it.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.it.resx @@ -113,4 +113,19 @@ Rapporto di incidente NERIS + + Sensibilità per i flussi di lavoro protetti + + + I campi riservati e quelli 42 CFR Part 2 richiedono una dichiarazione aggiuntiva prima che un flusso di lavoro protetto possa autorizzarli. Modificare questo valore rimanda in approvazione ogni autorizzazione che usa il campo. + + + Nessuna + + + Riservato + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.pl.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.pl.resx index 7e614e9f9..496ff89cf 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.pl.resx @@ -113,4 +113,19 @@ Raport o zdarzeniu NERIS + + Wrażliwość dla chronionych przepływów pracy + + + Pola zastrzeżone i pola objęte 42 CFR Part 2 wymagają dodatkowego oświadczenia, zanim chroniony przepływ pracy będzie mógł je udostępnić. Zmiana tej wartości odsyła do ponownego zatwierdzenia każde zezwolenie, które używa tego pola. + + + Brak + + + Zastrzeżone + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.resx index e9746b44f..504a5090f 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.resx @@ -321,5 +321,20 @@ NERIS incident report + + Protected Workflows release sensitivity + + + Restricted and 42 CFR Part 2 fields need an extra attestation before a Protected Workflow can release them. Changing this sends every release that uses the field back for approval. + + + None + + + Restricted + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.sv.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.sv.resx index 17bc34925..22eb86173 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.sv.resx @@ -113,4 +113,19 @@ NERIS-incidentrapport + + Känslighet för skyddade arbetsflöden + + + Begränsade fält och fält enligt 42 CFR Part 2 kräver ett extra intyg innan ett skyddat arbetsflöde får frisläppa dem. En ändring här skickar tillbaka varje frisläppning som använder fältet för godkännande. + + + Ingen + + + Begränsat + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.uk.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.uk.resx index 3d1b54556..eb131bf30 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.uk.resx @@ -113,4 +113,19 @@ Звіт про інцидент NERIS + + Чутливість для захищених робочих процесів + + + Обмежені поля та поля згідно з 42 CFR Part 2 потребують додаткового підтвердження, перш ніж захищений робочий процес зможе їх розкрити. Зміна цього значення повертає на затвердження кожен дозвіл, що використовує поле. + + + Немає + + + Обмежене + + + 42 CFR Part 2 + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx index 4b4e51852..5fd1c01d6 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx @@ -581,11 +581,8 @@ المخرجات المعروضة - - جميع أنواع أحداث التشغيل المتاحة لها بالفعل سير عمل مُكوَّن. احذف سير عمل موجوداً لتحرير نوع حدثه. - - يمكن لكل نوع حدث أن يكون له سير عمل واحد فقط. يتم عرض أنواع الأحداث غير المستخدمة فقط. + الحدث الذي يبدأ سير العمل هذا. يمكن لعدة مسارات عمل استخدام الحدث نفسه؛ ويعمل كل منها بشكل مستقل. تفاصيل @@ -617,4 +614,112 @@ تصدير تقرير السجلات لا شيء (إرسال القالب المُعالَج فقط) تُرفق خطوات البريد الإلكتروني التصدير؛ وترفع خطوات رفع الملفات التصدير باسم ملفه بدلًا من النص المُعالَج. تُصمَّم التصديرات ضمن السجلات > تصدير التقارير. + + بيانات اعتماد عميل OAuth2 + + + عنوان URL للرمز المميز + + + نقطة نهاية الرمز المميز عبر HTTPS، على سبيل المثال https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + معرّف العميل + + + سر العميل + + + النطاق + + + بالنسبة إلى Microsoft Dataverse، استخدم https://<org>.crm.dynamics.com/.default. + + + الجمهور (اختياري) + + + مصادقة العميل + + + سرّ العميل + + + JWT بمفتاح خاص (SMART Backend Services) + + + خوارزمية التوقيع + + + تُنشئ Resgrid زوج المفاتيح وتحتفظ به؛ يُشفَّر المفتاح الخاص ولا يُعرض أبدًا. سجّل عنوان JWKS (أو ارفع المفتاح العام) لدى خادم الرموز. + + + عنوان JWKS + + + نسخ + + + المفتاح الحالي {0}، أُنشئ في {1}. المفاتيح المنشورة: {2}. + + + تنزيل JWK العام + + + تدوير المفتاح + + + هل تريد إنشاء مفتاح توقيع جديد؟ يبقى المفتاح الحالي منشورًا خلال فترة التداخل ليتمكن خادم الرموز من الانتقال. + + + تم إنشاء مفتاح توقيع جديد. يبقى المفتاح السابق منشورًا خلال فترة التداخل. + + + يُنشأ زوج المفاتيح عند الحفظ. بعد ذلك يظهر هنا عنوان JWKS وتنزيل المفتاح العام. + + + البدء من قالب + + + تُنشئ القوالب سير عمل معطّلًا بوجهات افتراضية. اضبط العنوان وبيانات الاعتماد لكل خطوة ثم فعّله. + + + استخدام القالب + + + سير العمل المحمي + + + تم إنشاء سير العمل من القالب. إنه معطّل: اضبط الوجهة وبيانات الاعتماد لكل خطوة ثم فعّله. + + + تم إنشاء سير العمل من القالب مع مسودة إذن محمي. اضبط عنوان الوجهة وبيانات الاعتماد للخطوة، واختر الحقول المسموح بها، ثم اطلب الاعتماد. لن يُرسل أي شيء قبل اعتماد الإذن. + + + تم بلوغ الحد الأقصى لسير العمل في خطتك. يُرجى الترقية لإضافة المزيد. + + + Webhook: بلاغ جديد (JSON) + + + يرسل رقم البلاغ واسمه وأولويته وعنوانه بصيغة JSON عند إضافته، مع مفتاح عدم التكرار. + + + بريد إلكتروني: إغلاق بلاغ + + + يرسل ملخصًا قصيرًا بالبريد الإلكتروني عند إغلاق بلاغ. + + + FHIR R4 Encounter وObservations + + + عند إغلاق بلاغ، يرسل حزمة معاملات FHIR R4 (Bundle) إلى السجل الصحي الإلكتروني: Encounter (الفئة FLD، منتهٍ، من الإرسال حتى الإغلاق) للمريض في subject_ids.ehr_client_id، وObservation لكل حقل مخصص مسموح به. يستخدم الإنشاء المشروط ويحفظ معرّف Encounter الجديد باسم ehr_encounter_id. + + + ملاحظة زيارة HL7 v2.5.1 MDM^T02 + + + عند إغلاق بلاغ، يرسل مستند MDM^T02 "Crisis Field Response" إلى محرك تكامل عبر HTTPS: PID-3 هو معرّف العميل في السجل الصحي، وMSH-10 مفتاح عدم التكرار، وOBX لكل حقل مخصص مسموح به. ينجح فقط عند تلقي إقرار AA. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx index 6fe2ba152..93ae7d271 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx @@ -582,11 +582,8 @@ Gerenderte Ausgabe - - Alle verfügbaren Trigger-Ereignistypen haben bereits einen konfigurierten Workflow. Löschen Sie einen vorhandenen Workflow, um seinen Ereignistyp freizugeben. - - Jeder Ereignistyp kann nur einen Workflow haben. Es werden nur ungenutzte Ereignistypen angezeigt. + Das Ereignis, das diesen Workflow startet. Mehrere Workflows können dasselbe Ereignis verwenden; jeder läuft für sich. Detail @@ -618,4 +615,112 @@ Berichtsexport (Datensätze) Keiner (nur die gerenderte Vorlage senden) E-Mail-Schritte hängen den Export an; Datei-Upload-Schritte laden ihn unter seinem Dateinamen statt des gerenderten Textes hoch. Exporte werden unter Datensätze > Berichtsexporte angelegt. + + OAuth2-Clientanmeldeinformationen + + + Token-URL + + + Der HTTPS-Token-Endpunkt, zum Beispiel https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + Client-ID + + + Geheimer Clientschlüssel + + + Bereich + + + Verwenden Sie für Microsoft Dataverse https://<org>.crm.dynamics.com/.default. + + + Zielgruppe (optional) + + + Client-Authentifizierung + + + Client-Geheimnis + + + Private-Key-JWT (SMART Backend Services) + + + Signaturalgorithmus + + + Resgrid erzeugt und verwahrt das Schlüsselpaar; der private Schlüssel wird verschlüsselt und nie angezeigt. Registrieren Sie die JWKS-URL (oder laden Sie den öffentlichen Schlüssel hoch) beim Token-Server. + + + JWKS-URL + + + Kopieren + + + Aktueller Schlüssel {0}, erstellt am {1}. Veröffentlichte Schlüssel: {2}. + + + Öffentlichen JWK herunterladen + + + Schlüssel rotieren + + + Einen neuen Signaturschlüssel erzeugen? Der aktuelle Schlüssel bleibt während der Übergangszeit veröffentlicht, damit der Token-Server umstellen kann. + + + Ein neuer Signaturschlüssel wurde erzeugt. Der bisherige Schlüssel bleibt während der Übergangszeit veröffentlicht. + + + Das Schlüsselpaar wird beim Speichern erzeugt. Danach erscheinen hier die JWKS-URL und der Download des öffentlichen Schlüssels. + + + Mit einer Vorlage beginnen + + + Vorlagen erstellen einen deaktivierten Workflow mit Platzhalterzielen. Legen Sie für jeden Schritt URL und Zugangsdaten fest und aktivieren Sie ihn dann. + + + Vorlage verwenden + + + Geschützte Workflows + + + Workflow aus der Vorlage erstellt. Er ist deaktiviert: Legen Sie für jeden Schritt Ziel und Zugangsdaten fest und aktivieren Sie ihn dann. + + + Workflow aus der Vorlage mit einem Entwurf einer geschützten Freigabe erstellt. Legen Sie Ziel-URL und Zugangsdaten des Schritts fest, wählen Sie die freigegebenen Felder und beantragen Sie die Genehmigung. Es wird nichts gesendet, bevor die Freigabe genehmigt ist. + + + Das Workflow-Limit Ihres Tarifs ist erreicht. Bitte führen Sie ein Upgrade durch, um weitere Workflows hinzuzufügen. + + + Webhook: neuer Einsatz (JSON) + + + Sendet Einsatznummer, Name, Priorität und Adresse als JSON, wenn ein Einsatz angelegt wird, mit einem Idempotenzschlüssel. + + + E-Mail: Einsatz abgeschlossen + + + Sendet eine kurze Zusammenfassung per E-Mail, wenn ein Einsatz abgeschlossen wird. + + + FHIR R4 Encounter und Observations + + + Beim Abschluss eines Einsatzes wird ein FHIR-R4-Transaktions-Bundle an die Patientenakte gesendet: ein Encounter (Klasse FLD, abgeschlossen, von der Alarmierung bis zum Abschluss) für den Patienten aus subject_ids.ehr_client_id und eine Observation je freigegebenem benutzerdefiniertem Feld. Verwendet bedingtes Anlegen und speichert die neue Encounter-ID als ehr_encounter_id. + + + HL7 v2.5.1 MDM^T02 Kontaktnotiz + + + Beim Abschluss eines Einsatzes wird ein MDM^T02-Dokument "Crisis Field Response" per HTTPS an eine Schnittstellen-Engine gesendet: PID-3 ist die Klienten-ID der Patientenakte, MSH-10 der Idempotenzschlüssel, ein OBX je freigegebenem benutzerdefiniertem Feld. Erfolgreich nur bei einer AA-Bestätigung. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx index 386bceaf0..c27f2bd29 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx @@ -605,11 +605,8 @@ Αποδοθέν Αποτέλεσμα - - Όλοι οι διαθέσιμοι τύποι συμβάντων ενεργοποίησης έχουν ήδη διαμορφωμένη ροή εργασίας. Διαγράψτε μια υπάρχουσα ροή εργασίας για να ελευθερώσετε τον τύπο συμβάντος της. - - Κάθε τύπος συμβάντος μπορεί να έχει μόνο μία ροή εργασίας. Εμφανίζονται μόνο οι μη χρησιμοποιούμενοι τύποι συμβάντων. + Το συμβάν που ξεκινά αυτή τη ροή εργασιών. Πολλές ροές εργασιών μπορούν να χρησιμοποιούν το ίδιο συμβάν· καθεμία εκτελείται ξεχωριστά. Λεπτομέρεια @@ -641,5 +638,113 @@ Εξαγωγή αναφοράς εγγραφών Καμία (αποστολή μόνο του αποδοθέντος προτύπου) Τα βήματα e-mail επισυνάπτουν την εξαγωγή· τα βήματα μεταφόρτωσης αρχείου τη μεταφορτώνουν με το όνομα αρχείου της αντί για το αποδοθέν κείμενο. Οι εξαγωγές σχεδιάζονται στο Εγγραφές > Εξαγωγές αναφορών. + + Διαπιστευτήρια πελάτη OAuth2 + + + URL διακριτικού + + + Το τελικό σημείο HTTPS του διακριτικού, για παράδειγμα https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + Αναγνωριστικό πελάτη + + + Μυστικό πελάτη + + + Εύρος + + + Για το Microsoft Dataverse χρησιμοποιήστε https://<org>.crm.dynamics.com/.default. + + + Αποδέκτης (προαιρετικό) + + + Πιστοποίηση πελάτη + + + Μυστικό πελάτη + + + JWT ιδιωτικού κλειδιού (SMART Backend Services) + + + Αλγόριθμος υπογραφής + + + Το Resgrid δημιουργεί και διατηρεί το ζεύγος κλειδιών· το ιδιωτικό κλειδί κρυπτογραφείται και δεν εμφανίζεται ποτέ. Καταχωρίστε το URL JWKS (ή ανεβάστε το δημόσιο κλειδί) στον διακομιστή διακριτικών. + + + URL JWKS + + + Αντιγραφή + + + Τρέχον κλειδί {0}, δημιουργήθηκε στις {1}. Δημοσιευμένα κλειδιά: {2}. + + + Λήψη δημόσιου JWK + + + Εναλλαγή κλειδιού + + + Να δημιουργηθεί νέο κλειδί υπογραφής; Το τρέχον κλειδί παραμένει δημοσιευμένο κατά την περίοδο επικάλυψης, ώστε ο διακομιστής διακριτικών να μεταβεί. + + + Δημιουργήθηκε νέο κλειδί υπογραφής. Το προηγούμενο κλειδί παραμένει δημοσιευμένο κατά την περίοδο επικάλυψης. + + + Το ζεύγος κλειδιών δημιουργείται κατά την αποθήκευση. Στη συνέχεια εμφανίζονται εδώ το URL JWKS και η λήψη του δημόσιου κλειδιού. + + + Ξεκινήστε από πρότυπο + + + Τα πρότυπα δημιουργούν μια απενεργοποιημένη ροή εργασιών με ενδεικτικούς προορισμούς. Ορίστε το URL και τα διαπιστευτήρια σε κάθε βήμα και έπειτα ενεργοποιήστε τη. + + + Χρήση προτύπου + + + Προστατευμένες ροές εργασιών + + + Η ροή εργασιών δημιουργήθηκε από το πρότυπο. Είναι απενεργοποιημένη: ορίστε τον προορισμό και τα διαπιστευτήρια σε κάθε βήμα και έπειτα ενεργοποιήστε τη. + + + Η ροή εργασιών δημιουργήθηκε από το πρότυπο μαζί με πρόχειρη προστατευμένη έγκριση. Ορίστε το URL προορισμού και τα διαπιστευτήρια του βήματος, επιλέξτε τα πεδία προς κοινοποίηση και ζητήστε έγκριση. Δεν στέλνεται τίποτα πριν εγκριθεί. + + + Συμπληρώθηκε το όριο ροών εργασιών του προγράμματός σας. Αναβαθμίστε για να προσθέσετε περισσότερες. + + + Webhook: νέα κλήση (JSON) + + + Στέλνει σε JSON τον αριθμό, το όνομα, την προτεραιότητα και τη διεύθυνση της κλήσης όταν προστίθεται, με κλειδί idempotency. + + + Email: κλείσιμο κλήσης + + + Στέλνει μια σύντομη περίληψη με email όταν κλείνει μια κλήση. + + + FHIR R4 Encounter και Observations + + + Όταν κλείνει μια κλήση, στέλνει στον ηλεκτρονικό φάκελο υγείας ένα Bundle συναλλαγής FHIR R4: ένα Encounter (κατηγορία FLD, ολοκληρωμένο, από την αποστολή έως το κλείσιμο) για τον ασθενή του subject_ids.ehr_client_id και μία Observation για κάθε εγκεκριμένο προσαρμοσμένο πεδίο. Χρησιμοποιεί υπό όρους δημιουργία και αποθηκεύει το αναγνωριστικό του νέου Encounter ως ehr_encounter_id. + + + Σημείωμα επαφής HL7 v2.5.1 MDM^T02 + + + Όταν κλείνει μια κλήση, στέλνει ένα έγγραφο MDM^T02 «Crisis Field Response» σε μηχανή διασύνδεσης μέσω HTTPS: το PID-3 είναι το αναγνωριστικό πελάτη του φακέλου υγείας, το MSH-10 το κλειδί idempotency, ένα OBX για κάθε εγκεκριμένο προσαρμοσμένο πεδίο. Επιτυγχάνει μόνο με επιβεβαίωση AA. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx index f50edfd4a..dcb36ab68 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx @@ -605,11 +605,8 @@ Rendered Output - - All available trigger event types already have a workflow configured. Delete an existing workflow to free up its event type. - - Each event type can only have one workflow. Only unused event types are shown. + The event that starts this workflow. Several workflows can use the same event; each one runs on its own. Detail @@ -641,5 +638,113 @@ Records report export None (send the rendered template only) Email steps attach the export; file-upload steps upload it under its file name instead of the rendered text. Exports are designed under Records > Report exports. + + OAuth2 client credentials + + + Token URL + + + The HTTPS token endpoint, for example https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + Client ID + + + Client secret + + + Scope + + + For Microsoft Dataverse use https://<org>.crm.dynamics.com/.default. + + + Audience (optional) + + + Client authentication + + + Client secret + + + Private key JWT (SMART Backend Services) + + + Signing algorithm + + + Resgrid generates and keeps the key pair; the private key is encrypted and never shown. Register the JWKS URL (or upload the public key) with the token server. + + + JWKS URL + + + Copy + + + Current key {0}, created {1}. Keys published: {2}. + + + Download public JWK + + + Rotate key + + + Generate a new signing key? The current key stays published for the overlap period so the token server can move over. + + + A new signing key was generated. The previous key stays published for the overlap period. + + + The key pair is generated when you save. The JWKS URL and the public key download then appear here. + + + Start from a template + + + Templates create a disabled workflow with placeholder destinations. Set the URL and credential on each step, then enable it. + + + Use template + + + Protected Workflows + + + Workflow created from the template. It is disabled: set the destination and credential on each step, then enable it. + + + Workflow created from the template with a draft protected release. Set the destination URL and credential on the step, choose the released fields, then request approval. It never sends until the release is approved. + + + Workflow limit reached for your plan. Please upgrade to add more workflows. + + + Webhook: new call (JSON) + + + Posts the call number, name, priority and address as JSON when a call is added, with an idempotency key. + + + Email: call closed + + + Emails a short summary when a call is closed. + + + FHIR R4 Encounter and Observations + + + When a call closes, sends a FHIR R4 transaction Bundle to the EHR: an Encounter (class FLD, finished, dispatch to close) for the patient in subject_ids.ehr_client_id and one Observation per released custom field. Uses conditional create and saves the new Encounter id as ehr_encounter_id. + + + HL7 v2.5.1 MDM^T02 encounter note + + + When a call closes, sends an MDM^T02 "Crisis Field Response" document to an interface engine over HTTPS: PID-3 is the EHR client id, MSH-10 the idempotency key, one OBX per released custom field. Succeeds only on an AA acknowledgement. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx index 62cb745d3..f2ec38c9c 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx @@ -603,11 +603,8 @@ Salida renderizada - - Todos los tipos de eventos de activación disponibles ya tienen un flujo de trabajo configurado. Elimine un flujo de trabajo existente para liberar su tipo de evento. - - Cada tipo de evento solo puede tener un flujo de trabajo. Solo se muestran los tipos de eventos no utilizados. + El evento que inicia este flujo de trabajo. Varios flujos de trabajo pueden usar el mismo evento; cada uno se ejecuta por separado. Detalle @@ -639,5 +636,113 @@ Exportación de informe de registros Ninguna (enviar solo la plantilla renderizada) Los pasos de correo adjuntan la exportación; los pasos de carga de archivos la suben con su nombre de archivo en lugar del texto renderizado. Las exportaciones se diseñan en Registros > Exportaciones de informes. + + Credenciales de cliente OAuth2 + + + URL del token + + + El punto de conexión HTTPS del token, por ejemplo https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + ID de cliente + + + Secreto de cliente + + + Ámbito + + + Para Microsoft Dataverse, use https://<org>.crm.dynamics.com/.default. + + + Audiencia (opcional) + + + Autenticación del cliente + + + Secreto de cliente + + + JWT de clave privada (SMART Backend Services) + + + Algoritmo de firma + + + Resgrid genera y conserva el par de claves; la clave privada se cifra y nunca se muestra. Registre la URL de JWKS (o cargue la clave pública) en el servidor de tokens. + + + URL de JWKS + + + Copiar + + + Clave actual {0}, creada el {1}. Claves publicadas: {2}. + + + Descargar JWK público + + + Rotar clave + + + ¿Generar una nueva clave de firma? La clave actual sigue publicada durante el periodo de solapamiento para que el servidor de tokens pueda cambiar. + + + Se generó una nueva clave de firma. La clave anterior sigue publicada durante el periodo de solapamiento. + + + El par de claves se genera al guardar. Después aparecerán aquí la URL de JWKS y la descarga de la clave pública. + + + Empezar desde una plantilla + + + Las plantillas crean un flujo de trabajo desactivado con destinos de ejemplo. Configure la URL y la credencial de cada paso y luego actívelo. + + + Usar plantilla + + + Flujos de trabajo protegidos + + + Flujo de trabajo creado a partir de la plantilla. Está desactivado: configure el destino y la credencial de cada paso y luego actívelo. + + + Flujo de trabajo creado a partir de la plantilla con una autorización protegida en borrador. Configure la URL de destino y la credencial del paso, elija los campos autorizados y solicite la aprobación. Nunca envía nada hasta que se apruebe la autorización. + + + Se alcanzó el límite de flujos de trabajo de su plan. Actualícelo para añadir más flujos de trabajo. + + + Webhook: nueva llamada (JSON) + + + Envía como JSON el número, el nombre, la prioridad y la dirección de la llamada cuando se añade, con una clave de idempotencia. + + + Correo: llamada cerrada + + + Envía un breve resumen por correo cuando se cierra una llamada. + + + FHIR R4 Encounter y Observations + + + Al cerrarse una llamada, envía a la HCE un Bundle de transacción FHIR R4: un Encounter (clase FLD, finalizado, del despacho al cierre) para el paciente de subject_ids.ehr_client_id y una Observation por cada campo personalizado autorizado. Usa creación condicional y guarda el id del nuevo Encounter como ehr_encounter_id. + + + Nota de encuentro HL7 v2.5.1 MDM^T02 + + + Al cerrarse una llamada, envía un documento MDM^T02 "Crisis Field Response" a un motor de interfaces por HTTPS: PID-3 es el id de cliente de la HCE, MSH-10 la clave de idempotencia y un OBX por cada campo personalizado autorizado. Solo tiene éxito con un acuse AA. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx index a3f9def32..4c8655ebb 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx @@ -582,11 +582,8 @@ Sortie rendue - - Tous les types d'événements déclencheurs disponibles ont déjà un workflow configuré. Supprimez un workflow existant pour libérer son type d'événement. - - Chaque type d'événement ne peut avoir qu'un seul workflow. Seuls les types d'événements non utilisés sont affichés. + L'événement qui déclenche ce flux de travail. Plusieurs flux de travail peuvent utiliser le même événement ; chacun s'exécute séparément. Détail @@ -618,4 +615,112 @@ Export de rapport (enregistrements) Aucun (envoyer uniquement le modèle rendu) Les étapes e-mail joignent l'export ; les étapes d'envoi de fichier le téléversent sous son nom de fichier à la place du texte rendu. Les exports se conçoivent dans Enregistrements > Exports de rapports. + + Identifiants client OAuth2 + + + URL du jeton + + + Le point de terminaison HTTPS du jeton, par exemple https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + ID client + + + Secret client + + + Portée + + + Pour Microsoft Dataverse, utilisez https://<org>.crm.dynamics.com/.default. + + + Audience (facultatif) + + + Authentification du client + + + Secret client + + + JWT à clé privée (SMART Backend Services) + + + Algorithme de signature + + + Resgrid génère et conserve la paire de clés ; la clé privée est chiffrée et n'est jamais affichée. Enregistrez l'URL JWKS (ou téléversez la clé publique) auprès du serveur de jetons. + + + URL JWKS + + + Copier + + + Clé actuelle {0}, créée le {1}. Clés publiées : {2}. + + + Télécharger la JWK publique + + + Renouveler la clé + + + Générer une nouvelle clé de signature ? La clé actuelle reste publiée pendant la période de chevauchement afin que le serveur de jetons puisse basculer. + + + Une nouvelle clé de signature a été générée. La clé précédente reste publiée pendant la période de chevauchement. + + + La paire de clés est générée à l'enregistrement. L'URL JWKS et le téléchargement de la clé publique apparaîtront ensuite ici. + + + Partir d'un modèle + + + Les modèles créent un flux de travail désactivé avec des destinations fictives. Définissez l'URL et l'identifiant de chaque étape, puis activez-le. + + + Utiliser le modèle + + + Flux de travail protégés + + + Flux de travail créé à partir du modèle. Il est désactivé : définissez la destination et l'identifiant de chaque étape, puis activez-le. + + + Flux de travail créé à partir du modèle avec une autorisation protégée en brouillon. Définissez l'URL de destination et l'identifiant de l'étape, choisissez les champs autorisés, puis demandez l'approbation. Rien n'est envoyé avant l'approbation de l'autorisation. + + + La limite de flux de travail de votre forfait est atteinte. Passez à un forfait supérieur pour en ajouter. + + + Webhook : nouvel appel (JSON) + + + Envoie en JSON le numéro, le nom, la priorité et l'adresse de l'appel lors de sa création, avec une clé d'idempotence. + + + E-mail : appel clôturé + + + Envoie un court résumé par e-mail à la clôture d'un appel. + + + FHIR R4 Encounter et Observations + + + À la clôture d'un appel, envoie au DPI un Bundle de transaction FHIR R4 : un Encounter (classe FLD, terminé, de l'engagement à la clôture) pour le patient de subject_ids.ehr_client_id et une Observation par champ personnalisé autorisé. Utilise la création conditionnelle et enregistre l'id du nouvel Encounter sous ehr_encounter_id. + + + Note de rencontre HL7 v2.5.1 MDM^T02 + + + À la clôture d'un appel, envoie un document MDM^T02 « Crisis Field Response » à un moteur d'interfaces via HTTPS : PID-3 est l'identifiant client du DPI, MSH-10 la clé d'idempotence, un OBX par champ personnalisé autorisé. Réussit uniquement sur un accusé AA. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx index 03ef777d8..63a34f156 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx @@ -582,11 +582,8 @@ Output renderizzato - - Tutti i tipi di evento trigger disponibili hanno già un workflow configurato. Elimina un workflow esistente per liberare il suo tipo di evento. - - Ogni tipo di evento può avere un solo workflow. Vengono mostrati solo i tipi di evento non utilizzati. + L'evento che avvia questo flusso di lavoro. Più flussi di lavoro possono usare lo stesso evento; ognuno viene eseguito separatamente. Dettaglio @@ -618,4 +615,112 @@ Esportazione report dei record Nessuna (invia solo il modello renderizzato) I passaggi e-mail allegano l'esportazione; i passaggi di caricamento file la caricano con il suo nome file al posto del testo renderizzato. Le esportazioni si progettano in Record > Esportazioni di report. + + Credenziali client OAuth2 + + + URL del token + + + L'endpoint HTTPS del token, ad esempio https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + ID client + + + Segreto client + + + Ambito + + + Per Microsoft Dataverse usa https://<org>.crm.dynamics.com/.default. + + + Destinatario del token (facoltativo) + + + Autenticazione del client + + + Segreto client + + + JWT con chiave privata (SMART Backend Services) + + + Algoritmo di firma + + + Resgrid genera e conserva la coppia di chiavi; la chiave privata è cifrata e non viene mai mostrata. Registra l'URL JWKS (o carica la chiave pubblica) presso il server dei token. + + + URL JWKS + + + Copia + + + Chiave attuale {0}, creata il {1}. Chiavi pubblicate: {2}. + + + Scarica la JWK pubblica + + + Ruota la chiave + + + Generare una nuova chiave di firma? La chiave attuale resta pubblicata per il periodo di sovrapposizione, così il server dei token può passare alla nuova. + + + È stata generata una nuova chiave di firma. La chiave precedente resta pubblicata per il periodo di sovrapposizione. + + + La coppia di chiavi viene generata al salvataggio. L'URL JWKS e il download della chiave pubblica appariranno poi qui. + + + Parti da un modello + + + I modelli creano un flusso di lavoro disattivato con destinazioni segnaposto. Imposta l'URL e la credenziale di ogni passaggio, poi attivalo. + + + Usa il modello + + + Flussi di lavoro protetti + + + Flusso di lavoro creato dal modello. È disattivato: imposta la destinazione e la credenziale di ogni passaggio, poi attivalo. + + + Flusso di lavoro creato dal modello con un'autorizzazione protetta in bozza. Imposta l'URL di destinazione e la credenziale del passaggio, scegli i campi autorizzati e richiedi l'approvazione. Non invia nulla finché l'autorizzazione non è approvata. + + + È stato raggiunto il limite di flussi di lavoro del tuo piano. Passa a un piano superiore per aggiungerne altri. + + + Webhook: nuova chiamata (JSON) + + + Invia in JSON numero, nome, priorità e indirizzo della chiamata quando viene aggiunta, con una chiave di idempotenza. + + + E-mail: chiamata chiusa + + + Invia per e-mail un breve riepilogo quando una chiamata viene chiusa. + + + FHIR R4 Encounter e Observations + + + Alla chiusura di una chiamata, invia alla cartella clinica elettronica un Bundle di transazione FHIR R4: un Encounter (classe FLD, concluso, dall'invio alla chiusura) per il paziente in subject_ids.ehr_client_id e una Observation per ogni campo personalizzato autorizzato. Usa la creazione condizionale e salva l'id del nuovo Encounter come ehr_encounter_id. + + + Nota di incontro HL7 v2.5.1 MDM^T02 + + + Alla chiusura di una chiamata, invia un documento MDM^T02 "Crisis Field Response" a un motore di integrazione via HTTPS: PID-3 è l'id cliente della cartella clinica, MSH-10 la chiave di idempotenza, un OBX per ogni campo personalizzato autorizzato. Riesce solo con una conferma AA. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx index e8c513780..6f55efd3f 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx @@ -582,11 +582,8 @@ Wyrenderowane wyjście - - Wszystkie dostępne typy zdarzeń wyzwalacza mają już skonfigurowany przepływ pracy. Usuń istniejący przepływ pracy, aby zwolnić jego typ zdarzenia. - - Każdy typ zdarzenia może mieć tylko jeden przepływ pracy. Wyświetlane są tylko nieużywane typy zdarzeń. + Zdarzenie, które uruchamia ten przepływ pracy. Kilka przepływów pracy może korzystać z tego samego zdarzenia; każdy działa niezależnie. Szczegóły @@ -618,4 +615,112 @@ Eksport raportu rekordów Brak (wyślij tylko wyrenderowany szablon) Kroki e-mail dołączają eksport; kroki przesyłania pliku wysyłają go pod jego nazwą pliku zamiast wyrenderowanego tekstu. Eksporty projektuje się w Rekordy > Eksporty raportów. + + Poświadczenia klienta OAuth2 + + + Adres URL tokenu + + + Punkt końcowy tokenu HTTPS, na przykład https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + Identyfikator klienta + + + Klucz tajny klienta + + + Zakres + + + W przypadku Microsoft Dataverse użyj https://<org>.crm.dynamics.com/.default. + + + Odbiorca tokenu (opcjonalnie) + + + Uwierzytelnianie klienta + + + Klucz tajny klienta + + + JWT z kluczem prywatnym (SMART Backend Services) + + + Algorytm podpisu + + + Resgrid generuje i przechowuje parę kluczy; klucz prywatny jest szyfrowany i nigdy nie jest wyświetlany. Zarejestruj adres URL JWKS (lub prześlij klucz publiczny) na serwerze tokenów. + + + Adres URL JWKS + + + Kopiuj + + + Bieżący klucz {0}, utworzony {1}. Opublikowane klucze: {2}. + + + Pobierz publiczny JWK + + + Zmień klucz + + + Wygenerować nowy klucz podpisu? Bieżący klucz pozostanie opublikowany przez okres przejściowy, aby serwer tokenów mógł się przełączyć. + + + Wygenerowano nowy klucz podpisu. Poprzedni klucz pozostaje opublikowany przez okres przejściowy. + + + Para kluczy jest generowana przy zapisie. Następnie pojawią się tu adres URL JWKS i pobieranie klucza publicznego. + + + Zacznij od szablonu + + + Szablony tworzą wyłączony przepływ pracy z przykładowymi miejscami docelowymi. Ustaw adres URL i poświadczenie dla każdego kroku, a następnie go włącz. + + + Użyj szablonu + + + Chronione przepływy pracy + + + Przepływ pracy utworzono z szablonu. Jest wyłączony: ustaw miejsce docelowe i poświadczenie dla każdego kroku, a następnie go włącz. + + + Przepływ pracy utworzono z szablonu wraz z roboczym chronionym zezwoleniem. Ustaw docelowy adres URL i poświadczenie kroku, wybierz udostępniane pola i poproś o zatwierdzenie. Nic nie zostanie wysłane przed zatwierdzeniem zezwolenia. + + + Osiągnięto limit przepływów pracy w Twoim planie. Przejdź na wyższy plan, aby dodać więcej. + + + Webhook: nowe zgłoszenie (JSON) + + + Wysyła numer, nazwę, priorytet i adres zgłoszenia jako JSON po jego dodaniu, z kluczem idempotencji. + + + E-mail: zamknięte zgłoszenie + + + Wysyła e-mailem krótkie podsumowanie po zamknięciu zgłoszenia. + + + FHIR R4 Encounter i Observations + + + Po zamknięciu zgłoszenia wysyła do elektronicznej dokumentacji medycznej pakiet transakcyjny FHIR R4 (Bundle): Encounter (klasa FLD, zakończony, od dysponowania do zamknięcia) dla pacjenta z subject_ids.ehr_client_id oraz jedną Observation na każde udostępnione pole niestandardowe. Używa warunkowego tworzenia i zapisuje identyfikator nowego Encounter jako ehr_encounter_id. + + + Notatka z wizyty HL7 v2.5.1 MDM^T02 + + + Po zamknięciu zgłoszenia wysyła dokument MDM^T02 „Crisis Field Response” do silnika integracyjnego przez HTTPS: PID-3 to identyfikator klienta w dokumentacji medycznej, MSH-10 to klucz idempotencji, jeden OBX na każde udostępnione pole niestandardowe. Kończy się sukcesem tylko przy potwierdzeniu AA. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx index fe348a377..a5176f38f 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx @@ -602,9 +602,6 @@ - - - @@ -638,5 +635,113 @@ Records report export None (send the rendered template only) Email steps attach the export; file-upload steps upload it under its file name instead of the rendered text. Exports are designed under Records > Report exports. + + OAuth2 client credentials + + + Token URL + + + The HTTPS token endpoint, for example https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + Client ID + + + Client secret + + + Scope + + + For Microsoft Dataverse use https://<org>.crm.dynamics.com/.default. + + + Audience (optional) + + + Client authentication + + + Client secret + + + Private key JWT (SMART Backend Services) + + + Signing algorithm + + + Resgrid generates and keeps the key pair; the private key is encrypted and never shown. Register the JWKS URL (or upload the public key) with the token server. + + + JWKS URL + + + Copy + + + Current key {0}, created {1}. Keys published: {2}. + + + Download public JWK + + + Rotate key + + + Generate a new signing key? The current key stays published for the overlap period so the token server can move over. + + + A new signing key was generated. The previous key stays published for the overlap period. + + + The key pair is generated when you save. The JWKS URL and the public key download then appear here. + + + Start from a template + + + Templates create a disabled workflow with placeholder destinations. Set the URL and credential on each step, then enable it. + + + Use template + + + Protected Workflows + + + Workflow created from the template. It is disabled: set the destination and credential on each step, then enable it. + + + Workflow created from the template with a draft protected release. Set the destination URL and credential on the step, choose the released fields, then request approval. It never sends until the release is approved. + + + Workflow limit reached for your plan. Please upgrade to add more workflows. + + + Webhook: new call (JSON) + + + Posts the call number, name, priority and address as JSON when a call is added, with an idempotency key. + + + Email: call closed + + + Emails a short summary when a call is closed. + + + FHIR R4 Encounter and Observations + + + When a call closes, sends a FHIR R4 transaction Bundle to the EHR: an Encounter (class FLD, finished, dispatch to close) for the patient in subject_ids.ehr_client_id and one Observation per released custom field. Uses conditional create and saves the new Encounter id as ehr_encounter_id. + + + HL7 v2.5.1 MDM^T02 encounter note + + + When a call closes, sends an MDM^T02 "Crisis Field Response" document to an interface engine over HTTPS: PID-3 is the EHR client id, MSH-10 the idempotency key, one OBX per released custom field. Succeeds only on an AA acknowledgement. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx index a1670f932..b137a2ddf 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx @@ -582,11 +582,8 @@ Renderad utdata - - Alla tillgängliga trigghändelsetyper har redan ett konfigurerat arbetsflöde. Ta bort ett befintligt arbetsflöde för att frigöra dess händelsetyp. - - Varje händelsetyp kan bara ha ett arbetsflöde. Endast oanvända händelsetyper visas. + Händelsen som startar arbetsflödet. Flera arbetsflöden kan använda samma händelse; vart och ett körs för sig. Detalj @@ -618,4 +615,112 @@ Rapportexport (poster) Ingen (skicka endast den renderade mallen) E-poststeg bifogar exporten; filuppladdningssteg laddar upp den under sitt filnamn i stället för den renderade texten. Exporter utformas under Poster > Rapportexporter. + + OAuth2-klientautentiseringsuppgifter + + + Token-URL + + + HTTPS-slutpunkten för token, till exempel https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + Klient-ID + + + Klienthemlighet + + + Omfång + + + För Microsoft Dataverse använder du https://<org>.crm.dynamics.com/.default. + + + Målgrupp (valfritt) + + + Klientautentisering + + + Klienthemlighet + + + JWT med privat nyckel (SMART Backend Services) + + + Signeringsalgoritm + + + Resgrid skapar och förvarar nyckelparet; den privata nyckeln krypteras och visas aldrig. Registrera JWKS-URL:en (eller ladda upp den publika nyckeln) hos tokenservern. + + + JWKS-URL + + + Kopiera + + + Aktuell nyckel {0}, skapad {1}. Publicerade nycklar: {2}. + + + Ladda ned publik JWK + + + Rotera nyckel + + + Skapa en ny signeringsnyckel? Den aktuella nyckeln förblir publicerad under överlappningsperioden så att tokenservern hinner byta. + + + En ny signeringsnyckel har skapats. Den tidigare nyckeln förblir publicerad under överlappningsperioden. + + + Nyckelparet skapas när du sparar. Därefter visas JWKS-URL:en och nedladdningen av den publika nyckeln här. + + + Börja från en mall + + + Mallar skapar ett inaktiverat arbetsflöde med platshållare för mottagare. Ange URL och autentiseringsuppgift för varje steg och aktivera det sedan. + + + Använd mall + + + Skyddade arbetsflöden + + + Arbetsflödet skapades från mallen. Det är inaktiverat: ange mottagare och autentiseringsuppgift för varje steg och aktivera det sedan. + + + Arbetsflödet skapades från mallen med ett utkast till skyddad frisläppning. Ange mottagarens URL och autentiseringsuppgift för steget, välj de frisläppta fälten och begär sedan godkännande. Inget skickas förrän frisläppningen har godkänts. + + + Gränsen för arbetsflöden i din plan har nåtts. Uppgradera för att lägga till fler arbetsflöden. + + + Webhook: nytt larm (JSON) + + + Skickar larmets nummer, namn, prioritet och adress som JSON när ett larm läggs till, med en idempotensnyckel. + + + E-post: larm avslutat + + + Skickar en kort sammanfattning via e-post när ett larm avslutas. + + + FHIR R4 Encounter och Observations + + + När ett larm avslutas skickas en FHIR R4-transaktion (Bundle) till journalsystemet: en Encounter (klass FLD, avslutad, från utlarmning till avslut) för patienten i subject_ids.ehr_client_id och en Observation per frisläppt anpassat fält. Använder villkorlig skapelse och sparar den nya Encounterns id som ehr_encounter_id. + + + HL7 v2.5.1 MDM^T02 besöksanteckning + + + När ett larm avslutas skickas ett MDM^T02-dokument "Crisis Field Response" till en integrationsmotor via HTTPS: PID-3 är journalsystemets klient-id, MSH-10 idempotensnyckeln och en OBX per frisläppt anpassat fält. Lyckas endast vid kvittensen AA. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx index 60889d78b..ebcc56d5d 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx @@ -582,11 +582,8 @@ Відрендерений вивід - - Усі доступні типи подій-тригерів вже мають налаштований робочий процес. Видаліть наявний робочий процес, щоб звільнити його тип події. - - Кожен тип події може мати лише один робочий процес. Відображаються лише невикористані типи подій. + Подія, яка запускає цей робочий процес. Кілька робочих процесів можуть використовувати ту саму подію; кожен виконується окремо. Деталі @@ -618,4 +615,112 @@ Експорт звіту записів Немає (надіслати лише сформований шаблон) Кроки електронної пошти прикріплюють експорт; кроки завантаження файлу надсилають його під назвою файлу замість сформованого тексту. Експорти створюються в розділі Записи > Експорт звітів. + + Облікові дані клієнта OAuth2 + + + URL-адреса токена + + + HTTPS-кінцева точка токена, наприклад https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. + + + Ідентифікатор клієнта + + + Секрет клієнта + + + Область дії + + + Для Microsoft Dataverse використовуйте https://<org>.crm.dynamics.com/.default. + + + Аудиторія (необов'язково) + + + Автентифікація клієнта + + + Секрет клієнта + + + JWT із приватним ключем (SMART Backend Services) + + + Алгоритм підпису + + + Resgrid створює та зберігає пару ключів; приватний ключ зашифровано, і він ніколи не відображається. Зареєструйте URL JWKS (або завантажте відкритий ключ) на сервері токенів. + + + URL JWKS + + + Копіювати + + + Поточний ключ {0}, створено {1}. Опубліковано ключів: {2}. + + + Завантажити відкритий JWK + + + Змінити ключ + + + Створити новий ключ підпису? Поточний ключ залишиться опублікованим протягом перехідного періоду, щоб сервер токенів міг перейти на новий. + + + Створено новий ключ підпису. Попередній ключ залишається опублікованим протягом перехідного періоду. + + + Пара ключів створюється під час збереження. Після цього тут з'являться URL JWKS і завантаження відкритого ключа. + + + Почати із шаблону + + + Шаблони створюють вимкнений робочий процес із заповнювачами призначень. Укажіть URL та облікові дані для кожного кроку, а потім увімкніть його. + + + Використати шаблон + + + Захищені робочі процеси + + + Робочий процес створено із шаблону. Він вимкнений: укажіть призначення та облікові дані для кожного кроку, а потім увімкніть його. + + + Робочий процес створено із шаблону разом із чернеткою захищеного дозволу. Укажіть URL призначення та облікові дані кроку, виберіть поля для розкриття та надішліть запит на затвердження. Нічого не надсилається, доки дозвіл не затверджено. + + + Досягнуто ліміту робочих процесів вашого плану. Оновіть план, щоб додати більше робочих процесів. + + + Вебхук: новий виклик (JSON) + + + Надсилає номер, назву, пріоритет і адресу виклику у форматі JSON під час його додавання, з ключем ідемпотентності. + + + Ел. пошта: виклик закрито + + + Надсилає короткий підсумок електронною поштою, коли виклик закрито. + + + FHIR R4 Encounter і Observations + + + Після закриття виклику надсилає до електронної медичної картки транзакційний Bundle FHIR R4: Encounter (клас FLD, завершений, від диспетчеризації до закриття) для пацієнта з subject_ids.ehr_client_id та одну Observation для кожного дозволеного користувацького поля. Використовує умовне створення та зберігає ідентифікатор нового Encounter як ehr_encounter_id. + + + Нотатка про контакт HL7 v2.5.1 MDM^T02 + + + Після закриття виклику надсилає документ MDM^T02 «Crisis Field Response» до інтеграційного рушія через HTTPS: PID-3 — ідентифікатор клієнта в медичній картці, MSH-10 — ключ ідемпотентності, один OBX для кожного дозволеного користувацького поля. Успішно лише з підтвердженням AA. + diff --git a/Core/Resgrid.Model/Call.cs b/Core/Resgrid.Model/Call.cs index a7fb901c2..8ff98901d 100644 --- a/Core/Resgrid.Model/Call.cs +++ b/Core/Resgrid.Model/Call.cs @@ -201,6 +201,21 @@ public class Call : IEntity public bool CheckInTimersEnabled { get; set; } + /// + /// External subject and record identifiers as a JSON object of string keys to string values, for example + /// {"ehr_client_id":"123456","ehr_encounter_id":"E-9"}. Protected (catalog calls.subjectidentifiers): an + /// envelope in an ADP department. Kept apart from ExternalIdentifier, which integrations already use for their + /// own case id. See for the rules. + /// + public string SubjectIdentifiers { get; set; } + + /// + /// The department has 42 CFR Part 2 consent (or another Part 2 basis) on file for this call's redisclosure. + /// Structural, never protected: a workflow condition may read it, and a Protected Workflow releasing a Part 2 + /// field refuses to send unless it is true. + /// + public bool Part2ConsentOnFile { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Model/CallSubjectIdentifiers.cs b/Core/Resgrid.Model/CallSubjectIdentifiers.cs new file mode 100644 index 000000000..35faab80c --- /dev/null +++ b/Core/Resgrid.Model/CallSubjectIdentifiers.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Model +{ + /// + /// Rules for : a JSON object of string keys to string values, keys matching + /// ^[a-z0-9_]{1,64}$, values of at most 256 characters, and at most 20 keys. Stored canonically (keys sorted). + /// + public static class CallSubjectIdentifiers + { + public const int MaxKeys = 20; + public const int MaxValueLength = 256; + + // Validation codes (v4 returns them; the UI localizes SubjectIdentifiers_{code}). + public const string TooManyKeys = "subject_identifiers_too_many_keys"; + public const string InvalidKey = "subject_identifiers_invalid_key"; + public const string ValueTooLong = "subject_identifiers_value_too_long"; + public const string InvalidValue = "subject_identifiers_invalid_value"; + public const string NotAnObject = "subject_identifiers_not_an_object"; + + /// Errors for a candidate set of identifiers; empty when valid. + public static List Validate(IDictionary identifiers) + { + var errors = new List(); + if (identifiers == null) + return errors; + + if (identifiers.Count > MaxKeys) + errors.Add(TooManyKeys); + + foreach (var pair in identifiers) + { + if (pair.Key == null || !ProtectedStepOptions.SubjectKeyPattern.IsMatch(pair.Key)) + errors.Add(InvalidKey); + if (string.IsNullOrEmpty(pair.Value) || pair.Value.Any(char.IsControl)) + errors.Add(InvalidValue); + else if (pair.Value.Length > MaxValueLength) + errors.Add(ValueTooLong); + } + + return errors.Distinct(StringComparer.Ordinal).ToList(); + } + + /// + /// Parses stored plaintext. Null or empty is an empty set. Anything that is not an object of strings fails: + /// callers must never fall back to the raw text. + /// + public static bool TryParse(string json, out SortedDictionary identifiers) + { + identifiers = new SortedDictionary(StringComparer.Ordinal); + if (string.IsNullOrWhiteSpace(json)) + return true; + + try + { + using var reader = new JsonTextReader(new StringReader(json)) { DateParseHandling = DateParseHandling.None }; + if (JToken.ReadFrom(reader) is not JObject obj || reader.Read()) + return false; + + foreach (var property in obj.Properties()) + { + if (property.Value.Type != JTokenType.String) + return false; + identifiers[property.Name] = (string)property.Value; + } + + return true; + } + catch (JsonException) + { + identifiers = new SortedDictionary(StringComparer.Ordinal); + return false; + } + } + + /// Canonical storage form (keys sorted), or null for an empty set. + public static string Serialize(IDictionary identifiers) + { + if (identifiers == null || identifiers.Count == 0) + return null; + + var obj = new JObject(); + foreach (var pair in identifiers.OrderBy(p => p.Key, StringComparer.Ordinal)) + obj[pair.Key] = pair.Value; + return obj.ToString(Formatting.None); + } + } +} diff --git a/Core/Resgrid.Model/DepartmentProtectedDataEgressPolicy.cs b/Core/Resgrid.Model/DepartmentProtectedDataEgressPolicy.cs index bd0bd7d6a..fbff17331 100644 --- a/Core/Resgrid.Model/DepartmentProtectedDataEgressPolicy.cs +++ b/Core/Resgrid.Model/DepartmentProtectedDataEgressPolicy.cs @@ -12,8 +12,10 @@ namespace Resgrid.Model /// department). Every channel defaults to ProtectedDataEgressMode.GenericOnly; enabling protected /// content on any channel requires an explicit versioned warning acknowledgement. Changing any /// mode increments the department PolicyEpoch (on DepartmentDataProtectionPolicy), cancelling - /// pending protected deliveries where possible. Egress policy can never relax BigBoard or Workflow - /// restrictions. + /// pending protected deliveries where possible. Egress policy can never relax BigBoard + /// restrictions. Workflow restrictions are relaxed only through approved Protected Workflow + /// releases (WorkflowProtectedRelease), which additionally require ProtectedWorkflowsEnabled here; + /// every other workflow keeps receiving REDACTED values. /// [Table("DepartmentProtectedDataEgressPolicies")] [ProtoContract] @@ -79,6 +81,41 @@ public class DepartmentProtectedDataEgressPolicy : IEntity [ProtoMember(15)] public string UpdatedByUserId { get; set; } + /// + /// Department opt-in to Protected Workflows. Off by default; while off every workflow receives REDACTED + /// values and every release in the department is Suspended (department_disabled). Changing it bumps + /// the PolicyEpoch like any other egress change. + /// + [ProtoMember(16)] + public bool ProtectedWorkflowsEnabled { get; set; } + + /// Version of the Protected Workflows warning text the enabling administrator acknowledged. + [MaxLength(64)] + [ProtoMember(17)] + public string ProtectedWorkflowsAckVersion { get; set; } + + [MaxLength(128)] + [ProtoMember(18)] + public string ProtectedWorkflowsAckByUserId { get; set; } + + [ProtoMember(19)] + public DateTime? ProtectedWorkflowsAckOn { get; set; } + + /// Two-person rule: a release request must be approved by a second administrator. + [ProtoMember(20)] + public bool ProtectedWorkflowsRequireSecondApprover { get; set; } + + /// + /// Relaxing the two-person rule is itself two-person: the first administrator's request is parked here and only a + /// DIFFERENT administrator can confirm it. Tightening (turning the rule on) is immediate. + /// + [MaxLength(128)] + [ProtoMember(21)] + public string ProtectedWorkflowsRelaxRequestedByUserId { get; set; } + + [ProtoMember(22)] + public DateTime? ProtectedWorkflowsRelaxRequestedOn { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index 2aa10cbf5..ced29eb73 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -108,5 +108,11 @@ public enum DepartmentSettingTypes /// ProtoBuf-serialized : public-records statutory clock, default redaction profile, release approver. RMS-3. RecordsDisclosureConfig = 77, + + /// + /// ProtoBuf-serialized : whether dispatch views are scoped to the + /// user's group subtree, and which personnel roles stay department-wide while it is on. + /// + GroupDispatchScopeConfig = 78, } } diff --git a/Core/Resgrid.Model/DispatchScope.cs b/Core/Resgrid.Model/DispatchScope.cs new file mode 100644 index 000000000..58821c3dc --- /dev/null +++ b/Core/Resgrid.Model/DispatchScope.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// Why a user's dispatch scope resolved the way it did. + public enum DispatchScopeReasons + { + /// The department has not turned group-scoped dispatch on; everyone is department-wide. + ScopingDisabled = 0, + + /// Department admins always dispatch department-wide. + DepartmentAdmin = 1, + + /// The user holds a role configured as department-wide (e.g. a central dispatch center). + DepartmentWideRole = 2, + + /// The user administers a group; scope is that group and every group beneath it. + GroupAdmin = 3, + + /// The user is a member of a group; scope is that group and every group beneath it. + GroupMember = 4, + + /// The user is in no group and holds no department-wide role; only calls they are on are in scope. + NoGroup = 5 + } + + /// + /// The slice of a department a user dispatches right now. Computed per request from the + /// user's roles and group membership (see ). + /// + public class DispatchScope + { + public int DepartmentId { get; set; } + + public string UserId { get; set; } + + public bool IsDepartmentWide { get; set; } + + public DispatchScopeReasons Reason { get; set; } + + /// The group the scope hangs from (the user's own group); null when department-wide or ungrouped. + public int? AnchorGroupId { get; set; } + + /// The anchor group plus every descendant. Empty when department-wide (check ) or ungrouped. + public HashSet GroupIds { get; set; } = new HashSet(); + + public bool IncludesGroup(int? departmentGroupId) + { + if (IsDepartmentWide) + return true; + + return departmentGroupId.HasValue && GroupIds.Contains(departmentGroupId.Value); + } + + public static DispatchScope DepartmentWide(int departmentId, string userId, DispatchScopeReasons reason) + { + return new DispatchScope + { + DepartmentId = departmentId, + UserId = userId, + IsDepartmentWide = true, + Reason = reason + }; + } + } +} diff --git a/Core/Resgrid.Model/Events/ShiftRosterChangedEvent.cs b/Core/Resgrid.Model/Events/ShiftRosterChangedEvent.cs new file mode 100644 index 000000000..7ee346bc1 --- /dev/null +++ b/Core/Resgrid.Model/Events/ShiftRosterChangedEvent.cs @@ -0,0 +1,27 @@ +using Resgrid.Model.Queue; + +namespace Resgrid.Model.Events +{ + /// + /// A single-day roster change or approval step that someone should be told about: a signup or trade waiting for a + /// supervisor, a supervisor's decision, or a supervisor adding or removing a person on a day. + /// is the value the notification worker switches on. + /// + public class ShiftRosterChangedEvent + { + public int DepartmentId { get; set; } + + public string DepartmentNumber { get; set; } + + public ShiftQueueTypes ChangeType { get; set; } + + public int ShiftId { get; set; } + + public int ShiftSignupId { get; set; } + + public int ShiftSignupTradeId { get; set; } + + /// The person who acted (supervisor, requester). + public string UserId { get; set; } + } +} diff --git a/Core/Resgrid.Model/GroupDispatchScopeConfig.cs b/Core/Resgrid.Model/GroupDispatchScopeConfig.cs new file mode 100644 index 000000000..40c1db695 --- /dev/null +++ b/Core/Resgrid.Model/GroupDispatchScopeConfig.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using ProtoBuf; + +namespace Resgrid.Model +{ + /// + /// Department switch for group-scoped dispatch, stored serialized in + /// . When enabled, a user's + /// dispatch view (active call lists, single-call access, the nearest unit board) is limited + /// to the calls and resources of their group and every group beneath it, unless the user is + /// a department admin or holds one of . + /// + /// Scope is resolved from the user's roles and group membership on every request and is + /// never written onto calls, so handing dispatch from area supervisors to a central + /// dispatch center at shift change is a role assignment, not a data move. + /// + /// + [ProtoContract] + public class GroupDispatchScopeConfig + { + public GroupDispatchScopeConfig() + { + Enabled = false; + DepartmentWideRoleIds = new List(); + } + + /// Off by default: every member keeps today's department-wide call view. + [ProtoMember(1)] + public bool Enabled { get; set; } + + /// + /// Personnel roles whose holders dispatch department-wide (e.g. a central dispatch center) + /// even while scoping is on. Department admins are always department-wide. + /// + [ProtoMember(2)] + public List DepartmentWideRoleIds { get; set; } + } +} diff --git a/Core/Resgrid.Model/Helpers/DepartmentGroupHierarchy.cs b/Core/Resgrid.Model/Helpers/DepartmentGroupHierarchy.cs new file mode 100644 index 000000000..227793be2 --- /dev/null +++ b/Core/Resgrid.Model/Helpers/DepartmentGroupHierarchy.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.Helpers +{ + /// + /// Pure walks over the DepartmentGroup parent/child tree (ParentDepartmentGroupId). Works on + /// a flat list of a department's groups so callers can use the cached group list instead of + /// loading children group by group. Every walk tracks visited ids, so a corrupt parent cycle + /// terminates instead of spinning. + /// + public static class DepartmentGroupHierarchy + { + /// The root group plus every group beneath it, at any depth. Empty when the root isn't in the list. + public static HashSet GetSelfAndDescendantIds(IEnumerable groups, int rootGroupId) + { + var result = new HashSet(); + + if (groups == null) + return result; + + var all = groups.Where(g => g != null).ToList(); + + if (all.All(g => g.DepartmentGroupId != rootGroupId)) + return result; + + var childrenByParent = all + .Where(g => g.ParentDepartmentGroupId.HasValue) + .GroupBy(g => g.ParentDepartmentGroupId.Value) + .ToDictionary(g => g.Key, g => g.Select(c => c.DepartmentGroupId).ToList()); + + var pending = new Queue(); + pending.Enqueue(rootGroupId); + + while (pending.Count > 0) + { + var current = pending.Dequeue(); + + if (!result.Add(current)) + continue; + + if (childrenByParent.TryGetValue(current, out var children)) + { + foreach (var child in children) + pending.Enqueue(child); + } + } + + return result; + } + + /// Parent, grandparent, ... of the group, nearest first. Excludes the group itself. + public static List GetAncestorIds(IEnumerable groups, int groupId) + { + var ancestors = new List(); + + if (groups == null) + return ancestors; + + var byId = groups.Where(g => g != null) + .GroupBy(g => g.DepartmentGroupId) + .ToDictionary(g => g.Key, g => g.First()); + + var visited = new HashSet { groupId }; + var current = byId.TryGetValue(groupId, out var start) ? start : null; + + while (current?.ParentDepartmentGroupId != null) + { + var parentId = current.ParentDepartmentGroupId.Value; + + if (!visited.Add(parentId)) + break; + + ancestors.Add(parentId); + current = byId.TryGetValue(parentId, out var parent) ? parent : null; + } + + return ancestors; + } + + /// + /// Whether can become the parent of : + /// it must exist in the list (so, the same department), must not be the group itself and must + /// not sit beneath the group, which would close a cycle. + /// + public static bool IsValidParent(IEnumerable groups, int groupId, int parentGroupId) + { + if (groups == null || groupId == parentGroupId) + return false; + + var all = groups.Where(g => g != null).ToList(); + + if (all.All(g => g.DepartmentGroupId != parentGroupId)) + return false; + + return !GetSelfAndDescendantIds(all, groupId).Contains(parentGroupId); + } + } +} diff --git a/Core/Resgrid.Model/Helpers/ShiftTimeWindow.cs b/Core/Resgrid.Model/Helpers/ShiftTimeWindow.cs new file mode 100644 index 000000000..b50c5b5aa --- /dev/null +++ b/Core/Resgrid.Model/Helpers/ShiftTimeWindow.cs @@ -0,0 +1,72 @@ +using System; +using System.Globalization; + +namespace Resgrid.Model.Helpers +{ + /// + /// Works out when a shift day actually runs from the shift's StartTime/EndTime strings, in the + /// department's local time. Shifts store times as free text ("7:00 AM", "19:00"); an overnight + /// shift ends on the day after its ShiftDay. Pure: no I/O and no department lookup. + /// + public static class ShiftTimeWindow + { + private static readonly string[] TimeFormats = + { + "h:mm tt", "hh:mm tt", "h:mmtt", "hh:mmtt", "h tt", "htt", + "H:mm", "HH:mm", "H:mm:ss", "HH:mm:ss", "HHmm" + }; + + /// Time of day from a shift time string, or null when it can't be read. + public static TimeSpan? TryParseTimeOfDay(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var trimmed = value.Trim().ToUpperInvariant(); + + if (DateTime.TryParseExact(trimmed, TimeFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + return parsed.TimeOfDay; + + return null; + } + + /// + /// The local [start, end) window of one shift day. A missing or unreadable start time means + /// midnight (matching the shift notification logic). The end comes from EndTime, rolling to + /// the next day when it is not after the start; failing that from ; + /// failing both the shift is taken to run a full day. + /// + public static (DateTime Start, DateTime End) GetWindow(DateTime shiftDay, string startTime, string endTime, int? hours) + { + var start = shiftDay.Date + (TryParseTimeOfDay(startTime) ?? TimeSpan.Zero); + var endOfDay = TryParseTimeOfDay(endTime); + + DateTime end; + if (endOfDay.HasValue) + { + end = shiftDay.Date + endOfDay.Value; + + if (end <= start) + end = end.AddDays(1); + } + else if (hours.HasValue && hours.Value > 0) + { + end = start.AddHours(hours.Value); + } + else + { + end = start.AddDays(1); + } + + return (start, end); + } + + /// Whether falls inside the shift day's window. + public static bool IsActive(DateTime localNow, DateTime shiftDay, string startTime, string endTime, int? hours) + { + var window = GetWindow(shiftDay, startTime, endTime, hours); + + return localNow >= window.Start && localNow < window.End; + } + } +} diff --git a/Core/Resgrid.Model/Helpers/UdfValidationHelper.cs b/Core/Resgrid.Model/Helpers/UdfValidationHelper.cs index bb30d15d2..38f395304 100644 --- a/Core/Resgrid.Model/Helpers/UdfValidationHelper.cs +++ b/Core/Resgrid.Model/Helpers/UdfValidationHelper.cs @@ -57,6 +57,139 @@ public static List ValidateFieldNamesUnique(IEnumerable fields return errors; } + + /// + /// Validates the predefined option lists of Dropdown, MultiSelect and ComboBox fields. Returns a + /// list of error messages; empty means every option field is usable. + /// + /// + /// Value validation skips the option check when a field has no options, so an option field saved + /// without any would render as an empty picker yet accept any typed value through the API. + /// MultiSelect keys may not contain commas because the selection is stored comma-joined, and no + /// key may be the ADP REDACTED sentinel, which a save treats as "keep the stored value". + /// A ComboBox resolves typed text to an option by key or label ignoring case, so its keys and + /// labels must be unique ignoring case, and its labels (what the input shows and posts) must be + /// non-empty, must not be the sentinel, and must pass the field's own text rules — the browser + /// enforces minlength/maxlength/pattern on the input, and would otherwise block a listed choice. + /// + public static List ValidateFieldOptions(IEnumerable fields) + { + var errors = new List(); + + foreach (var field in fields ?? Enumerable.Empty()) + { + var dataType = (UdfFieldDataType)field.FieldDataType; + if (dataType != UdfFieldDataType.Dropdown && dataType != UdfFieldDataType.MultiSelect && + dataType != UdfFieldDataType.ComboBox) + continue; + + var label = string.IsNullOrWhiteSpace(field.Label) ? field.Name : field.Label; + + UdfValidationRules rules = null; + if (!string.IsNullOrWhiteSpace(field.ValidationRules)) + { + try { rules = JsonConvert.DeserializeObject(field.ValidationRules); } + catch { /* Malformed rules JSON — reported below as having no options */ } + } + + var options = rules?.Options ?? new List(); + if (options.Count == 0) + { + errors.Add($"'{label}' is a {dataType} field and needs at least one option."); + continue; + } + + var keys = options.Select(o => o?.Key?.Trim() ?? string.Empty).ToList(); + + if (keys.Any(string.IsNullOrEmpty)) + errors.Add($"Every option in '{label}' needs a key."); + + if (dataType == UdfFieldDataType.MultiSelect && keys.Any(k => k.Contains(','))) + errors.Add($"Option keys in multi-select '{label}' cannot contain commas."); + + if (keys.Any(k => k == ProtectedDataEnvelope.RedactionValue)) + errors.Add($"'{ProtectedDataEnvelope.RedactionValue}' is reserved and cannot be used as an option key in '{label}'."); + + var isCombo = dataType == UdfFieldDataType.ComboBox; + var keyComparer = isCombo ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + var duplicateKeys = keys + .Where(k => !string.IsNullOrEmpty(k)) + .GroupBy(k => k, keyComparer) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicateKeys.Count > 0) + errors.Add($"Option key(s) {string.Join(", ", duplicateKeys.Select(k => $"'{k}'"))} are used more than once in '{label}'."); + + if (!isCombo) + continue; + + var labels = options.Select(o => o?.Label?.Trim() ?? string.Empty).ToList(); + + if (labels.Any(string.IsNullOrEmpty)) + errors.Add($"Every option in combo box '{label}' needs a label."); + + if (labels.Any(l => l == ProtectedDataEnvelope.RedactionValue)) + errors.Add($"'{ProtectedDataEnvelope.RedactionValue}' is reserved and cannot be used as an option label in '{label}'."); + + var duplicateLabels = labels + .Where(l => !string.IsNullOrEmpty(l)) + .GroupBy(l => l, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicateLabels.Count > 0) + errors.Add($"Option label(s) {string.Join(", ", duplicateLabels.Select(l => $"'{l}'"))} are used more than once in '{label}'."); + + foreach (var optionLabel in labels.Where(l => !string.IsNullOrEmpty(l))) + { + var labelErrors = new List(); + ValidateFreeText(label, optionLabel, rules, labelErrors); + if (labelErrors.Count > 0) + errors.Add($"Option '{optionLabel}' in '{label}' does not meet the field's own length or format rules."); + } + } + + return errors; + } + + /// + /// Finds the ComboBox option an entry refers to: an exact key first, then a label or key ignoring + /// case. Returns null for free text (or when there are no options). + /// + public static UdfDropdownOption FindComboOption(UdfValidationRules rules, string value) + { + if (rules?.Options == null || string.IsNullOrWhiteSpace(value)) + return null; + + var text = value.Trim(); + var options = rules.Options.Where(o => o != null).ToList(); + + return options.FirstOrDefault(o => string.Equals(o.Key, text, StringComparison.Ordinal)) + ?? options.FirstOrDefault(o => string.Equals(o.Label?.Trim(), text, StringComparison.OrdinalIgnoreCase)) + ?? options.FirstOrDefault(o => string.Equals(o.Key?.Trim(), text, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Returns the value to persist for a field. For a ComboBox, an entry that names an option (the + /// web input posts the option's label; a mobile picker may send its key) is stored as that + /// option's key, and free text is stored trimmed. Every other type, and the ADP REDACTED + /// sentinel or a sealed envelope, is returned unchanged. + /// + public static string NormalizeFieldValue(UdfField field, string value) + { + if (field == null || field.FieldDataType != (int)UdfFieldDataType.ComboBox || string.IsNullOrWhiteSpace(value) || + value == ProtectedDataEnvelope.RedactionValue || ProtectedDataEnvelope.HasEnvelopePrefix(value)) + return value; + + return FindComboOption(ParseRules(field.ValidationRules), value)?.Key ?? value.Trim(); + } + + /// + /// Validates a single field value against its definition's data type and validation rules. /// /// The UDF field definition containing type and rules. /// The raw string value to validate. @@ -82,6 +215,13 @@ public static List ValidateFieldValue(UdfField field, string value) if (isEmpty) return errors; // Not required and empty — valid + // The REDACTED sentinel is an ADP-protected value the editor never had revealed, posted + // back unchanged. The save swaps it for the stored value before persisting, so it is not + // a value to type-check: a dropdown, picker, checkbox or number field would otherwise + // reject its own round-trip ("must be one of the allowed options") and block the save. + if (value == ProtectedDataEnvelope.RedactionValue) + return errors; + // Parse validation rules if present UdfValidationRules rules = null; if (!string.IsNullOrWhiteSpace(field.ValidationRules)) @@ -172,25 +312,20 @@ public static List ValidateFieldValue(UdfField field, string value) $"{field.Label} contains invalid options: {string.Join(", ", invalid)}."); } break; + + case UdfFieldDataType.ComboBox: + // A listed choice was sanctioned by the admin (and its label checked against these + // rules when the definition was saved); only free text is held to the text rules. + if (FindComboOption(rules, value) != null) + return errors; + + ValidateTextRules(field.Label, value, rules, errors); + break; } // Additional regex check (applies on top of type-specific checks) - if (rules?.Regex != null && errors.Count == 0) - { - try - { - if (!Regex.IsMatch(value, rules.Regex, RegexOptions.None, TimeSpan.FromMilliseconds(200))) - errors.Add(rules.RegexErrorMessage ?? $"{field.Label} does not match the required format."); - } - catch (RegexMatchTimeoutException) - { - errors.Add(rules.RegexErrorMessage ?? $"{field.Label} validation timed out; the value could not be validated against the required format."); - } - catch (ArgumentException) - { - errors.Add(rules.RegexErrorMessage ?? $"{field.Label} could not be validated due to an invalid pattern configuration."); - } - } + if (errors.Count == 0) + ValidatePattern(field.Label, value, rules, errors); return errors; } @@ -254,6 +389,41 @@ public static Dictionary GetHtmlValidationAttributes(UdfField fi // ── Private helpers ────────────────────────────────────────────────────── + private static UdfValidationRules ParseRules(string json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try { return JsonConvert.DeserializeObject(json); } + catch { return null; } + } + + /// Length rules, then the format rule when the length rules pass. + private static void ValidateFreeText(string label, string value, UdfValidationRules rules, List errors) + { + ValidateTextRules(label, value, rules, errors); + if (errors.Count == 0) + ValidatePattern(label, value, rules, errors); + } + + private static void ValidatePattern(string label, string value, UdfValidationRules rules, List errors) + { + if (rules?.Regex == null) + return; + + try + { + if (!Regex.IsMatch(value, rules.Regex, RegexOptions.None, TimeSpan.FromMilliseconds(200))) + errors.Add(rules.RegexErrorMessage ?? $"{label} does not match the required format."); + } + catch (RegexMatchTimeoutException) + { + errors.Add(rules.RegexErrorMessage ?? $"{label} validation timed out; the value could not be validated against the required format."); + } + catch (ArgumentException) + { + errors.Add(rules.RegexErrorMessage ?? $"{label} could not be validated due to an invalid pattern configuration."); + } + } + private static void ValidateTextRules(string label, string value, UdfValidationRules rules, List errors) { if (rules == null) return; diff --git a/Core/Resgrid.Model/NearestUnitBoard.cs b/Core/Resgrid.Model/NearestUnitBoard.cs new file mode 100644 index 000000000..f88fb81e5 --- /dev/null +++ b/Core/Resgrid.Model/NearestUnitBoard.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// How an ETA was produced. + public enum EtaSources + { + None = 0, + + /// Straight-line distance at an assumed road speed; labelled as an estimate. + Estimated = 1, + + /// Routed drive time from the mapping provider. + Road = 2 + } + + /// Where a unit's position on the board comes from. + public enum UnitPositionSources + { + None = 0, + + /// The unit's latest GPS fix. + Live = 1, + + /// The unit's station location; used when the unit has no GPS fix. + Station = 2 + } + + /// Who a unit's crew is taken to be. + public enum UnitCrewSources + { + /// Nobody is assigned to the unit and nobody is on shift at its station. + None = 0, + + /// The people assigned to the unit's seats (unit roles). + Assigned = 1, + + /// No seats are filled, so the people on a running shift at the unit's station. + StationShift = 2 + } + + public class NearestUnitRequest + { + public int DepartmentId { get; set; } + + /// The viewer. Drives dispatch scope and unit/personnel visibility. + public string UserId { get; set; } + + public double Latitude { get; set; } + + public double Longitude { get; set; } + + /// + /// Look up routed drive times for the closest available units. Null follows the department's + /// dispatch recommendation setting (UseRoutedEta); the lookup count is capped by its ETA shortlist size. + /// + public bool? UseRoadEta { get; set; } + } + + /// + /// The nearest available unit board for an incident. A Unit is whatever the department dispatches: a + /// team, an apparatus, or an individual person set up as a unit. Units are ranked; individual personnel + /// are listed alongside for the people who respond on their own. + /// + public class NearestUnitBoard + { + public double Latitude { get; set; } + + public double Longitude { get; set; } + + public DateTime GeneratedOn { get; set; } + + /// False when the viewer's dispatch scope limited the board to their group subtree. + public bool IsDepartmentWide { get; set; } + + public DispatchScopeReasons ScopeReason { get; set; } + + /// Groups (any type) whose boundary contains the incident, innermost first when nested. + public List ContainingBoundaries { get; set; } = new List(); + + /// Every unit in scope: available first, then by ETA, then distance. + public List Units { get; set; } = new List(); + + /// Every responder in scope, ordered the same way. + public List Personnel { get; set; } = new List(); + + public List Notes { get; set; } = new List(); + } + + public class NearestBoundaryGroup + { + public int DepartmentGroupId { get; set; } + + public string Name { get; set; } + + public int? GroupType { get; set; } + } + + public class NearestUnitResult + { + public int UnitId { get; set; } + + public string Name { get; set; } + + /// The department's unit type (e.g. a team, an engine, a solo clinician). + public string UnitType { get; set; } + + /// The unit's station group. + public int? DepartmentGroupId { get; set; } + + public string GroupName { get; set; } + + /// The group above the station (e.g. its service area). + public int? ParentGroupId { get; set; } + + public string ParentGroupName { get; set; } + + /// The incident is inside the boundary of the unit's own station group. + public bool IncidentInGroupBoundary { get; set; } + + /// The incident is inside the boundary of a group above the unit's station (e.g. its service area). + public bool IncidentInParentBoundary { get; set; } + + public string StatusText { get; set; } + + public bool IsAvailable { get; set; } + + public double? Latitude { get; set; } + + public double? Longitude { get; set; } + + public DateTime? PositionTimestamp { get; set; } + + public bool PositionIsStale { get; set; } + + public UnitPositionSources PositionSource { get; set; } + + /// The viewer may not see this unit's location; coordinates are withheld, distance and ETA are not. + public bool LocationHidden { get; set; } + + public double? DistanceMeters { get; set; } + + public double? EtaSeconds { get; set; } + + public EtaSources EtaSource { get; set; } + + public UnitCrewSources CrewSource { get; set; } + + /// Crew names, as far as the viewer may see them. + public List Crew { get; set; } = new List(); + + public int CrewCount { get; set; } + + public int CrewAvailableCount { get; set; } + + /// Crew members on a shift that is running now. + public int OnShiftCount { get; set; } + + public List ShiftNames { get; set; } = new List(); + + /// Personnel roles held by the crew, most common first. + public List RoleMix { get; set; } = new List(); + } + + public class RoleCount + { + public int PersonnelRoleId { get; set; } + + public string Name { get; set; } + + public int Count { get; set; } + } + + public class NearestPersonnelResult + { + public string UserId { get; set; } + + public string Name { get; set; } + + /// The group the person counts toward: the group they cover on a running shift, else their own group. + public int? DepartmentGroupId { get; set; } + + public string GroupName { get; set; } + + /// The unit the person is assigned to right now, if any. + public int? UnitId { get; set; } + + public string UnitName { get; set; } + + public string StatusText { get; set; } + + public string StaffingText { get; set; } + + public bool IsAvailable { get; set; } + + public bool IsOnShift { get; set; } + + public List Roles { get; set; } = new List(); + + public double? Latitude { get; set; } + + public double? Longitude { get; set; } + + public DateTime? LocationTimestamp { get; set; } + + public bool LocationIsStale { get; set; } + + /// The viewer may not see this person's location; coordinates are withheld, distance and ETA are not. + public bool LocationHidden { get; set; } + + public double? DistanceMeters { get; set; } + + public double? EtaSeconds { get; set; } + + public EtaSources EtaSource { get; set; } + } +} diff --git a/Core/Resgrid.Model/OnShiftAssignment.cs b/Core/Resgrid.Model/OnShiftAssignment.cs new file mode 100644 index 000000000..491300ddd --- /dev/null +++ b/Core/Resgrid.Model/OnShiftAssignment.cs @@ -0,0 +1,21 @@ +namespace Resgrid.Model +{ + /// + /// One person on a shift that is running right now (see IShiftsService.GetOnShiftPersonnelAsync). + /// A person on two overlapping shifts appears once per shift. + /// + public class OnShiftAssignment + { + public string UserId { get; set; } + + public int ShiftId { get; set; } + + public string ShiftName { get; set; } + + /// + /// The group the person is covering on this shift: the signup's group, or the group an assigned + /// shift placed them in. Null when the shift doesn't say; callers fall back to the person's own group. + /// + public int? DepartmentGroupId { get; set; } + } +} diff --git a/Core/Resgrid.Model/ProcessLogTypes.cs b/Core/Resgrid.Model/ProcessLogTypes.cs index fd3b79990..23bf45951 100644 --- a/Core/Resgrid.Model/ProcessLogTypes.cs +++ b/Core/Resgrid.Model/ProcessLogTypes.cs @@ -3,6 +3,9 @@ public enum ProcessLogTypes { ShiftNotifier = 1, - TrainingNotifier = 2 + TrainingNotifier = 2, + + /// A shift day's start reminder; the log id is the ShiftDayId (ShiftNotifier logs were keyed on the ShiftId). + ShiftDayNotifier = 3 } } \ No newline at end of file diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedPayloadValidator.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedPayloadValidator.cs new file mode 100644 index 000000000..5741e03c5 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedPayloadValidator.cs @@ -0,0 +1,177 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Xml; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Model +{ + /// The value-free result of checking a rendered protected payload: the rule that failed and where, never what. + public readonly struct ProtectedPayloadCheck + { + private ProtectedPayloadCheck(bool ok, string rule, int? line, int? position) + { + Ok = ok; + Rule = rule; + Line = line; + Position = position; + } + + public bool Ok { get; } + public string Rule { get; } + public int? Line { get; } + public int? Position { get; } + + public static ProtectedPayloadCheck Valid => new ProtectedPayloadCheck(true, null, null, null); + + public static ProtectedPayloadCheck Invalid(string rule, int? line = null, int? position = null) => new ProtectedPayloadCheck(false, rule, line, position); + + /// "payload_invalid: rule=json_parse line=3 position=14" — position and rule only. + public string Describe() + { + if (Ok) + return null; + var text = $"{ProtectedWorkflowErrorCodes.PayloadInvalid}: rule={Rule}"; + if (Line.HasValue) + text += $" line={Line.Value}"; + if (Position.HasValue) + text += $" position={Position.Value}"; + return text; + } + } + + /// + /// Pre-send validation of a rendered protected payload for its declared content type. A payload that fails never + /// leaves: malformed JSON, a FHIR body without a resourceType, XML that is not well formed (DTDs are refused), or an + /// HL7 v2 message whose header or segment ids are wrong. Parser messages are never surfaced — they can quote input. + /// + public static class ProtectedPayloadValidator + { + public const string RuleJsonParse = "json_parse"; + public const string RuleFhirResourceType = "fhir_resource_type"; + public const string RuleXmlWellFormed = "xml_well_formed"; + public const string RuleHl7Header = "hl7_header"; + public const string RuleHl7Segment = "hl7_segment"; + + public const string Hl7MediaType = "x-application/hl7-v2+er7"; + public const string Hl7Header = "MSH|^~\\&"; + + private static readonly Regex SegmentId = new Regex("^[A-Z0-9]{3}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// The body as it will be sent. HL7 v2 segments are separated by a carriage return: a template written in a + /// browser uses CRLF or LF between lines, so line breaks become CR and a trailing break is dropped. Line breaks + /// inside values cannot reach here as raw characters when the values went through hl7_escape. + /// + public static string NormalizeBody(string mediaType, string body) + { + if (body == null || !string.Equals(mediaType, Hl7MediaType, StringComparison.Ordinal)) + return body; + + var normalized = body.Replace("\r\n", "\r").Replace('\n', '\r'); + return normalized.TrimEnd('\r'); + } + + public static ProtectedPayloadCheck Validate(string mediaType, string body) + { + body ??= string.Empty; + switch (mediaType) + { + case "application/json": + return ValidateJson(body, requireResourceType: false); + case "application/fhir+json": + return ValidateJson(body, requireResourceType: true); + case "application/xml": + case "text/xml": + case "application/soap+xml": + return ValidateXml(body); + case Hl7MediaType: + return ValidateHl7(body); + case "text/plain": + return ProtectedPayloadCheck.Valid; + default: + return ProtectedPayloadCheck.Invalid(ProtectedStepOptions.ContentTypeNotAllowed); + } + } + + private static ProtectedPayloadCheck ValidateJson(string body, bool requireResourceType) + { + JToken token; + try + { + using var reader = new JsonTextReader(new StringReader(body)) { DateParseHandling = DateParseHandling.None, FloatParseHandling = FloatParseHandling.Decimal }; + token = JToken.ReadFrom(reader); + // Anything after the first value (a second object, stray text) is malformed. + while (reader.Read()) + { + if (reader.TokenType != JsonToken.Comment) + return ProtectedPayloadCheck.Invalid(RuleJsonParse, reader.LineNumber, reader.LinePosition); + } + } + catch (JsonReaderException ex) + { + return ProtectedPayloadCheck.Invalid(RuleJsonParse, ex.LineNumber, ex.LinePosition); + } + catch (JsonException) + { + return ProtectedPayloadCheck.Invalid(RuleJsonParse); + } + + if (requireResourceType) + { + var resourceType = (token as JObject)?.GetValue("resourceType", StringComparison.Ordinal); + if (resourceType == null || resourceType.Type != JTokenType.String || string.IsNullOrWhiteSpace((string)resourceType)) + return ProtectedPayloadCheck.Invalid(RuleFhirResourceType); + } + + return ProtectedPayloadCheck.Valid; + } + + private static ProtectedPayloadCheck ValidateXml(string body) + { + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + CheckCharacters = true, + ConformanceLevel = ConformanceLevel.Document, + MaxCharactersFromEntities = 0 + }; + + try + { + using var reader = XmlReader.Create(new StringReader(body), settings); + while (reader.Read()) + { + } + } + catch (XmlException ex) + { + return ProtectedPayloadCheck.Invalid(RuleXmlWellFormed, ex.LineNumber, ex.LinePosition); + } + + return ProtectedPayloadCheck.Valid; + } + + private static ProtectedPayloadCheck ValidateHl7(string body) + { + if (!body.StartsWith(Hl7Header, StringComparison.Ordinal)) + return ProtectedPayloadCheck.Invalid(RuleHl7Header, 1, 1); + + var segments = body.Split('\r'); + for (var i = 0; i < segments.Length; i++) + { + var segment = segments[i]; + // A trailing CR leaves one empty tail; an empty segment anywhere else is malformed. + if (segment.Length == 0 && i == segments.Length - 1 && i > 0) + continue; + + if (segment.Length < 3 || !SegmentId.IsMatch(segment.Substring(0, 3)) || (segment.Length > 3 && segment[3] != '|')) + return ProtectedPayloadCheck.Invalid(RuleHl7Segment, i + 1, 1); + } + + return ProtectedPayloadCheck.Valid; + } + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedReleaseState.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedReleaseState.cs new file mode 100644 index 000000000..c3162fb49 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedReleaseState.cs @@ -0,0 +1,31 @@ +namespace Resgrid.Model +{ + /// + /// Lifecycle of a . Only Active opens protected values to a workflow; a + /// workflow whose current release is in any other state has its run skipped (never run unprotected, because a + /// destination that expects plaintext would otherwise be overwritten with REDACTED). + /// + /// Draft -> (request) -> PendingApproval -> (second approver) -> Active + /// Draft -> (request, single approver) -> Active + /// Active -> config change -> PendingApproval (config_changed) -> (re-request) -> ... + /// Active -> Suspended (credential_changed, department_disabled, admin_suspended, adp_not_enabled) + /// Active -> Expired (ExpiresOn passed) -> (renew) -> ... + /// any -> Revoked (terminal; a new release row may be created afterwards) + /// + public enum ProtectedReleaseState + { + Draft = 0, + PendingApproval = 1, + Active = 2, + Suspended = 3, + Expired = 4, + Revoked = 5 + } + + /// Who receives the released values, as attested by the requesting administrator. + public enum ProtectedReleaseRecipientType + { + CoveredEntity = 1, + BusinessAssociate = 2 + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedStepOptions.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedStepOptions.cs new file mode 100644 index 000000000..91049fbc7 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedStepOptions.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Model +{ + /// How a protected step decides the destination accepted the payload (action config "SuccessRule"). + public sealed class ProtectedSuccessRule + { + public const string Http2xx = "http_2xx"; + public const string JsonPath = "json_path"; + public const string XPath = "xpath"; + public const string Hl7Ack = "hl7_ack"; + public const string FhirOperationOutcome = "fhir_operation_outcome"; + + public static readonly string[] Types = { Http2xx, JsonPath, XPath, Hl7Ack, FhirOperationOutcome }; + + /// One of . + public string Type { get; set; } + + /// json_path / xpath: the expression to read from the response ($.status, //*[local-name()='status']). + public string Path { get; set; } + + /// json_path / xpath: the value it must equal (ordinal). Null means "present and not empty". + public string Expected { get; set; } + + /// True when the response body has to be read to decide (everything but http_2xx). + public bool NeedsBody => !string.Equals(Type, Http2xx, StringComparison.Ordinal); + } + + /// One value a protected step copies from the response into the call's subject identifiers (action config "ResponseCapture"). + public sealed class ProtectedCaptureEntry + { + public const string JsonPath = "json_path"; + public const string XPath = "xpath"; + public const string Hl7Field = "hl7_field"; + public const string Header = "header"; + public const string FhirLocationId = "fhir_location_id"; + + public static readonly string[] Sources = { JsonPath, XPath, Hl7Field, Header, FhirLocationId }; + + /// One of . + public string Source { get; set; } + + /// + /// json_path: $.id; xpath: //*[local-name()='id']/@value; hl7_field: MSA-2 or PID-3.1; + /// header: Location; fhir_location_id: an optional resource type (Encounter) to pick from a Bundle. + /// + public string Expression { get; set; } + + /// The subject identifier key the value is written under (ehr_encounter_id). + public string Key { get; set; } + } + + /// + /// The EHR-integration options of a protected HTTP step, read from its action config: content type, success rule, + /// response capture and idempotency. All of them live in the action config, so every one is part of the release + /// fingerprint; changing any of them sends the release back for approval. + /// + public sealed class ProtectedStepOptions + { + public const string DefaultContentType = "application/json"; + + // Validation codes (ValidationError_{code} in the UI). + public const string ContentTypeNotAllowed = "content_type_not_allowed"; + public const string SuccessRuleInvalid = "success_rule_invalid"; + public const string CaptureInvalid = "capture_invalid"; + public const string CaptureTooMany = "capture_too_many"; + public const string IdempotencyHeaderInvalid = "idempotency_header_invalid"; + public const string IfNoneExistInvalid = "if_none_exist_invalid"; + + /// The only content types a protected step may declare. + public static readonly string[] AllowedContentTypes = + { + "application/json", "application/fhir+json", "application/xml", "text/xml", "application/soap+xml", "x-application/hl7-v2+er7", "text/plain" + }; + + /// Headers a protected step may never set itself; they carry authentication or framing. + public static readonly HashSet ForbiddenHeaders = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Host", "Authorization", "Proxy-Authorization", "Content-Length", "Transfer-Encoding", "Connection", "Cookie", "Content-Type", "If-None-Exist" + }; + + public static readonly Regex SubjectKeyPattern = new Regex("^[a-z0-9_]{1,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex HeaderToken = new Regex("^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex Hl7FieldReference = new Regex(@"^[A-Z0-9]{3}-\d{1,3}(\.\d{1,3}){0,2}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// The declared content type exactly as configured (null when absent). + public string ContentType { get; set; } + + /// The media type, lower-cased and without parameters; application/json when none is configured. + public string MediaType => NormalizeMediaType(ContentType); + + /// Null means http_2xx. + public ProtectedSuccessRule SuccessRule { get; set; } + + public List ResponseCapture { get; set; } = new List(); + + /// Header that carries run.idempotency_key (Idempotency-Key), or null. + public string IdempotencyHeader { get; set; } + + /// The FHIR conditional-create query sent as If-None-Exist (it may use ordinary template values), or null. + public string IfNoneExist { get; set; } + + public string SuccessRuleType => SuccessRule?.Type ?? ProtectedSuccessRule.Http2xx; + + /// True when the response body must be read (a rule that inspects it, or a capture). + public bool NeedsResponseBody => (SuccessRule?.NeedsBody ?? false) || ResponseCapture.Any(c => c.Source != ProtectedCaptureEntry.Header); + + public static string NormalizeMediaType(string contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + return DefaultContentType; + var semicolon = contentType.IndexOf(';'); + return (semicolon >= 0 ? contentType.Substring(0, semicolon) : contentType).Trim().ToLowerInvariant(); + } + + public static bool IsAllowedContentType(string contentType) => + AllowedContentTypes.Contains(NormalizeMediaType(contentType), StringComparer.Ordinal) && + (contentType == null || (contentType.IndexOf("{{", StringComparison.Ordinal) < 0 && contentType.IndexOfAny(new[] { '\r', '\n' }) < 0)); + + /// + /// Reads the options from an action config (keys are case-insensitive). Malformed JSON reads as no options; + /// the structural validator reports it separately. lists every problem found. + /// + public static ProtectedStepOptions Read(string actionConfigJson, out List errors, int maxCaptureEntries = 5) + { + errors = new List(); + var options = new ProtectedStepOptions(); + if (string.IsNullOrWhiteSpace(actionConfigJson)) + return options; + + JObject config; + try + { + using var reader = new JsonTextReader(new System.IO.StringReader(actionConfigJson)) { DateParseHandling = DateParseHandling.None }; + config = JToken.ReadFrom(reader) as JObject; + } + catch (JsonException) + { + return options; + } + + if (config == null) + return options; + + options.ContentType = String(config, "ContentType"); + if (options.ContentType != null && !IsAllowedContentType(options.ContentType)) + errors.Add(ContentTypeNotAllowed); + + var rule = config.GetValue("SuccessRule", StringComparison.OrdinalIgnoreCase); + if (rule != null && rule.Type != JTokenType.Null) + { + if (rule is JObject ruleObject) + { + options.SuccessRule = new ProtectedSuccessRule + { + Type = String(ruleObject, "Type")?.ToLowerInvariant(), + Path = String(ruleObject, "Path"), + Expected = String(ruleObject, "Expected") + }; + } + else if (rule.Type == JTokenType.String) + { + options.SuccessRule = new ProtectedSuccessRule { Type = ((string)rule)?.Trim().ToLowerInvariant() }; + } + + var parsed = options.SuccessRule; + if (parsed == null || !ProtectedSuccessRule.Types.Contains(parsed.Type, StringComparer.Ordinal) || + ((parsed.Type == ProtectedSuccessRule.JsonPath || parsed.Type == ProtectedSuccessRule.XPath) && string.IsNullOrWhiteSpace(parsed.Path)) || + HasTemplate(parsed.Path) || HasTemplate(parsed.Expected)) + errors.Add(SuccessRuleInvalid); + } + + var capture = config.GetValue("ResponseCapture", StringComparison.OrdinalIgnoreCase); + if (capture != null && capture.Type != JTokenType.Null) + { + if (capture is JArray entries) + { + foreach (var entry in entries) + { + if (entry is not JObject entryObject) + { + errors.Add(CaptureInvalid); + continue; + } + + var parsed = new ProtectedCaptureEntry + { + Source = String(entryObject, "Source")?.ToLowerInvariant(), + Expression = String(entryObject, "Expression"), + Key = String(entryObject, "Key") + }; + options.ResponseCapture.Add(parsed); + if (!IsValidCapture(parsed)) + errors.Add(CaptureInvalid); + } + + if (options.ResponseCapture.Count > Math.Max(0, maxCaptureEntries)) + errors.Add(CaptureTooMany); + if (options.ResponseCapture.Select(c => c.Key).Distinct(StringComparer.Ordinal).Count() != options.ResponseCapture.Count) + errors.Add(CaptureInvalid); + } + else + { + errors.Add(CaptureInvalid); + } + } + + options.IdempotencyHeader = String(config, "IdempotencyHeader"); + if (options.IdempotencyHeader != null && + (!HeaderToken.IsMatch(options.IdempotencyHeader) || ForbiddenHeaders.Contains(options.IdempotencyHeader))) + errors.Add(IdempotencyHeaderInvalid); + + options.IfNoneExist = String(config, "IfNoneExist"); + if (options.IfNoneExist != null && (options.IfNoneExist.Length > 1024 || options.IfNoneExist.IndexOfAny(new[] { '\r', '\n' }) >= 0)) + errors.Add(IfNoneExistInvalid); + + errors = errors.Distinct(StringComparer.Ordinal).ToList(); + return options; + } + + /// The header names the action config's Headers object sets (for the forbidden-header check). + public static IEnumerable ReadHeaderNames(string actionConfigJson) + { + if (string.IsNullOrWhiteSpace(actionConfigJson)) + yield break; + + JObject headers; + try + { + headers = JObject.Parse(actionConfigJson).GetValue("Headers", StringComparison.OrdinalIgnoreCase) as JObject; + } + catch (JsonException) + { + yield break; + } + + if (headers == null) + yield break; + foreach (var property in headers.Properties()) + yield return property.Name; + } + + private static bool IsValidCapture(ProtectedCaptureEntry entry) + { + if (entry.Key == null || !SubjectKeyPattern.IsMatch(entry.Key)) + return false; + if (!ProtectedCaptureEntry.Sources.Contains(entry.Source, StringComparer.Ordinal) || HasTemplate(entry.Expression)) + return false; + + return entry.Source switch + { + ProtectedCaptureEntry.FhirLocationId => entry.Expression == null || Regex.IsMatch(entry.Expression, "^[A-Za-z]{1,64}$"), + ProtectedCaptureEntry.Hl7Field => entry.Expression != null && Hl7FieldReference.IsMatch(entry.Expression), + ProtectedCaptureEntry.Header => entry.Expression != null && HeaderToken.IsMatch(entry.Expression), + _ => !string.IsNullOrWhiteSpace(entry.Expression) && entry.Expression.Length <= 512 + }; + } + + private static bool HasTemplate(string value) => value != null && (value.Contains("{{") || value.Contains("}}")); + + private static string String(JObject json, string name) + { + var token = json.GetValue(name, StringComparison.OrdinalIgnoreCase); + if (token == null || token.Type == JTokenType.Null) + return null; + var text = token.Type == JTokenType.String ? (string)token : token.ToString(Formatting.None); + return string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + } + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowConstants.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowConstants.cs new file mode 100644 index 000000000..fc46ddeeb --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowConstants.cs @@ -0,0 +1,224 @@ +using System; + +namespace Resgrid.Model +{ + /// The broker workload purpose a Protected Workflow decrypt runs under, and the current warning text versions. + public static class ProtectedWorkflowDefaults + { + /// Broker workload purpose (must be on DataProtectionConfig.BrokerWorkloadPurposes). + public const string WorkloadPurpose = "protected-workflow"; + + /// + /// Version key of the warning text an administrator acknowledges when enabling Protected Workflows and when + /// attesting a release. Bump it (and the WarningText resource) whenever the wording changes; an + /// acknowledgement of an older version no longer satisfies a new request. + /// + public const string WarningTextVersion = "PW-WARN-1"; + + /// Version of the extra attestation for releasing fields tagged restricted (text: RestrictedAttestationText). + public const string RestrictedAttestationVersion = "PW-RESTRICTED-1"; + + /// Version of the 42 CFR Part 2 redisclosure attestation for fields tagged part2 (text: Part2AttestationText). + public const string Part2AttestationVersion = "PW-PART2-1"; + + /// Entity type recorded on disclosures for call-triggered releases. + public const string CallEntityType = "call"; + } + + /// Why a release left Active (WorkflowProtectedRelease.SuspendedReason). Value-free, stable, never localized. + public static class ProtectedWorkflowSuspendReasons + { + public const string ConfigChanged = "config_changed"; + public const string DepartmentDisabled = "department_disabled"; + public const string AdpNotEnabled = "adp_not_enabled"; + public const string CredentialChanged = "credential_changed"; + public const string AdminSuspended = "admin_suspended"; + + // Revocation reasons share the column; a Revoked release is terminal. + public const string AdminRevoked = "admin_revoked"; + public const string AdpOffboarding = "adp_offboarding"; + public const string WorkflowDeleted = "workflow_deleted"; + } + + /// Outcome of one protected send attempt (ProtectedWorkflowDisclosure.Outcome). + public static class ProtectedWorkflowDisclosureOutcomes + { + public const string Sent = "sent"; + public const string FailedHttp = "failed_http"; + public const string FailedBroker = "failed_broker"; + public const string BlockedHost = "blocked_host"; + public const string BlockedRelease = "blocked_release"; + public const string BlockedDepartment = "blocked_department"; + + /// The rendered payload still carried a ciphertext envelope (ProtectedOutboundGuard); nothing was sent. + public const string BlockedGuard = "blocked_guard"; + + /// + /// Written BEFORE a request leaves, with the payload hash and fields. If it cannot be written the request is not + /// sent, so a disclosure can never happen without a chain record; the outcome follows as a second record. + /// + public const string Attempted = "attempted"; + + /// The payload could not be rendered (template error, oversize); nothing was sent. + public const string FailedRender = "failed_render"; + + /// The rendered payload is not valid for its content type (JSON, FHIR, XML, HL7 v2); nothing was sent. + public const string FailedValidation = "failed_validation"; + + /// A released value could not be projected (malformed subject identifiers); nothing was sent. + public const string FailedProjection = "failed_projection"; + + /// The request was sent but the step's success rule rejected the response (HL7 AE/AR, OperationOutcome error, ...). + public const string FailedAck = "failed_ack"; + + /// The request was sent but the response was over the size cap, so its rule and capture could not run. + public const string FailedResponseTooLarge = "failed_response_too_large"; + + /// A 42 CFR Part 2 field was released but the call has no Part 2 consent on file; nothing was sent. + public const string BlockedConsent = "blocked_consent"; + } + + /// + /// Which failed protected attempts the workflow retry policy may repeat. A transport failure, a timeout, a 5xx or a + /// 429 is worth another attempt. Nothing else is: a refused request, any other 4xx, a rejected acknowledgement on a + /// 2xx (an HL7 AE or AR included: it is a data problem another attempt would only send again), an oversized + /// response, an invalid payload and a missing consent would all fail the same way. + /// + public static class ProtectedWorkflowRetryPolicy + { + public static bool IsRetryable(string outcome, int? httpStatus, string errorCode = null) + { + switch (outcome) + { + case ProtectedWorkflowDisclosureOutcomes.FailedHttp: + return !httpStatus.HasValue || httpStatus.Value >= 500 || httpStatus.Value == 429; + case ProtectedWorkflowDisclosureOutcomes.FailedAck: + return httpStatus.HasValue && (httpStatus.Value >= 500 || httpStatus.Value == 429); + case ProtectedWorkflowDisclosureOutcomes.FailedBroker: + return errorCode != null && errorCode.StartsWith(ProtectedWorkflowErrorCodes.BrokerFailed, StringComparison.Ordinal); + default: + return errorCode == ProtectedWorkflowErrorCodes.DisclosureUnavailable || errorCode == ProtectedWorkflowErrorCodes.StepError; + } + } + } + + /// run.idempotency_key: one value per logical delivery of one step, the same on every retry of it. + public static class WorkflowIdempotency + { + /// sha256(workflowId:eventId:stepId), lowercase hex, first 32 characters. Without an event id the run id stands in (retries reuse the run). + public static string Key(string workflowId, string eventId, string workflowRunId, string workflowStepId) + { + var material = $"{workflowId}:{(string.IsNullOrWhiteSpace(eventId) ? workflowRunId : eventId)}:{workflowStepId}"; + var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(material)); + return Convert.ToHexString(hash).ToLowerInvariant().Substring(0, 32); + } + } + + /// Record types in the per-department disclosure hash chain. + public static class ProtectedWorkflowRecordTypes + { + public const string Disclosure = "disclosure"; + public const string AdminEvent = "admin_event"; + } + + /// Administrative events written to the chain (ProtectedWorkflowAdminEvent records). They carry the actor, never field values. + public static class ProtectedWorkflowAdminEventTypes + { + public const string DepartmentEnabled = "department_enabled"; + public const string DepartmentDisabled = "department_disabled"; + public const string ReleaseRequested = "release_requested"; + public const string ReleaseApproved = "release_approved"; + public const string ReleaseSuspended = "release_suspended"; + public const string ReleaseRevoked = "release_revoked"; + public const string ReleaseExpired = "release_expired"; + public const string CredentialRotated = "credential_rotated"; + public const string SecondApproverRelaxRequested = "second_approver_relax_requested"; + public const string SecondApproverRelaxed = "second_approver_relaxed"; + } + + /// + /// Fixed, value-free error codes written to a protected step's run log and returned by the admin commands. + /// Never extend one with a message that could carry request content. + /// + public static class ProtectedWorkflowErrorCodes + { + public const string AdpNotEnabled = "adp_not_enabled"; + public const string DepartmentDisabled = "protected_workflows_disabled"; + public const string ReleaseNotActive = "release_not_active"; + public const string ReleaseExpired = "release_expired"; + public const string ConfigChanged = "config_changed"; + public const string ActionNotAllowed = "action_not_allowed"; + public const string CredentialNotAllowed = "credential_not_allowed"; + public const string HostMismatch = "host_mismatch"; + public const string SchemeNotHttps = "scheme_not_https"; + public const string Redirected = "redirect_refused"; + public const string TokenHostMismatch = "token_host_mismatch"; + public const string BrokerFailed = "broker_failed"; + public const string EntityUnavailable = "entity_unavailable"; + public const string RenderFailed = "render_failed"; + public const string PayloadTooLarge = "payload_too_large"; + public const string EnvelopeInPayload = "envelope_in_payload"; + public const string HttpFailed = "http_failed"; + public const string HttpTimeout = "http_timeout"; + public const string StepError = "protected_step_error"; + + // Admin command outcomes + public const string PermissionDenied = "protected_access_denied"; + public const string StepUpRequired = "step_up_required"; + public const string InteractiveRequired = "interactive_session_required"; + public const string AttestationRequired = "attestation_required"; + public const string AckVersionMismatch = "ack_version_mismatch"; + public const string SelfApproval = "self_approval_not_allowed"; + public const string InvalidState = "invalid_state"; + public const string ValidationFailed = "validation_failed"; + public const string NotFound = "not_found"; + public const string TriggerNotSupported = "trigger_not_supported"; + public const string TooManyFields = "too_many_fields"; + public const string NoFields = "no_fields"; + public const string UnknownField = "unknown_field"; + public const string RecipientRequired = "recipient_required"; + public const string PurposeRequired = "purpose_required"; + public const string ConcurrentChange = "concurrent_change"; + public const string DisclosureUnavailable = "disclosure_unavailable"; + + // EHR integration (addendum) + public const string PayloadInvalid = "payload_invalid"; + public const string ProjectionFailed = "projection_failed"; + public const string AckRejected = "ack_rejected"; + public const string ResponseTooLarge = "response_too_large"; + public const string ConsentMissing = "part2_consent_missing"; + public const string CaptureFailed = "capture_failed"; + public const string OAuthTokenFailed = "oauth_token_failed"; + public const string AuthMethodMismatch = "auth_method_mismatch"; + public const string SigningKeyUnavailable = "signing_key_unavailable"; + public const string FieldConflict = "field_conflict"; + public const string RestrictedAttestationRequired = "restricted_attestation_required"; + public const string Part2AttestationRequired = "part2_attestation_required"; + public const string CaptureRequiresSubjectIdentifiers = "capture_requires_subject_identifiers"; + } + + /// + /// Builds the value-free lines a protected step writes to its run log. Everything here is an identifier, a code, + /// a count or a hash — the rendered payload and the response body never reach WorkflowRunLog or Logging. + /// + public static class ProtectedWorkflowLogText + { + public const int MaxErrorLength = 500; + + public static string RenderedOutputSummary(string sha256, int bytes, System.Collections.Generic.IEnumerable fieldIds) => + $"[protected payload] sha256={sha256} bytes={bytes} fields={string.Join(",", fieldIds ?? Array.Empty())}"; + + /// A fixed code plus the exception type; the message is scrubbed and capped when one is included at all. + public static string Error(string code, Exception exception = null, bool includeMessage = false) + { + if (exception == null) + return code; + + var text = $"{code}: {exception.GetType().FullName}"; + if (includeMessage && !string.IsNullOrWhiteSpace(exception.Message)) + text += " - " + ProtectedOutboundGuard.Scrub(exception.Message, out _); + + return text.Length > MaxErrorLength ? text.Substring(0, MaxErrorLength) : text; + } + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowDisclosure.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowDisclosure.cs new file mode 100644 index 000000000..80462c01b --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowDisclosure.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// One append-only, value-free record in a department's Protected Workflow hash chain. Two record types share + /// the chain (): a disclosure (one per protected send attempt, whatever + /// its outcome) and an administrative event (toggle, request, approval, suspension, revocation, expiry, + /// credential rotation). Hash = SHA256(PrevHash + canonical row); altering, removing or reordering any row + /// breaks verification from that row on (ProtectedWorkflowDisclosureChain.Verify). + /// + /// Never carries a field value, a rendered payload or a response body: only identifiers, catalog field ids, + /// the destination host, the SHA-256 and length of the exact bytes sent, the HTTP status and an outcome code. + /// Retained with the department's audit data (like AuditLogs, not deleted with the department). + /// + [Table("ProtectedWorkflowDisclosures")] + public class ProtectedWorkflowDisclosure : IEntity + { + [Key] + [MaxLength(128)] + public string ProtectedWorkflowDisclosureId { get; set; } + + public int DepartmentId { get; set; } + + /// Per-department chain position, starting at 1 (unique with DepartmentId). + public long ChainSequence { get; set; } + + /// ProtectedWorkflowRecordTypes value. + [MaxLength(32)] + public string RecordType { get; set; } + + /// ProtectedWorkflowAdminEventTypes value for admin events; null for disclosures. + [MaxLength(64)] + public string EventType { get; set; } + + /// Administrator who acted (admin events); null for disclosures, which run unattended. + [MaxLength(128)] + public string ActorUserId { get; set; } + + [MaxLength(128)] + public string WorkflowId { get; set; } + + [MaxLength(128)] + public string WorkflowRunId { get; set; } + + [MaxLength(128)] + public string WorkflowStepId { get; set; } + + [MaxLength(128)] + public string WorkflowProtectedReleaseId { get; set; } + + /// "call" for call-triggered releases. + [MaxLength(32)] + public string EntityType { get; set; } + + [MaxLength(64)] + public string EntityId { get; set; } + + /// JSON array of the catalog field ids actually decrypted and rendered. + public string FieldIds { get; set; } + + [MaxLength(255)] + public string DestinationHost { get; set; } + + /// SHA-256 (lowercase hex) of the exact request body bytes sent; null when nothing was sent. + [MaxLength(64)] + public string PayloadSha256 { get; set; } + + public int? PayloadBytes { get; set; } + + /// HTTP status; null when the request never completed. + public int? HttpStatus { get; set; } + + /// ProtectedWorkflowDisclosureOutcomes value (disclosures only). + [MaxLength(32)] + public string Outcome { get; set; } + + /// True for "Send test with sample data" — synthetic values, nothing decrypted. + public bool IsTest { get; set; } + + /// The request id sent to the broker, linking this row to the broker's value-free audit line. + [MaxLength(64)] + public string BrokerRequestId { get; set; } + + /// Value-free detail: an error or reason code. + [MaxLength(500)] + public string Detail { get; set; } + + /// The media type the payload was declared as (application/fhir+json, x-application/hl7-v2+er7, ...). + [MaxLength(64)] + public string ContentType { get; set; } + + /// JSON array of the subject identifier KEYS written from the response (never the values). + [MaxLength(1000)] + public string CapturedKeys { get; set; } + + /// UTC, truncated to the millisecond so the hash survives every database's datetime precision. + public DateTime OccurredOn { get; set; } + + [MaxLength(64)] + public string PrevHash { get; set; } + + [MaxLength(64)] + public string Hash { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get => ProtectedWorkflowDisclosureId; + set => ProtectedWorkflowDisclosureId = (string)value; + } + + [NotMapped] public string TableName => "ProtectedWorkflowDisclosures"; + [NotMapped] public string IdName => "ProtectedWorkflowDisclosureId"; + [NotMapped] public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => + new[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowDisclosureChain.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowDisclosureChain.cs new file mode 100644 index 000000000..e163b04d1 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowDisclosureChain.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Model +{ + /// + /// The per-department hash chain over rows. Pure functions, so the + /// repository, the verifier and the tests all compute the exact same bytes. + /// + /// Hash = lowercase hex SHA-256 over UTF-8 (PrevHash + canonical row). The canonical row is compact JSON with a + /// FIXED property order covering every column except Hash itself; PrevHash of the first row is + /// . OccurredOn is formatted to the millisecond with no zone conversion, because the + /// value read back from the database has an unspecified Kind and PostgreSQL keeps only microseconds. + /// + public static class ProtectedWorkflowDisclosureChain + { + public const string GenesisHash = "0000000000000000000000000000000000000000000000000000000000000000"; + + /// Truncates to the millisecond (the precision the hash is computed at). + public static DateTime NormalizeTimestamp(DateTime value) => + new DateTime(value.Ticks - value.Ticks % TimeSpan.TicksPerMillisecond, DateTimeKind.Utc); + + public static string CanonicalRow(ProtectedWorkflowDisclosure row) + { + if (row == null) + throw new ArgumentNullException(nameof(row)); + + var json = new JObject + { + ["id"] = row.ProtectedWorkflowDisclosureId, + ["departmentId"] = row.DepartmentId, + ["sequence"] = row.ChainSequence, + ["recordType"] = row.RecordType, + ["eventType"] = row.EventType, + ["actorUserId"] = row.ActorUserId, + ["workflowId"] = row.WorkflowId, + ["workflowRunId"] = row.WorkflowRunId, + ["workflowStepId"] = row.WorkflowStepId, + ["releaseId"] = row.WorkflowProtectedReleaseId, + ["entityType"] = row.EntityType, + ["entityId"] = row.EntityId, + ["fieldIds"] = row.FieldIds, + ["destinationHost"] = row.DestinationHost, + ["payloadSha256"] = row.PayloadSha256, + ["payloadBytes"] = row.PayloadBytes, + ["httpStatus"] = row.HttpStatus, + ["outcome"] = row.Outcome, + ["isTest"] = row.IsTest, + ["brokerRequestId"] = row.BrokerRequestId, + ["detail"] = row.Detail, + ["contentType"] = row.ContentType, + ["capturedKeys"] = row.CapturedKeys, + ["occurredOn"] = FormatTimestamp(row.OccurredOn) + }; + + return json.ToString(Formatting.None); + } + + public static string ComputeHash(string prevHash, ProtectedWorkflowDisclosure row) + { + var input = (prevHash ?? string.Empty) + CanonicalRow(row); + return ToHex(SHA256.HashData(Encoding.UTF8.GetBytes(input))); + } + + /// Stamps Sequence, PrevHash and Hash onto a new row that follows (null for the first row). + public static void Link(ProtectedWorkflowDisclosure row, ProtectedWorkflowDisclosure previous) + { + row.OccurredOn = NormalizeTimestamp(row.OccurredOn == default ? DateTime.UtcNow : row.OccurredOn); + row.ChainSequence = (previous?.ChainSequence ?? 0) + 1; + row.PrevHash = previous?.Hash ?? GenesisHash; + row.Hash = ComputeHash(row.PrevHash, row); + } + + /// + /// Verifies a department's chain in sequence order. Returns the first sequence number that fails (a changed + /// row, a broken link, a gap or a duplicate), or null when the whole chain verifies. + /// + public static long? Verify(IEnumerable rows) + { + var expectedPrev = GenesisHash; + long expectedSequence = 1; + foreach (var row in (rows ?? Enumerable.Empty()).OrderBy(r => r.ChainSequence)) + { + if (row.ChainSequence != expectedSequence || + !string.Equals(row.PrevHash, expectedPrev, StringComparison.Ordinal) || + !string.Equals(row.Hash, ComputeHash(row.PrevHash, row), StringComparison.Ordinal)) + return row.ChainSequence; + + expectedPrev = row.Hash; + expectedSequence++; + } + + return null; + } + + public static string Sha256Hex(byte[] data) => ToHex(SHA256.HashData(data ?? Array.Empty())); + + private static string FormatTimestamp(DateTime value) => + new DateTime(value.Ticks - value.Ticks % TimeSpan.TicksPerMillisecond) + .ToString("yyyy-MM-dd'T'HH:mm:ss.fff", CultureInfo.InvariantCulture); + + private static string ToHex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowFieldCatalog.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowFieldCatalog.cs new file mode 100644 index 000000000..64a56e381 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowFieldCatalog.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + /// One protected catalog field a release can allow-list, with the snake_case name it renders under. + public sealed class ProtectedWorkflowField + { + public ProtectedWorkflowField(string fieldId, string templateName, Func getCallValue) + { + FieldId = fieldId; + TemplateName = templateName; + GetCallValue = getCallValue; + } + + /// Catalog field id ("calls.completednotes"). + public string FieldId { get; } + + /// Name under protected.call — the same snake_case name call.* uses ("completed_notes"). + public string TemplateName { get; } + + /// Localization key for the checkbox label. + public string LabelKey => "Field_" + FieldId.Replace('.', '_'); + + public Func GetCallValue { get; } + } + + /// + /// Which triggers a Protected Workflow release supports and which protected fields each can release. v1 ships + /// call triggers only; the shape (trigger -> entity type -> field list) is what a later trigger family plugs into. + /// The call field ids mirror the ADP call catalog (ProtectedReadService.CallFieldAccessors — pinned by a parity test). + /// + public static class ProtectedWorkflowFieldCatalog + { + /// Template namespace root for released values: protected.call.completed_notes. + public const string NamespaceRoot = "protected"; + + /// protected.call.form: the parsed form data object, available when calls.callformdata is released. + public const string ParsedFormName = "form"; + + public const string FormDataFieldId = "calls.callformdata"; + + /// External subject and record identifiers (a JSON object). Released whole or per key; renders as protected.call.subject_ids. + public const string SubjectIdentifiersFieldId = "calls.subjectidentifiers"; + + public const string SubjectIdentifiersTemplateName = "subject_ids"; + + /// A release id for one call custom field: calls.udf#<field name> (renders as protected.call.udf.<name>). + public const string UdfFieldPrefix = "calls.udf#"; + + public const string UdfTemplateName = "udf"; + + /// The ADP catalog field every custom field value is enveloped under (row key: the UdfFieldValueId). + public const string UdfValueCatalogFieldId = "udffieldvalues.value"; + + /// Separates a field id from a sub-field key: calls.subjectidentifiers#ehr_client_id. + public const char SubFieldSeparator = '#'; + + private static readonly System.Text.RegularExpressions.Regex UdfName = + new System.Text.RegularExpressions.Regex("^[a-z_][a-z0-9_]{0,99}$", System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + private static readonly IReadOnlyList CallFields = new List + { + new ProtectedWorkflowField("calls.name", "name", c => c.Name), + new ProtectedWorkflowField("calls.type", "type", c => c.Type), + new ProtectedWorkflowField("calls.natureofcall", "nature", c => c.NatureOfCall), + new ProtectedWorkflowField("calls.notes", "notes", c => c.Notes), + new ProtectedWorkflowField("calls.completednotes", "completed_notes", c => c.CompletedNotes), + new ProtectedWorkflowField("calls.address", "address", c => c.Address), + new ProtectedWorkflowField("calls.geolocationdata", "geo_location", c => c.GeoLocationData), + new ProtectedWorkflowField("calls.w3w", "w3w", c => c.W3W), + new ProtectedWorkflowField("calls.contactname", "contact_name", c => c.ContactName), + new ProtectedWorkflowField("calls.contactnumber", "contact_number", c => c.ContactNumber), + new ProtectedWorkflowField("calls.sourceidentifier", "source_identifier", c => c.SourceIdentifier), + new ProtectedWorkflowField("calls.incidentnumber", "incident_number", c => c.IncidentNumber), + new ProtectedWorkflowField("calls.externalidentifier", "external_id", c => c.ExternalIdentifier), + new ProtectedWorkflowField("calls.referencenumber", "reference_number", c => c.ReferenceNumber), + new ProtectedWorkflowField(FormDataFieldId, "form_data", c => c.CallFormData), + new ProtectedWorkflowField("calls.deletedreason", "deleted_reason", c => c.DeletedReason), + new ProtectedWorkflowField(SubjectIdentifiersFieldId, SubjectIdentifiersTemplateName, c => c.SubjectIdentifiers) + }; + + public static bool IsSupportedTrigger(int triggerEventType) => + triggerEventType == (int)WorkflowTriggerEventType.CallAdded || + triggerEventType == (int)WorkflowTriggerEventType.CallUpdated || + triggerEventType == (int)WorkflowTriggerEventType.CallClosed; + + /// Disclosure entity type for the trigger ("call"), or null when unsupported. + public static string EntityTypeFor(int triggerEventType) => + IsSupportedTrigger(triggerEventType) ? ProtectedWorkflowDefaults.CallEntityType : null; + + /// The template sub-namespace for the trigger's entity ("call" -> protected.call). + public static string EntityNamespaceFor(int triggerEventType) => EntityTypeFor(triggerEventType); + + public static IReadOnlyList FieldsFor(int triggerEventType) => + IsSupportedTrigger(triggerEventType) ? CallFields : Array.Empty(); + + public static ProtectedWorkflowField Find(int triggerEventType, string fieldId) => + FieldsFor(triggerEventType).FirstOrDefault(f => string.Equals(f.FieldId, fieldId, StringComparison.OrdinalIgnoreCase)); + + /// Every call field id, for parity checks against the ADP catalog. + public static IReadOnlyList CallFieldIds => CallFields.Select(f => f.FieldId).ToList(); + + /// The release id of one call custom field (lower case, as stored on the release). + public static string UdfFieldId(string udfName) => UdfFieldPrefix + (udfName ?? string.Empty).Trim().ToLowerInvariant(); + + /// The release id of one subject identifier key. + public static string SubjectIdentifierFieldId(string key) => SubjectIdentifiersFieldId + SubFieldSeparator + (key ?? string.Empty).Trim().ToLowerInvariant(); + + /// The custom field name of a calls.udf#name id, or null. + public static string ParseUdfName(string fieldId) => + fieldId != null && fieldId.StartsWith(UdfFieldPrefix, StringComparison.OrdinalIgnoreCase) ? fieldId.Substring(UdfFieldPrefix.Length) : null; + + /// + /// Splits a release's allow-list into what the runtime reads: whole call columns, the subject identifiers (whole or + /// per key) and custom fields. Unknown or malformed ids, and a whole-field id listed with its own sub-field ids, are + /// reported rather than guessed at. + /// + public static ProtectedReleaseFieldPlan Plan(int triggerEventType, IEnumerable fieldIds) + { + var plan = new ProtectedReleaseFieldPlan(); + foreach (var id in WorkflowProtectedRelease.NormalizeFieldIds(fieldIds)) + { + var udf = ParseUdfName(id); + if (udf != null) + { + if (IsSupportedTrigger(triggerEventType) && UdfName.IsMatch(udf)) + plan.UdfNames.Add(udf); + else + plan.Unknown.Add(id); + continue; + } + + var separator = id.IndexOf(SubFieldSeparator); + if (separator > 0) + { + var baseId = id.Substring(0, separator); + var key = id.Substring(separator + 1); + if (baseId == SubjectIdentifiersFieldId && IsSupportedTrigger(triggerEventType) && ProtectedStepOptions.SubjectKeyPattern.IsMatch(key)) + plan.SubjectIdentifierKeys.Add(key); + else + plan.Unknown.Add(id); + continue; + } + + var field = Find(triggerEventType, id); + if (field == null) + plan.Unknown.Add(id); + else if (field.FieldId == SubjectIdentifiersFieldId) + plan.SubjectIdentifiersWhole = true; + else + plan.Columns.Add(field); + } + + return plan; + } + } + + /// What a release's allow-list asks the runtime to read (see ). + public sealed class ProtectedReleaseFieldPlan + { + /// Whole call columns (never the subject identifiers, which are projected separately). + public List Columns { get; } = new List(); + + public bool SubjectIdentifiersWhole { get; set; } + + public SortedSet SubjectIdentifierKeys { get; } = new SortedSet(StringComparer.Ordinal); + + /// Custom field names, lower case. + public List UdfNames { get; } = new List(); + + public List Unknown { get; } = new List(); + + /// The whole subject identifiers field and some of its keys are both listed: one or the other. + public bool HasConflict => SubjectIdentifiersWhole && SubjectIdentifierKeys.Count > 0; + + /// True when the subject identifiers envelope is decrypted (whole, or to project keys from it). + public bool ReadsSubjectIdentifiers => SubjectIdentifiersWhole || SubjectIdentifierKeys.Count > 0; + + public bool IsEmpty => Columns.Count == 0 && !ReadsSubjectIdentifiers && UdfNames.Count == 0; + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowFingerprint.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowFingerprint.cs new file mode 100644 index 000000000..ca91fefae --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowFingerprint.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Model +{ + /// + /// The configuration fingerprint a Protected Workflow approval binds to: SHA-256 (lowercase hex) over canonical + /// JSON of the trigger; every ENABLED step in execution order (action type, order, output template, condition, + /// the full action config with object keys sorted, and the credential id); the sorted allow-listed field ids; the + /// destination host; and the OAuth2 token host. Anything that changes what is sent, where, when or how changes + /// the fingerprint. The credential's SECRET is deliberately not an input — rotating it must not force re-approval + /// (the credential id is pinned, the secret is not). The step options (content type, success rule, capture, + /// idempotency) live in the action config and so are covered with it; + /// adds the credential's OAuth2 method, the restricted and Part 2 attestations and every released custom field's + /// sensitivity tag. + /// + public static class ProtectedWorkflowFingerprint + { + private const int Version = 2; + + public static string Compute(int triggerEventType, IEnumerable steps, IEnumerable allowedFieldIds, + string destinationHost, string tokenHost, ProtectedFingerprintExtras extras = null) + { + extras ??= new ProtectedFingerprintExtras(); + var stepArray = new JArray(); + foreach (var step in OrderedEnabledSteps(steps)) + { + stepArray.Add(new JObject + { + ["actionType"] = step.ActionType, + ["stepOrder"] = step.StepOrder, + ["outputTemplate"] = NormalizeText(step.OutputTemplate), + ["conditionExpression"] = NormalizeText(step.ConditionExpression), + ["actionConfig"] = CanonicalizeActionConfig(step.ActionConfig), + ["credentialId"] = string.IsNullOrWhiteSpace(step.WorkflowCredentialId) ? null : step.WorkflowCredentialId.Trim().ToLowerInvariant() + }); + } + + var canonical = new JObject + { + ["v"] = Version, + ["trigger"] = triggerEventType, + ["steps"] = stepArray, + ["fields"] = new JArray(WorkflowProtectedRelease.NormalizeFieldIds(allowedFieldIds).Cast().ToArray()), + ["destinationHost"] = NormalizeHost(destinationHost), + ["tokenHost"] = NormalizeHost(tokenHost), + ["authMethod"] = string.IsNullOrWhiteSpace(extras.AuthMethod) ? null : extras.AuthMethod.Trim().ToLowerInvariant(), + ["allowsRestricted"] = extras.AllowsRestricted, + ["allowsPart2"] = extras.AllowsPart2, + ["sensitivity"] = new JArray((extras.FieldSensitivities ?? new Dictionary()) + .OrderBy(p => p.Key, StringComparer.Ordinal) + .Select(p => (object)$"{p.Key.ToLowerInvariant()}={p.Value}").ToArray()) + }; + + var bytes = Encoding.UTF8.GetBytes(canonical.ToString(Formatting.None)); + return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + } + + /// Enabled steps in the order the run executes them (StepOrder, then id for a stable tie-break). + public static IEnumerable OrderedEnabledSteps(IEnumerable steps) => + (steps ?? Enumerable.Empty()) + .Where(s => s != null && s.IsEnabled) + .OrderBy(s => s.StepOrder) + .ThenBy(s => s.WorkflowStepId ?? string.Empty, StringComparer.Ordinal); + + public static string NormalizeHost(string host) => + string.IsNullOrWhiteSpace(host) ? null : host.Trim().TrimEnd('.').ToLowerInvariant(); + + private static string NormalizeText(string text) => + text?.Replace("\r\n", "\n").Replace("\r", "\n"); + + private static JToken CanonicalizeActionConfig(string actionConfig) + { + if (string.IsNullOrWhiteSpace(actionConfig)) + return JValue.CreateNull(); + + try + { + // No date or float coercion: a date-looking header value parsed to a local DateTime would make the + // fingerprint depend on the host's time zone, and web and worker hosts must agree byte for byte. + using var reader = new JsonTextReader(new System.IO.StringReader(actionConfig)) + { + DateParseHandling = DateParseHandling.None, + FloatParseHandling = FloatParseHandling.Decimal + }; + return Sort(JToken.ReadFrom(reader)); + } + catch (JsonException) + { + // Not JSON (a template that only becomes JSON when rendered): the raw text is the configuration. + return new JValue(NormalizeText(actionConfig)); + } + } + + private static JToken Sort(JToken token) + { + switch (token) + { + case JObject obj: + var sorted = new JObject(); + foreach (var property in obj.Properties().OrderBy(p => p.Name, StringComparer.Ordinal)) + sorted[property.Name] = Sort(property.Value); + return sorted; + case JArray array: + return new JArray(array.Select(Sort)); + default: + return token.DeepClone(); + } + } + } + + /// Fingerprint inputs beyond the steps, fields and hosts. + public sealed class ProtectedFingerprintExtras + { + /// The pinned credential's OAuth2 client authentication (client_secret / private_key_jwt), or null. + public string AuthMethod { get; set; } + + public bool AllowsRestricted { get; set; } + + public bool AllowsPart2 { get; set; } + + /// + /// Released custom field id (calls.udf#name) to its current sensitivity (UdfFieldSensitivity), or -1 when the field + /// no longer exists or is disabled. A retag or removal changes the fingerprint. + /// + public IReadOnlyDictionary FieldSensitivities { get; set; } + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowModels.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowModels.cs new file mode 100644 index 000000000..9ae2bdc60 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowModels.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Who is running an administrative Protected Workflow command. Approval-class commands (request, approve, + /// renew, department toggle) require and a no older + /// than DataProtectionConfig.ProtectedWorkflowStepUpFreshnessMinutes. API-key, client-credentials and worker + /// callers are never interactive. + /// + public sealed class ProtectedWorkflowActor + { + public string UserId { get; init; } + + /// An interactive user session (web cookie, or a v4 user token) — never a service account or API key. + public bool IsInteractive { get; init; } + + /// UTC instant of the actor's most recent verified second factor, if any. + public DateTime? StepUpVerifiedAtUtc { get; init; } + + public static ProtectedWorkflowActor System(string id) => new ProtectedWorkflowActor { UserId = id, IsInteractive = false }; + } + + /// Outcome of an administrative command. ErrorCode is a ProtectedWorkflowErrorCodes value. + public sealed class ProtectedWorkflowCommandResult + { + public bool Success { get; init; } + public string ErrorCode { get; init; } + public WorkflowProtectedRelease Release { get; init; } + public IReadOnlyList ValidationErrors { get; init; } = Array.Empty(); + + /// The command was recorded but waits for a different administrator (relaxing the two-person rule). + public bool PendingConfirmation { get; init; } + + public static ProtectedWorkflowCommandResult Ok(WorkflowProtectedRelease release = null) => + new ProtectedWorkflowCommandResult { Success = true, Release = release }; + + public static ProtectedWorkflowCommandResult Fail(string errorCode, IReadOnlyList errors = null) => + new ProtectedWorkflowCommandResult { Success = false, ErrorCode = errorCode, ValidationErrors = errors ?? Array.Empty() }; + } + + /// What an administrator enters on a release before requesting approval. + public sealed class ProtectedReleaseDraft + { + public IReadOnlyList FieldIds { get; init; } = Array.Empty(); + public int RecipientType { get; init; } + public string RecipientName { get; init; } + public string Purpose { get; init; } + } + + /// The department's Protected Workflows settings plus the ADP state they depend on. + public sealed class ProtectedWorkflowDepartmentSettings + { + public int DepartmentId { get; init; } + public bool Enabled { get; init; } + public bool RequireSecondApprover { get; init; } + public string AckVersion { get; init; } + public string AckByUserId { get; init; } + public DateTime? AckOn { get; init; } + public DepartmentDataProtectionState AdpState { get; init; } + + /// Set while a request to turn the two-person rule off waits for a second administrator. + public string RelaxRequestedByUserId { get; init; } + public DateTime? RelaxRequestedOn { get; init; } + + /// True when ADP is Enabled or Rotating — the only states in which a release may open data. + public bool AdpActive => AdpState == DepartmentDataProtectionState.Enabled || AdpState == DepartmentDataProtectionState.Rotating; + } + + /// Everything the workflow editor's "Protected release" panel shows. + public sealed class ProtectedWorkflowReleaseView + { + public Workflow Workflow { get; init; } + public WorkflowProtectedRelease Release { get; init; } + public ProtectedWorkflowDepartmentSettings Department { get; init; } + public bool TriggerSupported { get; init; } + public bool CanAdminister { get; init; } + public ProtectedWorkflowValidationResult Validation { get; init; } + public IReadOnlyList AvailableFields { get; init; } = Array.Empty(); + + /// Fingerprint of the workflow as it is saved now (with the release's field list and hosts). + public string CurrentFingerprint { get; init; } + + /// True when the release's approved/requested fingerprint equals . + public bool FingerprintMatches { get; init; } + + /// + /// Fingerprint of the steps, host and token host as shown NOW (no field list). A request must present it back, so an + /// administrator only ever requests the configuration they were looking at. + /// + public string StepsFingerprint { get; init; } + + /// OAuth2 token host of the pinned credential, when it is OAuth2. + public string TokenHost { get; init; } + + /// OAuth2 client authentication of the pinned credential (client_secret / private_key_jwt), when it is OAuth2. + public string AuthMethod { get; init; } + + /// The department's enabled call custom fields a release can allow-list one by one, with their sensitivity. + public IReadOnlyList AvailableUdfFields { get; init; } = Array.Empty(); + + /// Subject identifier keys worth offering: well-known EHR keys plus every key a step of this workflow captures. + public IReadOnlyList SuggestedSubjectKeys { get; init; } = Array.Empty(); + + /// The release's selection includes a restricted / Part 2 custom field (the extra attestations apply). + public bool NeedsRestrictedAttestation { get; init; } + public bool NeedsPart2Attestation { get; init; } + } + + /// + /// The extra attestations a release needs when it carries custom fields tagged restricted or 42 CFR Part 2. Each is + /// accepted only for the CURRENT version of its text. + /// + public sealed class ProtectedSensitiveAttestation + { + public bool Restricted { get; init; } + public string RestrictedVersion { get; init; } + public bool Part2 { get; init; } + public string Part2Version { get; init; } + + public bool RestrictedValid => Restricted && string.Equals(RestrictedVersion, ProtectedWorkflowDefaults.RestrictedAttestationVersion, StringComparison.Ordinal); + public bool Part2Valid => Part2 && string.Equals(Part2Version, ProtectedWorkflowDefaults.Part2AttestationVersion, StringComparison.Ordinal); + } + + /// The OAuth2 settings of a pinned credential that a release pins: token host and client authentication. + public sealed class ProtectedCredentialPins + { + public string TokenHost { get; init; } + public string AuthMethod { get; init; } + + /// private_key_jwt only: the credential has a current signing key. + public bool HasSigningKey { get; init; } + } + + /// Filter for the disclosure log. All criteria are optional; MaxRows caps the result. + public sealed class ProtectedWorkflowDisclosureFilter + { + public string WorkflowId { get; init; } + public string EntityId { get; init; } + public DateTime? FromUtc { get; init; } + public DateTime? ToUtc { get; init; } + public string RecordType { get; init; } + public int MaxRows { get; init; } = 1000; + } + + public sealed class ProtectedWorkflowChainVerification + { + public bool IsValid { get; init; } + public long RecordCount { get; init; } + + /// First sequence number that failed verification; null when the chain verifies. + public long? FirstInvalidSequence { get; init; } + } + + public sealed class ProtectedWorkflowSweepResult + { + public int Expired { get; set; } + public int Revoked { get; set; } + public int Suspended { get; set; } + public int NoticesSent { get; set; } + public int Failed { get; set; } + } + + /// Outcome of "Send test with sample data": synthetic values only, recorded as test disclosures. + public sealed class ProtectedWorkflowTestResult + { + public bool Success { get; init; } + + /// ProtectedWorkflowErrorCodes value when the test could not run at all. + public string ErrorCode { get; init; } + + public IReadOnlyList ValidationErrors { get; init; } = Array.Empty(); + + public List Steps { get; } = new List(); + } + + public sealed class ProtectedWorkflowTestStepResult + { + public string WorkflowStepId { get; init; } + public string Outcome { get; init; } + public int? HttpStatus { get; init; } + public string ErrorCode { get; init; } + } + + /// Run-level decision taken once per workflow execution. + public sealed class ProtectedRunGate + { + public static readonly ProtectedRunGate NotProtected = new ProtectedRunGate(); + + /// True when the workflow has an Active release: every step runs through the protected path. + public bool IsProtected { get; init; } + + /// Set when the run must be recorded as Skipped (protected_release_{state}); never run unprotected. + public string SkipReason { get; init; } + + public WorkflowProtectedRelease Release { get; init; } + } + + /// Per-step precondition outcome. When not allowed, nothing may be sent. + public sealed class ProtectedStepAuthorization + { + public bool Allowed { get; init; } + public WorkflowProtectedRelease Release { get; init; } + + /// ProtectedWorkflowDisclosureOutcomes value for a refusal. + public string Outcome { get; init; } + + /// ProtectedWorkflowErrorCodes value for a refusal. + public string ErrorCode { get; init; } + + public static ProtectedStepAuthorization Allow(WorkflowProtectedRelease release) => + new ProtectedStepAuthorization { Allowed = true, Release = release }; + + public static ProtectedStepAuthorization Block(WorkflowProtectedRelease release, string outcome, string errorCode) => + new ProtectedStepAuthorization { Allowed = false, Release = release, Outcome = outcome, ErrorCode = errorCode }; + } + + /// + /// The decrypted, allow-listed values for one step attempt, as a template namespace. Plaintext lives ONLY in + /// , in memory, for the duration of the render and send; it is never persisted, queued + /// or logged. is a Scriban ScriptObject typed as object to keep Scriban out of Model. + /// + public sealed class ProtectedReleasedValues + { + public bool Success { get; init; } + public string ErrorCode { get; init; } + public object Namespace { get; init; } + public IReadOnlyList FieldIds { get; init; } = Array.Empty(); + public string EntityType { get; init; } + public string EntityId { get; init; } + public string BrokerRequestId { get; init; } + + /// ProtectedWorkflowDisclosureOutcomes value when not successful (failed_broker when null). + public string Outcome { get; init; } + } + + /// What happened to the values a step captured from its response: the keys written, never the values. + public sealed class ProtectedCaptureWriteResult + { + public bool Success { get; init; } + public string ErrorCode { get; init; } + public IReadOnlyList WrittenKeys { get; init; } = Array.Empty(); + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowValidator.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowValidator.cs new file mode 100644 index 000000000..972b1d106 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedWorkflowValidator.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Model +{ + /// One blocking problem with a workflow's protected configuration. + public sealed class ProtectedWorkflowValidationError + { + public ProtectedWorkflowValidationError(string code, string workflowStepId = null) + { + Code = code; + WorkflowStepId = workflowStepId; + } + + /// Stable code; the UI maps it to localized text (ValidationError_{code}). + public string Code { get; } + + public string WorkflowStepId { get; } + } + + public sealed class ProtectedWorkflowValidationResult + { + public List Errors { get; } = new List(); + + /// Non-blocking advice (for example a protected value without an escape helper). Never stops a request. + public List Warnings { get; } = new List(); + + public bool IsValid => Errors.Count == 0; + + /// True when any enabled step writes values from its response into the call's subject identifiers. + public bool CapturesResponse { get; set; } + + /// The single literal https host every step posts to, when there is exactly one. + public string DestinationHost { get; set; } + + /// The single credential every step uses, when there is exactly one. + public string WorkflowCredentialId { get; set; } + + /// Maps to for the pinned credential. + public int? CredentialType { get; set; } + + public void Add(string code, string stepId = null) + { + if (!Errors.Any(e => e.Code == code && e.WorkflowStepId == stepId)) + Errors.Add(new ProtectedWorkflowValidationError(code, stepId)); + } + } + + /// + /// Pure structural validation of a workflow before a protected release can be requested or approved, and again + /// before every protected send. Codes are value-free and stable. + /// + public static class ProtectedWorkflowValidator + { + public const string NoSteps = "no_steps"; + public const string ActionNotAllowed = "action_not_allowed"; + public const string CredentialRequired = "credential_required"; + public const string CredentialMismatch = "credential_mismatch"; + public const string CredentialNotAllowed = "credential_not_allowed"; + public const string CredentialMissing = "credential_missing"; + public const string UrlRequired = "url_required"; + public const string SchemeNotHttps = "scheme_not_https"; + public const string HostNotLiteral = "host_not_literal"; + public const string HostMismatch = "host_mismatch"; + public const string ProtectedInActionConfig = "protected_in_action_config"; + public const string ProtectedInCondition = "protected_in_condition"; + public const string TriggerNotSupported = "trigger_not_supported"; + public const string TokenUrlInvalid = "token_url_invalid"; + public const string ProtectedWithoutRelease = "protected_reference_without_release"; + public const string SigningKeyMissing = "signing_key_missing"; + + /// Warning: a protected value is placed in a structured payload without json_escape, xml_escape or hl7_escape. + public const string UnescapedProtectedValue = "unescaped_protected_value"; + + /// The template helpers that make a value safe inside JSON, XML and HL7 v2. + public static readonly string[] EscapeHelpers = { "json_escape", "xml_escape", "hl7_escape" }; + + private static readonly Regex CodeBlock = new(@"\{\{(.*?)\}\}", RegexOptions.Singleline | RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex ProtectedReference = new(@"(?[^/:?#\s]+)(:(?\d{1,5}))?(?=[/?#]|\s*$)", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + /// Credential types a protected step may authenticate with. + public static bool IsAllowedCredentialType(int credentialType, bool allowHttpBasic) => + credentialType == (int)WorkflowCredentialType.HttpBearer || + credentialType == (int)WorkflowCredentialType.HttpApiKey || + credentialType == (int)WorkflowCredentialType.OAuth2ClientCredentials || + (allowHttpBasic && credentialType == (int)WorkflowCredentialType.HttpBasic); + + public static bool IsAllowedActionType(int actionType) => + actionType == (int)WorkflowActionType.CallApiPost || actionType == (int)WorkflowActionType.CallApiPut; + + /// True when a Scriban template references the protected.* namespace inside a code block. + public static bool ReferencesProtectedNamespace(string template) + { + if (string.IsNullOrWhiteSpace(template) || template.IndexOf("protected", StringComparison.Ordinal) < 0) + return false; + + foreach (Match block in CodeBlock.Matches(template)) + if (ProtectedReference.IsMatch(block.Groups[1].Value)) + return true; + + return false; + } + + /// + /// True when a code block reads a protected value without passing it through an escape helper. Advice only: a + /// note with a quote or a line break can break the JSON, XML or HL7 structure it is placed in. + /// + public static bool HasUnescapedProtectedReference(string template) + { + if (string.IsNullOrWhiteSpace(template) || template.IndexOf("protected", StringComparison.Ordinal) < 0) + return false; + + foreach (Match block in CodeBlock.Matches(template)) + { + var code = block.Groups[1].Value; + // "for field in protected.call.udf" only chooses what to loop over; the values it yields are checked where they are output. + if (LoopHeader.IsMatch(code)) + continue; + if (ProtectedReference.IsMatch(code) && !EscapeHelpers.Any(h => Regex.IsMatch(code, @"(?The Url value of an HTTP step's action config (case-insensitive key), or null. + public static string ReadUrl(string actionConfigJson) + { + if (string.IsNullOrWhiteSpace(actionConfigJson)) + return null; + try + { + var config = JObject.Parse(actionConfigJson); + var token = config.GetValue("Url", StringComparison.OrdinalIgnoreCase); + return token?.Type == JTokenType.String ? ((string)token)?.Trim() : null; + } + catch (JsonException) + { + return null; + } + } + + /// + /// Parses the literal host of an https URL. Returns false (with a code) for a non-https scheme, a missing URL, + /// or a host that contains template syntax — the destination must be pinned before anything renders. + /// + public static bool TryParseLiteralHttpsHost(string url, out string host, out string errorCode) + { + host = null; + errorCode = null; + if (string.IsNullOrWhiteSpace(url)) + { + errorCode = UrlRequired; + return false; + } + + if (!url.TrimStart().StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + errorCode = SchemeNotHttps; + return false; + } + + var match = HttpsAuthority.Match(url); + if (!match.Success) + { + errorCode = HostNotLiteral; + return false; + } + + var candidate = match.Groups["host"].Value; + if (candidate.IndexOfAny(new[] { '{', '}', '%', '@', '*' }) >= 0 || candidate.Contains("..")) + { + errorCode = HostNotLiteral; + return false; + } + + host = ProtectedWorkflowFingerprint.NormalizeHost(candidate); + return !string.IsNullOrEmpty(host); + } + + /// Host of an already-rendered absolute URL (runtime check); false unless the scheme is https. + public static bool TryGetRenderedHttpsHost(string url, out string host, out string errorCode) + { + host = null; + errorCode = null; + if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri)) + { + errorCode = UrlRequired; + return false; + } + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + errorCode = SchemeNotHttps; + return false; + } + + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + errorCode = HostNotLiteral; + return false; + } + + host = ProtectedWorkflowFingerprint.NormalizeHost(uri.IdnHost); + return !string.IsNullOrEmpty(host); + } + + /// + /// Validates every step of a workflow for protected use. maps credential id + /// to WorkflowCredentialType (missing ids are reported as credential_missing). + /// + public static ProtectedWorkflowValidationResult Validate(int triggerEventType, IEnumerable steps, + IReadOnlyDictionary credentialTypes, bool allowHttpBasic, int maxCaptureEntries = 5) + { + var result = new ProtectedWorkflowValidationResult(); + if (!ProtectedWorkflowFieldCatalog.IsSupportedTrigger(triggerEventType)) + result.Add(TriggerNotSupported); + + var allSteps = (steps ?? Enumerable.Empty()).Where(s => s != null).ToList(); + var enabled = ProtectedWorkflowFingerprint.OrderedEnabledSteps(allSteps).ToList(); + if (enabled.Count == 0) + result.Add(NoSteps); + + // Any other action type in the workflow blocks — a disabled email step is one toggle away from running. + foreach (var step in allSteps.Where(s => !IsAllowedActionType(s.ActionType))) + result.Add(ActionNotAllowed, step.WorkflowStepId); + + var hosts = new HashSet(StringComparer.Ordinal); + var credentialIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var step in enabled) + { + if (ReferencesProtectedNamespace(step.ActionConfig)) + result.Add(ProtectedInActionConfig, step.WorkflowStepId); + if (ReferencesProtectedNamespace(step.ConditionExpression)) + result.Add(ProtectedInCondition, step.WorkflowStepId); + + if (string.IsNullOrWhiteSpace(step.WorkflowCredentialId)) + { + result.Add(CredentialRequired, step.WorkflowStepId); + } + else + { + credentialIds.Add(step.WorkflowCredentialId.Trim()); + if (credentialTypes == null || !credentialTypes.TryGetValue(step.WorkflowCredentialId.Trim(), out var type)) + result.Add(CredentialMissing, step.WorkflowStepId); + else if (!IsAllowedCredentialType(type, allowHttpBasic)) + result.Add(CredentialNotAllowed, step.WorkflowStepId); + } + + if (IsAllowedActionType(step.ActionType)) + { + if (TryParseLiteralHttpsHost(ReadUrl(step.ActionConfig), out var host, out var urlError)) + hosts.Add(host); + else + result.Add(urlError, step.WorkflowStepId); + + // Content type, success rule, capture and idempotency options (EHR integration). + var options = ProtectedStepOptions.Read(step.ActionConfig, out var optionErrors, maxCaptureEntries); + foreach (var code in optionErrors) + result.Add(code, step.WorkflowStepId); + if (options.ResponseCapture.Count > 0) + result.CapturesResponse = true; + + if (options.MediaType != "text/plain" && HasUnescapedProtectedReference(step.OutputTemplate) && + !result.Warnings.Any(w => w.WorkflowStepId == step.WorkflowStepId)) + result.Warnings.Add(new ProtectedWorkflowValidationError(UnescapedProtectedValue, step.WorkflowStepId)); + } + } + + if (hosts.Count > 1) + result.Add(HostMismatch); + else if (hosts.Count == 1) + result.DestinationHost = hosts.First(); + + if (credentialIds.Count > 1) + result.Add(CredentialMismatch); + else if (credentialIds.Count == 1) + { + result.WorkflowCredentialId = credentialIds.First(); + if (credentialTypes != null && credentialTypes.TryGetValue(result.WorkflowCredentialId, out var pinnedType)) + result.CredentialType = pinnedType; + } + + return result; + } + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/WorkflowJwtKeys.cs b/Core/Resgrid.Model/ProtectedWorkflows/WorkflowJwtKeys.cs new file mode 100644 index 000000000..5da447961 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/WorkflowJwtKeys.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Model +{ + /// One private_key_jwt signing key as stored INSIDE a credential's encrypted data. Never shown or exported. + public sealed class WorkflowSigningKey + { + [JsonProperty("kid")] public string Kid { get; set; } + [JsonProperty("alg")] public string Alg { get; set; } + + /// PKCS#8, base64. + [JsonProperty("privateKey")] public string PrivateKey { get; set; } + + [JsonProperty("createdOn")] public DateTime CreatedOn { get; set; } + + /// When a newer key replaced it; it stays published (never used to sign) for the overlap window. + [JsonProperty("retiredOn")] public DateTime? RetiredOn { get; set; } + } + + /// One PUBLIC key in , with its lifecycle dates. + public sealed class WorkflowPublicKey + { + [JsonProperty("kid")] public string Kid { get; set; } + [JsonProperty("alg")] public string Alg { get; set; } + [JsonProperty("jwk")] public JObject Jwk { get; set; } + [JsonProperty("createdOn")] public DateTime CreatedOn { get; set; } + [JsonProperty("retiredOn")] public DateTime? RetiredOn { get; set; } + } + + /// + /// SMART Backend Services (OAuth2 private_key_jwt) for workflow credentials: key generation (RS384 by default, + /// ES384 optional), the published JWKS (current key plus rotated keys for the overlap window), and the signed client + /// assertion (iss = sub = client id, aud = token URL, exp at most five minutes out, a random jti, the kid in the header). + /// + public static class WorkflowJwtKeys + { + public const string ClientSecret = "client_secret"; + public const string PrivateKeyJwt = "private_key_jwt"; + public const string Rs384 = "RS384"; + public const string Es384 = "ES384"; + public const string AssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + + public static readonly TimeSpan MaxAssertionLifetime = TimeSpan.FromMinutes(5); + + public static string NormalizeAuthMethod(string authMethod) => + string.Equals(authMethod?.Trim(), PrivateKeyJwt, StringComparison.OrdinalIgnoreCase) ? PrivateKeyJwt : ClientSecret; + + public static string NormalizeAlgorithm(string alg) => + string.Equals(alg?.Trim(), Es384, StringComparison.OrdinalIgnoreCase) ? Es384 : Rs384; + + /// A new key pair. The kid is the RFC 7638 thumbprint of the public key. + public static (WorkflowSigningKey Signing, WorkflowPublicKey Public) Generate(string alg, DateTime utcNow) + { + alg = NormalizeAlgorithm(alg); + byte[] pkcs8; + JObject jwk; + if (alg == Es384) + { + using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP384); + pkcs8 = ec.ExportPkcs8PrivateKey(); + var p = ec.ExportParameters(false); + jwk = new JObject { ["crv"] = "P-384", ["kty"] = "EC", ["x"] = Base64Url(p.Q.X), ["y"] = Base64Url(p.Q.Y) }; + } + else + { + using var rsa = RSA.Create(3072); + pkcs8 = rsa.ExportPkcs8PrivateKey(); + var p = rsa.ExportParameters(false); + jwk = new JObject { ["e"] = Base64Url(p.Exponent), ["kty"] = "RSA", ["n"] = Base64Url(p.Modulus) }; + } + + var kid = Thumbprint(jwk); + var published = new JObject(jwk.Properties()) { ["kid"] = kid, ["alg"] = alg, ["use"] = "sig" }; + var signing = new WorkflowSigningKey { Kid = kid, Alg = alg, PrivateKey = Convert.ToBase64String(pkcs8), CreatedOn = utcNow }; + CryptographicOperations.ZeroMemory(pkcs8); + return (signing, new WorkflowPublicKey { Kid = kid, Alg = alg, Jwk = published, CreatedOn = utcNow }); + } + + /// The key that signs now: the newest one not retired. + public static WorkflowSigningKey Current(IEnumerable keys) => + (keys ?? Enumerable.Empty()) + .Where(k => k != null && !k.RetiredOn.HasValue && !string.IsNullOrWhiteSpace(k.PrivateKey)) + .OrderByDescending(k => k.CreatedOn) + .FirstOrDefault(); + + /// Keys still worth keeping: not retired, or retired less than ago. + public static bool IsPublished(DateTime? retiredOn, DateTime utcNow, int overlapDays) => + !retiredOn.HasValue || retiredOn.Value.AddDays(Math.Max(0, overlapDays)) > utcNow; + + /// The JWKS document served for a credential: { "keys": [ public JWKs ] }. + public static string BuildJwks(string publicJwksColumn, DateTime utcNow, int overlapDays) + { + var keys = new JArray(); + foreach (var key in ReadPublicKeys(publicJwksColumn).Where(k => k.Jwk != null && IsPublished(k.RetiredOn, utcNow, overlapDays))) + keys.Add(key.Jwk.DeepClone()); + return new JObject { ["keys"] = keys }.ToString(Formatting.None); + } + + public static List ReadPublicKeys(string publicJwksColumn) + { + if (string.IsNullOrWhiteSpace(publicJwksColumn)) + return new List(); + try + { + return JsonConvert.DeserializeObject>(publicJwksColumn) ?? new List(); + } + catch (JsonException) + { + return new List(); + } + } + + public static string WritePublicKeys(IEnumerable keys) => + JsonConvert.SerializeObject((keys ?? Enumerable.Empty()).ToList()); + + /// The public half of a stored signing key (used to rebuild the column after an edit). + public static WorkflowPublicKey PublicFor(WorkflowSigningKey key) + { + var pkcs8 = Convert.FromBase64String(key.PrivateKey); + try + { + JObject jwk; + if (NormalizeAlgorithm(key.Alg) == Es384) + { + using var ec = ECDsa.Create(); + ec.ImportPkcs8PrivateKey(pkcs8, out _); + var p = ec.ExportParameters(false); + jwk = new JObject { ["crv"] = "P-384", ["kty"] = "EC", ["x"] = Base64Url(p.Q.X), ["y"] = Base64Url(p.Q.Y) }; + } + else + { + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(pkcs8, out _); + var p = rsa.ExportParameters(false); + jwk = new JObject { ["e"] = Base64Url(p.Exponent), ["kty"] = "RSA", ["n"] = Base64Url(p.Modulus) }; + } + + var published = new JObject(jwk.Properties()) { ["kid"] = key.Kid, ["alg"] = NormalizeAlgorithm(key.Alg), ["use"] = "sig" }; + return new WorkflowPublicKey { Kid = key.Kid, Alg = NormalizeAlgorithm(key.Alg), Jwk = published, CreatedOn = key.CreatedOn, RetiredOn = key.RetiredOn }; + } + finally + { + CryptographicOperations.ZeroMemory(pkcs8); + } + } + + /// A signed client assertion for the token request. + public static string CreateAssertion(WorkflowSigningKey key, string clientId, string tokenUrl, DateTime utcNow, TimeSpan? lifetime = null) + { + if (key == null || string.IsNullOrWhiteSpace(key.PrivateKey)) + throw new InvalidOperationException("No signing key."); + + var life = lifetime ?? MaxAssertionLifetime; + if (life > MaxAssertionLifetime || life <= TimeSpan.Zero) + life = MaxAssertionLifetime; + + var alg = NormalizeAlgorithm(key.Alg); + var issuedAt = new DateTimeOffset(DateTime.SpecifyKind(utcNow, DateTimeKind.Utc)).ToUnixTimeSeconds(); + var header = new JObject { ["alg"] = alg, ["kid"] = key.Kid, ["typ"] = "JWT" }; + var claims = new JObject + { + ["iss"] = clientId, + ["sub"] = clientId, + ["aud"] = tokenUrl, + ["exp"] = issuedAt + (long)life.TotalSeconds, + ["iat"] = issuedAt, + ["jti"] = Base64Url(RandomNumberGenerator.GetBytes(24)) + }; + + var signingInput = Base64Url(Encoding.UTF8.GetBytes(header.ToString(Formatting.None))) + "." + + Base64Url(Encoding.UTF8.GetBytes(claims.ToString(Formatting.None))); + var data = Encoding.ASCII.GetBytes(signingInput); + + var pkcs8 = Convert.FromBase64String(key.PrivateKey); + try + { + byte[] signature; + if (alg == Es384) + { + using var ec = ECDsa.Create(); + ec.ImportPkcs8PrivateKey(pkcs8, out _); + signature = ec.SignData(data, HashAlgorithmName.SHA384, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + else + { + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(pkcs8, out _); + signature = rsa.SignData(data, HashAlgorithmName.SHA384, RSASignaturePadding.Pkcs1); + } + + return signingInput + "." + Base64Url(signature); + } + finally + { + CryptographicOperations.ZeroMemory(pkcs8); + } + } + + /// Verifies an assertion against a public JWK (tests and diagnostics). + public static bool Verify(string assertion, JObject jwk) + { + var parts = assertion?.Split('.'); + if (parts == null || parts.Length != 3 || jwk == null) + return false; + + var data = Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]); + var signature = FromBase64Url(parts[2]); + if ((string)jwk["kty"] == "EC") + { + using var ec = ECDsa.Create(new ECParameters + { + Curve = ECCurve.NamedCurves.nistP384, + Q = new ECPoint { X = FromBase64Url((string)jwk["x"]), Y = FromBase64Url((string)jwk["y"]) } + }); + return ec.VerifyData(data, signature, HashAlgorithmName.SHA384, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + + using var rsa = RSA.Create(new RSAParameters { Modulus = FromBase64Url((string)jwk["n"]), Exponent = FromBase64Url((string)jwk["e"]) }); + return rsa.VerifyData(data, signature, HashAlgorithmName.SHA384, RSASignaturePadding.Pkcs1); + } + + /// RFC 7638 thumbprint: SHA-256 over the required members in lexicographic order. + private static string Thumbprint(JObject requiredMembers) + { + var ordered = new JObject(requiredMembers.Properties().OrderBy(p => p.Name, StringComparer.Ordinal)); + return Base64Url(SHA256.HashData(Encoding.UTF8.GetBytes(ordered.ToString(Formatting.None)))); + } + + public static string Base64Url(byte[] data) => + Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + public static byte[] FromBase64Url(string text) + { + var s = (text ?? string.Empty).Replace('-', '+').Replace('_', '/'); + switch (s.Length % 4) + { + case 2: s += "=="; break; + case 3: s += "="; break; + } + return Convert.FromBase64String(s); + } + } +} diff --git a/Core/Resgrid.Model/ProtectedWorkflows/WorkflowProtectedRelease.cs b/Core/Resgrid.Model/ProtectedWorkflows/WorkflowProtectedRelease.cs new file mode 100644 index 000000000..69710b625 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWorkflows/WorkflowProtectedRelease.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// A department's approval for ONE workflow to send an allow-listed set of protected call fields to ONE pinned + /// HTTPS host with ONE pinned credential. At most one non-Revoked release exists per workflow (filtered unique + /// index); revoked rows are kept as history. WorkflowId is deliberately not a foreign key: a deleted workflow's + /// release is Revoked and kept, and its disclosures reference it. + /// + /// The approval binds to : any change to what, where, when or how (steps, + /// templates, URLs, credential id, trigger, field list, host) makes the recomputed fingerprint differ and moves + /// the release back to PendingApproval. + /// + [Table("WorkflowProtectedReleases")] + public class WorkflowProtectedRelease : IEntity + { + [Key] + [MaxLength(128)] + public string WorkflowProtectedReleaseId { get; set; } + + [Required] + [MaxLength(128)] + public string WorkflowId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + /// Maps to . + public int State { get; set; } + + /// ProtectedWorkflowSuspendReasons value when the release left Active; null otherwise. + [MaxLength(64)] + public string SuspendedReason { get; set; } + + /// JSON array of catalog field ids, e.g. ["calls.completednotes","calls.callformdata"]. + public string AllowedFieldIds { get; set; } + + /// Always "https". + [MaxLength(8)] + public string DestinationScheme { get; set; } + + /// Exact destination host, lowercase, no wildcards. + [MaxLength(255)] + public string DestinationHost { get; set; } + + /// Pinned OAuth2 token endpoint host when the credential is OAuth2ClientCredentials; null otherwise. + [MaxLength(255)] + public string TokenHost { get; set; } + + /// The one credential every protected step must use. + [MaxLength(128)] + public string WorkflowCredentialId { get; set; } + + /// + /// Pinned OAuth2 client authentication of that credential (client_secret or private_key_jwt); null for other + /// credential types. A credential that switches method no longer matches the approval. + /// + [MaxLength(32)] + public string AuthMethod { get; set; } + + /// The release may carry fields tagged restricted: the requester attested the recipient is authorized. + public bool AllowsRestricted { get; set; } + + [MaxLength(64)] + public string RestrictedAckVersion { get; set; } + + [MaxLength(128)] + public string RestrictedAckByUserId { get; set; } + + /// The release may carry 42 CFR Part 2 fields: the requester attested a Part 2 basis for the redisclosure. + public bool AllowsPart2 { get; set; } + + [MaxLength(64)] + public string Part2AckVersion { get; set; } + + [MaxLength(128)] + public string Part2AckByUserId { get; set; } + + /// SHA-256 (lowercase hex) of the canonical configuration the requester attested (see ProtectedWorkflowFingerprint). + [MaxLength(64)] + public string ConfigFingerprint { get; set; } + + /// Maps to . + public int RecipientType { get; set; } + + [MaxLength(200)] + public string RecipientName { get; set; } + + /// Administrator-entered reason for the disclosure; shown in the audit trail. + [MaxLength(500)] + public string Purpose { get; set; } + + /// Warning text version the requester (and approver) attested. + [MaxLength(64)] + public string AckVersion { get; set; } + + [MaxLength(128)] + public string RequestedByUserId { get; set; } + + public DateTime? RequestedOn { get; set; } + + [MaxLength(128)] + public string ApprovedByUserId { get; set; } + + public DateTime? ApprovedOn { get; set; } + + /// ApprovedOn plus DataProtectionConfig.ProtectedWorkflowReleaseLifetimeDays. + public DateTime? ExpiresOn { get; set; } + + /// Smallest expiry-notice threshold (days) already emailed for the current ExpiresOn; reset on renewal. + public int? ExpiryNoticeSentDays { get; set; } + + [MaxLength(128)] + public string RevokedByUserId { get; set; } + + public DateTime? RevokedOn { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime? UpdatedOn { get; set; } + + /// + /// Optimistic concurrency: every state transition is a conditional update on this value, so a stale copy (a sweep, + /// a racing approval) can never overwrite a revoke or a suspension. + /// + public int Version { get; set; } + + [NotMapped] + public ProtectedReleaseState ReleaseState => (ProtectedReleaseState)State; + + /// The allow-listed field ids, distinct, lowercase, sorted. + public IReadOnlyList GetAllowedFieldIds() => ParseFieldIds(AllowedFieldIds); + + public void SetAllowedFieldIds(IEnumerable fieldIds) => + AllowedFieldIds = JsonConvert.SerializeObject(NormalizeFieldIds(fieldIds)); + + public static IReadOnlyList ParseFieldIds(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return Array.Empty(); + try + { + return NormalizeFieldIds(JsonConvert.DeserializeObject>(json)); + } + catch (JsonException) + { + return Array.Empty(); + } + } + + public static List NormalizeFieldIds(IEnumerable fieldIds) => + (fieldIds ?? Enumerable.Empty()) + .Where(f => !string.IsNullOrWhiteSpace(f)) + .Select(f => f.Trim().ToLowerInvariant()) + .Distinct(StringComparer.Ordinal) + .OrderBy(f => f, StringComparer.Ordinal) + .ToList(); + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get => WorkflowProtectedReleaseId; + set => WorkflowProtectedReleaseId = (string)value; + } + + [NotMapped] public string TableName => "WorkflowProtectedReleases"; + [NotMapped] public string IdName => "WorkflowProtectedReleaseId"; + [NotMapped] public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => + new[] { "IdValue", "IdType", "TableName", "IdName", "ReleaseState" }; + } +} diff --git a/Core/Resgrid.Model/Providers/WorkflowActionContext.cs b/Core/Resgrid.Model/Providers/WorkflowActionContext.cs index 16d896603..9ff03d9b7 100644 --- a/Core/Resgrid.Model/Providers/WorkflowActionContext.cs +++ b/Core/Resgrid.Model/Providers/WorkflowActionContext.cs @@ -34,6 +34,36 @@ public sealed class WorkflowActionContext /// of the rendered text. Null for every other step. /// public WorkflowAttachment Attachment { get; init; } + + /// + /// Protected Workflow send (an approved release): the executor refuses redirects, requires TLS 1.2+, applies the + /// protected timeout, re-checks the rendered URL against (and an OAuth2 token URL + /// against ), and reports only the status line — never the response body, never + /// the payload, never an exception message carrying request content, and nothing to Logging. + /// + public bool ProtectedMode { get; init; } + + /// Exact host the rendered URL must resolve to in protected mode. + public string PinnedHost { get; init; } + + /// Exact OAuth2 token endpoint host in protected mode (null when the credential is not OAuth2). + public string PinnedTokenHost { get; init; } + + /// Protected mode: the OAuth2 client authentication the release pinned (client_secret / private_key_jwt). + public string PinnedAuthMethod { get; init; } + + /// + /// run.idempotency_key for this step: stable across retries of the same delivery. Sent in the step's + /// IdempotencyHeader when one is configured. + /// + public string IdempotencyKey { get; init; } + + /// + /// Maps to for the attached credential, when one is attached. HTTP executors use it + /// to pick the auth scheme when the stored credential JSON carries no explicit authType (the web credential editor + /// stores only the type's fields). + /// + public int? CredentialType { get; init; } } /// A rendered export handed to an executor: bytes, name and content type; never a path. diff --git a/Core/Resgrid.Model/Providers/WorkflowActionResult.cs b/Core/Resgrid.Model/Providers/WorkflowActionResult.cs index 01bd7f441..daa888efa 100644 --- a/Core/Resgrid.Model/Providers/WorkflowActionResult.cs +++ b/Core/Resgrid.Model/Providers/WorkflowActionResult.cs @@ -11,6 +11,29 @@ public sealed class WorkflowActionResult /// Detailed error information when Success is false. public string ErrorDetail { get; init; } + /// HTTP status code when a request completed (HTTP executors); null otherwise. + public int? HttpStatus { get; init; } + + /// SHA-256 (lowercase hex) of the exact request body bytes sent, when a body was sent. + public string PayloadSha256 { get; init; } + + /// Length of the exact request body bytes sent. + public int? PayloadBytes { get; init; } + + /// + /// Protected mode: the disclosure outcome the executor determined when it refused or failed the send + /// (ProtectedWorkflowDisclosureOutcomes.BlockedHost for a pin mismatch or redirect, FailedHttp otherwise). + /// + public string ProtectedOutcome { get; init; } + + /// + /// Protected mode: values the step's ResponseCapture read from the response, by subject identifier key. In memory + /// only: WorkflowService writes them through the encrypt lane and drops them. Never serialized, logged or stored here. + /// + [Newtonsoft.Json.JsonIgnore] + [System.Text.Json.Serialization.JsonIgnore] + public System.Collections.Generic.IReadOnlyDictionary CapturedValues { get; init; } + public static WorkflowActionResult Succeeded(string message = null) => new WorkflowActionResult { Success = true, ResultMessage = message }; diff --git a/Core/Resgrid.Model/Queue/ShiftQueueItem.cs b/Core/Resgrid.Model/Queue/ShiftQueueItem.cs index cccaeefed..934e845fb 100644 --- a/Core/Resgrid.Model/Queue/ShiftQueueItem.cs +++ b/Core/Resgrid.Model/Queue/ShiftQueueItem.cs @@ -23,6 +23,9 @@ public class ShiftQueueItem [ProtoMember(6)] public int ShiftId { get; set; } + + [ProtoMember(7)] + public int ShiftSignupId { get; set; } } public enum ShiftQueueTypes @@ -33,6 +36,12 @@ public enum ShiftQueueTypes TradeRejected = 3, ShiftCreated = 4, ShiftUpdated = 5, - ShiftDaysAdded = 6 + ShiftDaysAdded = 6, + SignupPendingApproval = 7, + SignupReviewed = 8, + TradePendingApproval = 9, + TradeReviewed = 10, + DayAssigned = 11, + DayRemoved = 12 } } \ No newline at end of file diff --git a/Core/Resgrid.Model/Repositories/ICallsRepository.cs b/Core/Resgrid.Model/Repositories/ICallsRepository.cs index 36d402531..69b99b0a9 100644 --- a/Core/Resgrid.Model/Repositories/ICallsRepository.cs +++ b/Core/Resgrid.Model/Repositories/ICallsRepository.cs @@ -93,5 +93,12 @@ public interface ICallsRepository: IRepository /// The department identifier (used to scope the result). /// Active calls with check-in timers that the user is dispatched on. Task> GetActiveCallsWithCheckInTimersForUserAsync(string userId, int departmentId); + + /// + /// Sets Calls.SubjectIdentifiers only when the stored value is still (null matches + /// NULL): a concurrent edit is never overwritten by a stale merge. True when the row was updated. + /// + Task TryUpdateSubjectIdentifiersAsync(int callId, int departmentId, string expectedValue, string newValue, + System.Threading.CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Repositories/IProtectedWorkflowRepositories.cs b/Core/Resgrid.Model/Repositories/IProtectedWorkflowRepositories.cs new file mode 100644 index 000000000..0d0aa883d --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IProtectedWorkflowRepositories.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IWorkflowProtectedReleaseRepository : IRepository + { + /// The workflow's current release: the most recently created row (a Revoked row stays current until superseded). + Task GetLatestByWorkflowIdAsync(string workflowId); + + Task> GetAllByWorkflowIdAsync(string workflowId); + + Task> GetAllByDepartmentIdAsync(int departmentId); + + Task> GetAllByCredentialIdAsync(string workflowCredentialId); + + /// Cross-department read for the daily sweep. + Task> GetAllByStatesAsync(IEnumerable states); + + /// + /// Writes the release only if its stored Version still equals release.Version (the value it was read with), then + /// increments it. False means someone else changed the release first and nothing was written. + /// + Task TryUpdateAsync(WorkflowProtectedRelease release, CancellationToken cancellationToken = default); + } + + public interface IProtectedWorkflowDisclosureRepository : IRepository + { + /// + /// Appends one record to the department's hash chain: links it to the current tail (Sequence, PrevHash, Hash) + /// and inserts it, retrying on a concurrent append that took the same sequence number. Never updates a row. + /// + Task AppendAsync(ProtectedWorkflowDisclosure record, CancellationToken cancellationToken = default); + + Task GetLatestForDepartmentAsync(int departmentId); + + /// Filtered, newest first. + Task> GetForDepartmentAsync(int departmentId, ProtectedWorkflowDisclosureFilter filter); + + /// The whole chain in sequence order, for verification. + Task> GetChainForDepartmentAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Repositories/IShiftSignupRepository.cs b/Core/Resgrid.Model/Repositories/IShiftSignupRepository.cs index 2365a650e..2894b7733 100644 --- a/Core/Resgrid.Model/Repositories/IShiftSignupRepository.cs +++ b/Core/Resgrid.Model/Repositories/IShiftSignupRepository.cs @@ -34,5 +34,11 @@ public interface IShiftSignupRepository: IRepository Task> GetAllShiftSignupsByShiftIdAndDateAsync(int shiftId, DateTime shiftDayDate); Task> GetAllShiftSignupsByGroupIdAndDateAsync(int departmentGroupId, DateTime shiftDayDate); + + /// + /// Gets every signup (including pending and denied ones) on any shift in the department whose shift day is in + /// [, ). + /// + Task> GetShiftSignupsByDepartmentIdAndDateRangeAsync(int departmentId, DateTime startDate, DateTime endDate); } } diff --git a/Core/Resgrid.Model/Repositories/IShiftSignupTradeRepository.cs b/Core/Resgrid.Model/Repositories/IShiftSignupTradeRepository.cs index 22c2dd48a..5dedafe19 100644 --- a/Core/Resgrid.Model/Repositories/IShiftSignupTradeRepository.cs +++ b/Core/Resgrid.Model/Repositories/IShiftSignupTradeRepository.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace Resgrid.Model.Repositories @@ -37,5 +38,12 @@ public interface IShiftSignupTradeRepository: IRepository /// The user identifier. /// Task<IEnumerable<ShiftSignupTrade>>. Task> GetTradeRequestsAndSourceShiftsByUserIdAsync(string userId); + + /// + /// Gets every trade in a department whose source or swap-back shift day is on or after + /// , with and + /// populated. Users are not loaded. + /// + Task> GetShiftSignupTradesByDepartmentIdAsync(int departmentId, DateTime startDate); } } diff --git a/Core/Resgrid.Model/Repositories/IShiftSignupTradeUserShiftsRepository.cs b/Core/Resgrid.Model/Repositories/IShiftSignupTradeUserShiftsRepository.cs index 50467d87d..773a0e8f9 100644 --- a/Core/Resgrid.Model/Repositories/IShiftSignupTradeUserShiftsRepository.cs +++ b/Core/Resgrid.Model/Repositories/IShiftSignupTradeUserShiftsRepository.cs @@ -1,6 +1,13 @@ -namespace Resgrid.Model.Repositories +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories { public interface IShiftSignupTradeUserShiftsRepository: IRepository { + /// + /// Gets the trade offers that put up the given signup as a swap-back day. + /// + Task> GetShiftSignupTradeUserShiftsBySignupIdAsync(int shiftSignupId); } } diff --git a/Core/Resgrid.Model/Repositories/IWorkflowRepository.cs b/Core/Resgrid.Model/Repositories/IWorkflowRepository.cs index b4a5e5648..27d09e13c 100644 --- a/Core/Resgrid.Model/Repositories/IWorkflowRepository.cs +++ b/Core/Resgrid.Model/Repositories/IWorkflowRepository.cs @@ -7,7 +7,6 @@ public interface IWorkflowRepository : IRepository { Task> GetAllActiveByDepartmentAndEventTypeAsync(int departmentId, int triggerEventType); Task> GetAllByDepartmentIdAsync(int departmentId); - Task GetByDepartmentAndEventTypeAsync(int departmentId, int triggerEventType); /// /// Atomically deletes a workflow and all its dependent child records (WorkflowRunLogs, diff --git a/Core/Resgrid.Model/Services/IAuthorizationService.cs b/Core/Resgrid.Model/Services/IAuthorizationService.cs index bc5b5499e..335ca297a 100644 --- a/Core/Resgrid.Model/Services/IAuthorizationService.cs +++ b/Core/Resgrid.Model/Services/IAuthorizationService.cs @@ -240,6 +240,13 @@ public interface IAuthorizationService /// Task<System.Boolean>. Task CanUserDeleteShiftSignupAsync(string userId, int departmentId, int shiftSignupId); + /// + /// Which department groups the user supervises shifts for: all of them for a department admin or a + /// department-wide shift manager (Create Shift permission set to Everyone, or to select roles the user holds), + /// otherwise the groups (and child groups) they are a group admin of. + /// + Task GetShiftManagementScopeAsync(string userId, int departmentId); + /// /// Determines whether this instance [can view unit location asynchronous] the specified user identifier. /// diff --git a/Core/Resgrid.Model/Services/IDepartmentGroupsService.cs b/Core/Resgrid.Model/Services/IDepartmentGroupsService.cs index 0d7d61cd8..fe9b11684 100644 --- a/Core/Resgrid.Model/Services/IDepartmentGroupsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentGroupsService.cs @@ -198,6 +198,12 @@ Task MoveUserIntoGroupAsync(string userId, int groupId, b /// Task<List<DepartmentGroupMember>>. Task> GetAllAdminsForGroupAsync(int groupId); + /// + /// Admins of the group plus the admins of every group above it (parent, grandparent, ...), + /// so an area supervisor who administers a parent group is included for the groups beneath it. + /// + Task> GetAllAdminsForGroupAndAncestorsAsync(int groupId); + /// /// Gets the group by dispatch email code asynchronous. /// diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index b3b3c1620..3e7c7471d 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -353,6 +353,11 @@ Task SetUnitCallStatusOverridesByUnitTypeAsync(int department Task SetDispatchRecommendationConfigAsync(int departmentId, DispatchRecommendationConfig config, CancellationToken cancellationToken = default(CancellationToken)); + /// Group-scoped dispatch switch and its department-wide roles. Never null; defaults to off. Cached on the security-setting window. + Task GetGroupDispatchScopeConfigAsync(int departmentId, bool bypassCache = false); + + Task SetGroupDispatchScopeConfigAsync(int departmentId, GroupDispatchScopeConfig config, CancellationToken cancellationToken = default(CancellationToken)); + Task SetDepartmentModuleSettingsAsync(int departmentId, DepartmentModuleSettings settings, CancellationToken cancellationToken = default(CancellationToken)); /// diff --git a/Core/Resgrid.Model/Services/IDispatchScopeService.cs b/Core/Resgrid.Model/Services/IDispatchScopeService.cs new file mode 100644 index 000000000..924df1b6a --- /dev/null +++ b/Core/Resgrid.Model/Services/IDispatchScopeService.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Resolves which part of a department a user dispatches, for departments that turned on + /// group-scoped dispatch (). With it off, every user is + /// department-wide and nothing changes. + /// + /// With it on: department admins and holders of a configured department-wide role see the + /// whole department (a central dispatch center); everyone else sees their own group and the + /// groups beneath it (an area supervisor over the stations and units in their area). A call belongs to that slice when + /// its location falls inside one of those groups' boundaries, or one of those groups, their + /// units or their members was dispatched on it. A user always keeps the calls they reported or + /// were dispatched to. + /// + /// + /// Nothing is stamped on the call: scope is recomputed from roles and membership on every + /// request, so a hand-off between dispatch desks is a role change, not a data change. + /// + /// + public interface IDispatchScopeService + { + /// The user's scope right now. Never null. + Task GetScopeForUserAsync(int departmentId, string userId); + + /// Whether the call is in the scope. Loads the call's dispatches when they aren't populated yet. + Task IsCallInScopeAsync(DispatchScope scope, Call call); + + /// Convenience for + . + Task CanUserAccessCallAsync(int departmentId, string userId, Call call); + + /// The calls in the scope, in their original order. Returns the same list when department-wide. + Task> FilterCallsAsync(DispatchScope scope, List calls); + + /// Convenience for + . + Task> FilterCallsForUserAsync(int departmentId, string userId, List calls); + } +} diff --git a/Core/Resgrid.Model/Services/IGeoService.cs b/Core/Resgrid.Model/Services/IGeoService.cs index ff40ab981..7f3e72381 100644 --- a/Core/Resgrid.Model/Services/IGeoService.cs +++ b/Core/Resgrid.Model/Services/IGeoService.cs @@ -37,6 +37,13 @@ public interface IGeoService /// Task> GetStationsContainingPointAsync(int departmentId, double latitude, double longitude); + /// + /// Groups of any type (Station or Organizational) whose boundary polygon contains the point. + /// Groups without a parseable boundary are skipped. Nested boundaries all match, so a point + /// inside a station's area under a service area returns both groups. + /// + Task> GetGroupsWithBoundaryContainingPointAsync(int departmentId, double latitude, double longitude); + /// /// All station groups with resolvable coordinates ordered by straight-line /// distance to the point (nearest first), with geofence containment flagged. diff --git a/Core/Resgrid.Model/Services/INearestUnitService.cs b/Core/Resgrid.Model/Services/INearestUnitService.cs new file mode 100644 index 000000000..8bcf73b4a --- /dev/null +++ b/Core/Resgrid.Model/Services/INearestUnitService.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// The "nearest available unit" board: for an incident location, every unit (a team, an apparatus or an + /// individual set up as a unit) and every responder in the viewer's dispatch scope, side by side with + /// status, live position, ETA, shift coverage and role mix. Unlike the run card recommendation engine + /// it selects nothing; it ranks everything so a dispatcher can choose. + /// + public interface INearestUnitService + { + Task GetBoardAsync(NearestUnitRequest request, CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/Core/Resgrid.Model/Services/IProtectedWorkflowService.cs b/Core/Resgrid.Model/Services/IProtectedWorkflowService.cs new file mode 100644 index 000000000..b4df05e42 --- /dev/null +++ b/Core/Resgrid.Model/Services/IProtectedWorkflowService.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Protected Workflows (ADP push model): lets a department with Advanced Data Protection approve ONE workflow to + /// send an allow-listed set of protected call fields to ONE pinned HTTPS host with ONE pinned credential. + /// Off by default twice (department toggle, then per-workflow approval), minimum necessary (only ticked fields + /// decrypt), fail closed, and every disclosure and administrative act lands in a per-department hash chain. + /// + /// Every command enforces server-side: the ADP egress permission (ConfigureProtectedDataEgress, resolved through + /// AdpPermissionDefaults); for request/approve/renew/toggle an interactive actor with a fresh step-up; and the + /// two-person rule when the department requires it. + /// + public interface IProtectedWorkflowService + { + /// True when the user holds the ADP egress permission for the department (ConfigureProtectedDataEgress). + Task CanAdministerAsync(int departmentId, string userId); + + Task GetDepartmentSettingsAsync(int departmentId, bool bypassCache = false); + + /// + /// Turns Protected Workflows on or off and sets the two-person rule. Enabling requires ADP to be Enabled or + /// Rotating and the current warning text version acknowledged; turning it off suspends every release in the + /// department (department_disabled). Either way the PolicyEpoch is bumped. Turning the two-person rule ON is + /// immediate; turning it OFF is recorded as a request that only a DIFFERENT administrator can confirm + /// (PendingConfirmation), so one administrator can never remove the second approver on their own. + /// + Task SetDepartmentSettingsAsync(int departmentId, bool enabled, bool requireSecondApprover, + string acknowledgedVersion, ProtectedWorkflowActor actor, CancellationToken cancellationToken = default); + + /// The workflow's current release (latest row), or null when it has never been protected. + Task GetCurrentReleaseAsync(string workflowId); + + Task> GetReleasesForDepartmentAsync(int departmentId); + + /// Current release, department settings, validation and fingerprint status for the editor panel. + Task GetReleaseViewAsync(int departmentId, string workflowId, string userId, + CancellationToken cancellationToken = default); + + /// Creates or updates the workflow's Draft (fields, recipient, purpose). Editing an Active release's fields sends it back to PendingApproval. + Task SaveDraftAsync(int departmentId, string workflowId, ProtectedReleaseDraft draft, + ProtectedWorkflowActor actor, CancellationToken cancellationToken = default); + + /// + /// Requests approval of the workflow's current configuration (also re-requests after a config change, + /// suspension or expiry). Validates, pins host/credential/token host and the fingerprint, and records the + /// attestation. With a single approver the release becomes Active immediately; with the two-person rule it + /// waits in PendingApproval for a different administrator. + /// + /// The StepsFingerprint the administrator was shown; a mismatch (the steps + /// changed since) refuses the request with config_changed. + /// The restricted / Part 2 attestations; required when the selection includes a custom field with that tag. + Task RequestApprovalAsync(int departmentId, string workflowId, bool attested, + string acknowledgedVersion, string reviewedStepsFingerprint, ProtectedWorkflowActor actor, ProtectedSensitiveAttestation sensitive = null, + CancellationToken cancellationToken = default); + + /// Second-administrator approval of a pending request. The requester can never approve their own request. + /// The release ConfigFingerprint the approver was shown; the approval binds to it. + Task ApproveAsync(int departmentId, string releaseId, bool attested, + string acknowledgedVersion, string reviewedFingerprint, ProtectedWorkflowActor actor, ProtectedSensitiveAttestation sensitive = null, + CancellationToken cancellationToken = default); + + /// Renews an Active or Expired release for another lifetime (step-up and re-attestation required; follows the two-person rule). + Task RenewAsync(int departmentId, string releaseId, bool attested, + string acknowledgedVersion, string reviewedFingerprint, ProtectedWorkflowActor actor, ProtectedSensitiveAttestation sensitive = null, + CancellationToken cancellationToken = default); + + Task SuspendAsync(int departmentId, string releaseId, ProtectedWorkflowActor actor, + CancellationToken cancellationToken = default); + + Task RevokeAsync(int departmentId, string releaseId, ProtectedWorkflowActor actor, + CancellationToken cancellationToken = default); + + /// Deletes a never-requested Draft so the workflow runs (redacted) again. + Task DiscardDraftAsync(int departmentId, string releaseId, ProtectedWorkflowActor actor, + CancellationToken cancellationToken = default); + + // ── Lifecycle hooks (called by WorkflowService on every save/delete) ───────────────────────── + + /// Recomputes the fingerprint; an Active or pending release whose fingerprint moved goes to PendingApproval (config_changed). + Task OnWorkflowConfigurationChangedAsync(string workflowId, string actorUserId, CancellationToken cancellationToken = default); + + /// Revokes the deleted workflow's release (workflow_deleted); disclosures are kept. + Task OnWorkflowDeletedAsync(Workflow workflow, string actorUserId, CancellationToken cancellationToken = default); + + /// + /// A credential type change (or an OAuth2 token host change) suspends every release pinned to it + /// (credential_changed); a secret-only change writes a credential_rotated admin event and leaves releases Active. + /// + Task OnCredentialSavedAsync(WorkflowCredential credential, bool typeChanged, bool secretChanged, string previousTokenHost, + string currentTokenHost, string actorUserId, CancellationToken cancellationToken = default, string previousAuthMethod = null, + string currentAuthMethod = null, string rotatedKeyId = null); + + /// + /// The department's call custom field definition was republished: every Active or pending release that allow-lists + /// a custom field is re-fingerprinted, so a sensitivity retag (or a removed field) sends it back for approval. + /// + Task OnCallCustomFieldsChangedAsync(int departmentId, string actorUserId, CancellationToken cancellationToken = default); + + /// The enabled fields of the department's active call custom field definition. + Task> GetCallCustomFieldsAsync(int departmentId); + + /// The fingerprint of the workflow as saved now against the release (live custom field sensitivities included). + Task ComputeCurrentFingerprintAsync(Workflow workflow, IEnumerable steps, WorkflowProtectedRelease release); + + Task OnCredentialDeletedAsync(WorkflowCredential credential, string actorUserId, CancellationToken cancellationToken = default); + + /// + /// Save-time template check for a step: protected.* is never allowed in a condition or an action config, and + /// is only allowed in an output template when the workflow has (or is being given) a release. Returns a + /// ProtectedWorkflowValidator code, or null when the step is acceptable. + /// + Task ValidateStepTemplatesAsync(WorkflowStep step, CancellationToken cancellationToken = default); + + // ── Disclosure chain ───────────────────────────────────────────────────────────────────────── + + Task RecordDisclosureAsync(ProtectedWorkflowDisclosure disclosure, CancellationToken cancellationToken = default); + + Task RecordAdminEventAsync(int departmentId, string eventType, string actorUserId, string workflowId, + string releaseId, string detail, CancellationToken cancellationToken = default); + + Task> GetDisclosuresAsync(int departmentId, ProtectedWorkflowDisclosureFilter filter); + + Task VerifyChainAsync(int departmentId); + + /// Value-free CSV of the filtered disclosure log (metadata only). + Task ExportDisclosuresCsvAsync(int departmentId, ProtectedWorkflowDisclosureFilter filter); + + // ── State transitions shared with the runtime and the sweep ────────────────────────────────── + + /// Moves an Active release to Expired and records release_expired. + Task MarkExpiredAsync(WorkflowProtectedRelease release, CancellationToken cancellationToken = default); + + /// Revokes every non-revoked release in the department (ADP offboarding/disabled). + Task RevokeAllForDepartmentAsync(int departmentId, string reason, string actorUserId, CancellationToken cancellationToken = default); + + /// + /// Daily sweep (worker 71): expires releases past ExpiresOn, revokes releases of departments whose ADP is + /// offboarding or disabled, suspends releases of departments that turned the toggle off, and emails + /// department administrators 30 and 7 days before expiry. + /// + Task RunSweepAsync(DateTime utcNow, CancellationToken cancellationToken = default); + + /// + /// Generic final-failure notice to department administrators (workflow name, run id, error code — never a value). + /// Never throws. + /// + Task NotifyFinalFailureAsync(int departmentId, Workflow workflow, string workflowRunId, string errorCode, + CancellationToken cancellationToken = default); + + /// OAuth2 token endpoint host of a credential (null when the credential is not OAuth2 or its token URL is not a literal https URL). + Task GetCredentialTokenHostAsync(int departmentId, string workflowCredentialId); + + /// The OAuth2 token host and client authentication of a credential (null when it is not an OAuth2 credential). + Task GetCredentialPinsAsync(int departmentId, string workflowCredentialId); + } + + /// + /// The unattended half of Protected Workflows, called by WorkflowService for each run and each protected step: + /// the run gate, the per-attempt preconditions, the broker decrypt of ONLY the allow-listed fields (fresh request + /// id per attempt, purpose protected-workflow), and the synthetic namespace used by test sends. + /// + public interface IProtectedWorkflowRuntime + { + /// Run-level gate: no release (not protected), Active (protected), or anything else (skip the run). + Task GetRunGateAsync(Workflow workflow, CancellationToken cancellationToken = default); + + /// + /// Re-checks, from fresh reads, every precondition of one protected step attempt: ADP Enabled/Rotating, the + /// department toggle, the release Active and unexpired, the recomputed fingerprint, the action type, the + /// credential and the rendered URL's scheme and host. Nothing may be decrypted or sent unless Allowed. + /// + Task AuthorizeStepAsync(Workflow workflow, WorkflowStep step, string renderedActionConfig, + CancellationToken cancellationToken = default); + + /// Decrypts ONLY the release's allow-listed fields of the triggering entity. Any broker failure fails the whole resolve. + Task ResolveReleasedValuesAsync(Workflow workflow, WorkflowProtectedRelease release, string eventPayloadJson, + CancellationToken cancellationToken = default); + + /// Synthetic values for "Send test with sample data" — never decrypts anything. + ProtectedReleasedValues BuildSampleValues(int triggerEventType, IEnumerable fieldIds); + + /// + /// Writes values a step captured from its response into the call's subject identifiers (existing keys are + /// overwritten) through the workload encrypt lane, as a conditional update on the stored value. Only for a release + /// that reads the subject identifiers. Returns the keys written, never the values. + /// + Task WriteCapturedValuesAsync(Workflow workflow, WorkflowProtectedRelease release, string entityId, + IReadOnlyDictionary values, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IShiftsService.cs b/Core/Resgrid.Model/Services/IShiftsService.cs index 6dc3894fe..13b163084 100644 --- a/Core/Resgrid.Model/Services/IShiftsService.cs +++ b/Core/Resgrid.Model/Services/IShiftsService.cs @@ -305,5 +305,136 @@ Task PopulateShiftData(Shift shift, bool getDepartment, bool getPersonnel Task> GetShiftSignupsByDepartmentGroupIdAndDayAsync(int departmentGroupId, DateTime shiftDayDate); Task> GetShiftPersonsForUserAsync(string userId); + + /// + /// Everyone on a shift whose window (StartTime to EndTime, or Hours, in the department's local + /// time) contains : assigned personnel for assigned shifts, approved + /// (not denied) signups for signup shifts. Trades are not applied. + /// + Task> GetOnShiftPersonnelAsync(int departmentId, DateTime utcNow); + + + /// + /// Saves only the shift row itself (name, times, colour, approval flag...), leaving Days, Groups, Personnel, + /// Signups and Admins untouched. Use this for detail edits so a loaded collection is never re-synced. + /// + Task UpdateShiftAsync(Shift shift, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Builds the full schedule (resolved roster, needs, active flag, signups, trades) for one shift day. + /// Returns null for an unknown day. + /// + Task GetShiftDayScheduleAsync(int shiftDayId); + + /// + /// Schedules for every shift day in the department whose date is within [startDate, endDate] (department-local + /// dates, inclusive), optionally for one shift only. + /// + Task> GetShiftDaySchedulesForDateRangeAsync(int departmentId, DateTime startDate, DateTime endDate, int? shiftId = null); + + /// + /// Schedules for the department's shift days that are running at , including a + /// night shift that started the previous evening. + /// + Task> GetActiveShiftDaySchedulesAsync(int departmentId, DateTime timestampUtc); + + /// + /// Across all departments, the shift days that start within the next 24 hours of + /// (each in its department's local time), with resolved rosters, for the shift reminder worker. + /// + Task> GetShiftDaysStartingWithinDayAsync(DateTime timestampUtc); + + /// + /// The people on duty for a department group right now: approved roster entries for that group on every + /// running shift day, plus standing-roster people with no group on that shift who are members of the group. + /// Empty when no shift covering the group is running. + /// + Task> GetOnDutyUserIdsForGroupAsync(int departmentId, int departmentGroupId, DateTime timestampUtc); + + /// + /// for several groups off one schedule load: group id to the user + /// ids on duty for it (an empty list when no running shift covers the group). + /// + Task>> GetOnDutyUserIdsForGroupsAsync(int departmentId, IEnumerable departmentGroupIds, DateTime timestampUtc); + + /// + /// A user signs up for an open slot on a shift day. Validates the day, the group, and existing signups, and + /// leaves the signup pending when the shift requires approval. + /// + Task> SignupUserForShiftDayAsync(int shiftDayId, int? departmentGroupId, string userId, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// A supervisor puts a person on one shift day (single-day roster edit). Reactivates a day the person had been + /// taken off. Not subject to approval. + /// + Task> AssignUserToShiftDayAsync(int shiftDayId, string userId, int? departmentGroupId, string assignedByUserId, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// A supervisor takes a person off one shift day. Signups are marked denied (kept for the record) and a + /// standing-roster person gets a denied signup for that day so the rest of the month is unchanged. + /// + Task> RemoveUserFromShiftDayAsync(int shiftDayId, string userId, string removedByUserId, string note, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// A supervisor approves or denies a pending signup. + /// + Task> ReviewShiftSignupAsync(int shiftSignupId, bool approve, string reviewerUserId, string note, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Signups waiting for supervisor approval in the department whose day has not passed, with Shift and Group set. + /// + Task> GetPendingShiftSignupsAsync(int departmentId); + + /// + /// A user asks colleagues to take their slot on a shift day. People on the standing roster get a signup for + /// that day created so the slot can be traded. + /// + Task> RequestTradeAsync(int shiftDayId, string userId, List userIds, string note, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Starts a trade on an existing signup owned by the caller. + /// + Task> RequestTradeForSignupAsync(int shiftSignupId, string userId, List userIds, string note, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// An invited user accepts (optionally offering their own signups as swap-backs) or declines a trade request. + /// + Task> RespondToTradeAsync(int shiftSignupTradeId, string userId, bool accept, string note, List offeredShiftSignupIds, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// The requester picks an offer: a user taking the slot outright, or one of the offered swap-back signups. On a + /// shift that requires approval the trade then waits for a supervisor. + /// + Task> FinishTradeAsync(int shiftSignupTradeId, string requesterUserId, string acceptedUserId, int? targetShiftSignupId, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// The requester withdraws a trade that has not taken effect. + /// + Task> CancelTradeAsync(int shiftSignupTradeId, string requesterUserId, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// A supervisor approves or denies a trade waiting for approval. + /// + Task> ReviewTradeAsync(int shiftSignupTradeId, bool approve, string reviewerUserId, string note, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Trades waiting for supervisor approval in the department, fully loaded. + /// + Task> GetPendingTradesAsync(int departmentId); + + /// + /// Trades the user started or was asked to take whose day has not long passed, fully loaded. + /// + Task> GetTradesForUserAsync(int departmentId, string userId); } } diff --git a/Core/Resgrid.Model/Services/IUdfRenderingService.cs b/Core/Resgrid.Model/Services/IUdfRenderingService.cs index d022cbd47..71094ce09 100644 --- a/Core/Resgrid.Model/Services/IUdfRenderingService.cs +++ b/Core/Resgrid.Model/Services/IUdfRenderingService.cs @@ -25,6 +25,13 @@ public interface IUdfRenderingService /// Generates read-only HTML for display/detail pages (e.g. ViewCall). /// string GenerateReadOnlyHtml(UdfDefinition definition, List fields, List values); + + /// + /// The text a person reads for a stored value: an option key becomes its label (Dropdown, + /// MultiSelect, ComboBox), a Boolean becomes Yes/No, and a sealed ADP envelope becomes the + /// REDACTED placeholder. Anything else is returned as stored. + /// + string FormatDisplayValue(UdfField field, string value); } } diff --git a/Core/Resgrid.Model/Services/IWorkflowService.cs b/Core/Resgrid.Model/Services/IWorkflowService.cs index 84775d575..f716b861a 100644 --- a/Core/Resgrid.Model/Services/IWorkflowService.cs +++ b/Core/Resgrid.Model/Services/IWorkflowService.cs @@ -11,10 +11,6 @@ public interface IWorkflowService Task SaveWorkflowAsync(Workflow workflow, CancellationToken cancellationToken = default); Task DeleteWorkflowAsync(string workflowId, CancellationToken cancellationToken = default); Task> GetActiveWorkflowsByDepartmentAndEventTypeAsync(int departmentId, int triggerEventType, CancellationToken cancellationToken = default); -/// Returns true when a workflow already exists for the given trigger event type in the department. -Task WorkflowExistsForEventTypeAsync(int departmentId, int triggerEventType, CancellationToken cancellationToken = default); -/// Returns the set of integer values already claimed by a workflow in the department. -Task> GetUsedEventTypesForDepartmentAsync(int departmentId, CancellationToken cancellationToken = default); /// Returns true when the department is allowed to create an additional workflow (enforces plan-based caps). Task CanAddWorkflowAsync(int departmentId, bool isFreePlan, CancellationToken cancellationToken = default); /// Returns true when the workflow is allowed to have an additional step added (enforces plan-based caps). @@ -33,6 +29,12 @@ public interface IWorkflowService /// Task SaveCredentialAsync(WorkflowCredential credential, string departmentCode, CancellationToken cancellationToken = default); Task DeleteCredentialAsync(string credentialId, CancellationToken cancellationToken = default); +/// +/// OAuth2 private_key_jwt: generates a new signing key (new kid), retires the current one (it stays in the JWKS for +/// DataProtectionConfig.WorkflowJwksOverlapDays) and records credential_rotated. Null when the credential is not a +/// private_key_jwt credential of the department. +/// +Task RotateCredentialSigningKeyAsync(string credentialId, int departmentId, string departmentCode, string userId, CancellationToken cancellationToken = default); // ── Execution ───────────────────────────────────────────────────────────────── /// /// Executes all enabled steps of the workflow against the provided event payload. @@ -48,5 +50,11 @@ public interface IWorkflowService Task> GetLogsForRunAsync(string workflowRunId, CancellationToken cancellationToken = default); Task GetWorkflowHealthAsync(string workflowId, CancellationToken cancellationToken = default); Task ClearPendingRunsAsync(int departmentId, CancellationToken cancellationToken = default); +/// +/// Protected Workflows "Send test with sample data": renders every enabled step with SYNTHETIC values in the protected.* +/// namespace (nothing is decrypted), sends through the protected executor path to the workflow's pinned host, and +/// records each attempt as a test disclosure. The caller authorizes the administrator. +/// +Task SendProtectedTestAsync(int departmentId, string departmentCode, string workflowId, CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/ShiftActionResult.cs b/Core/Resgrid.Model/ShiftActionResult.cs new file mode 100644 index 000000000..3e45dd026 --- /dev/null +++ b/Core/Resgrid.Model/ShiftActionResult.cs @@ -0,0 +1,41 @@ +namespace Resgrid.Model +{ + /// + /// Why a shift signup, trade or roster change was refused. Serialized to API clients as snake_case codes. + /// + public enum ShiftActionErrors + { + None = 0, + NotFound, + NotAllowed, + AlreadySignedUp, + InvalidGroup, + DayInPast, + NotOnShift, + TradeExists, + NoUsers, + InvalidOffer, + NotPending, + AlreadyOnRoster, + InvalidRequest + } + + public class ShiftActionResult + { + public T Item { get; set; } + + public ShiftActionErrors Error { get; set; } + + public bool Success => Error == ShiftActionErrors.None; + + public static ShiftActionResult Ok(T item) + { + return new ShiftActionResult { Item = item, Error = ShiftActionErrors.None }; + } + + public static ShiftActionResult Fail(ShiftActionErrors error) + { + return new ShiftActionResult { Error = error }; + } + } +} diff --git a/Core/Resgrid.Model/ShiftDay.cs b/Core/Resgrid.Model/ShiftDay.cs index c6237e658..8189b9f9a 100644 --- a/Core/Resgrid.Model/ShiftDay.cs +++ b/Core/Resgrid.Model/ShiftDay.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Newtonsoft.Json; +using Resgrid.Model.Helpers; namespace Resgrid.Model { @@ -25,32 +26,24 @@ public class ShiftDay : IEntity public bool? Processed { get; set; } + /// + /// Department-local wall-clock start of this day's shift (Day plus the shift's StartTime; midnight when the shift has + /// no start time). Needs loaded. + /// [NotMapped] public DateTime Start { - get - { - if (Shift != null && !String.IsNullOrWhiteSpace(Shift.StartTime)) - { - return DateTime.Parse($"{Day.Month}/{Day.Day}/{Day.Year} " + Shift.StartTime); - } - - return Day; - } + get { return ShiftTimeWindow.GetWindow(Day, Shift?.StartTime, Shift?.EndTime, Shift?.Hours).Start; } } + /// + /// Department-local wall-clock end of this day's shift. An end time at or before the start time runs into the next + /// day (a 19:00 to 07:00 night shift); a shift with no end time runs for its Hours, or a full day. + /// [NotMapped] public DateTime End { - get - { - if (Shift != null && !String.IsNullOrWhiteSpace(Shift.EndTime)) - { - return DateTime.Parse($"{Day.Month}/{Day.Day}/{Day.Year} " + Shift.EndTime); - } - - return DateTime.Parse($"{Day.Month}/{Day.Day}/{Day.Year} 23:59:59"); - } + get { return ShiftTimeWindow.GetWindow(Day, Shift?.StartTime, Shift?.EndTime, Shift?.Hours).End; } } [NotMapped] diff --git a/Core/Resgrid.Model/ShiftDayRosterEntry.cs b/Core/Resgrid.Model/ShiftDayRosterEntry.cs new file mode 100644 index 000000000..9d75ecbe3 --- /dev/null +++ b/Core/Resgrid.Model/ShiftDayRosterEntry.cs @@ -0,0 +1,50 @@ +namespace Resgrid.Model +{ + public enum ShiftRosterSources + { + /// On the shift's standing roster (ShiftPersons) for every day of an Assigned shift. + Assigned = 0, + + /// Signed up for the day themselves. + Signup = 1, + + /// A supervisor put them on this one day. + SupervisorAssigned = 2, + + /// Working this slot because a trade moved it to them. + Trade = 3 + } + + /// + /// One person on a specific shift day after standing roster, signups, single-day supervisor edits and completed + /// trades have all been applied. Built by the shifts service; not persisted. + /// + public class ShiftDayRosterEntry + { + public string UserId { get; set; } + + /// The department group (team) the person fills a slot for, when the slot is tied to one. + public int? DepartmentGroupId { get; set; } + + public ShiftRosterSources Source { get; set; } + + /// The signup backing this entry; null for standing-roster entries. + public int? ShiftSignupId { get; set; } + + /// + /// Waiting for supervisor approval. Pending entries are shown on the day but are not on duty and do not fill needs. + /// + public bool ApprovalPending { get; set; } + + /// For , whose slot this was before the trade. + public string TradedFromUserId { get; set; } + + /// The trade that put this person here, for . + public int? ShiftSignupTradeId { get; set; } + + public bool IsOnDuty() + { + return !ApprovalPending; + } + } +} diff --git a/Core/Resgrid.Model/ShiftDaySchedule.cs b/Core/Resgrid.Model/ShiftDaySchedule.cs new file mode 100644 index 000000000..b0321e365 --- /dev/null +++ b/Core/Resgrid.Model/ShiftDaySchedule.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + /// + /// A shift day with everything needed to show or act on it: the resolved roster, remaining needs per group and + /// role, and whether it is running right now. Built by the shifts service; not persisted. + /// + public class ShiftDaySchedule + { + /// The day, with set so Start/End resolve. + public ShiftDay Day { get; set; } + + public Shift Shift { get; set; } + + public List Roster { get; set; } = new List(); + + /// Department group id to (personnel role id to people still needed). + public Dictionary> Needs { get; set; } = new Dictionary>(); + + /// Every signup for the day, including pending and denied ones. + public List Signups { get; set; } = new List(); + + /// Trades whose source or swap-back signup is on this day. + public List Trades { get; set; } = new List(); + + /// Department-local now falls inside the day's start/end window. + public bool IsActive { get; set; } + + public bool IsFilled() + { + return Needs == null || Needs.Values.All(x => x.Values.All(v => v <= 0)); + } + + public int OpenSlots() + { + return Needs == null ? 0 : Needs.Values.Sum(x => x.Values.Where(v => v > 0).Sum()); + } + } +} diff --git a/Core/Resgrid.Model/ShiftManagementScope.cs b/Core/Resgrid.Model/ShiftManagementScope.cs new file mode 100644 index 000000000..09874d99c --- /dev/null +++ b/Core/Resgrid.Model/ShiftManagementScope.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + /// + /// Which department groups a user may supervise shifts for (approve signups and trades, add or remove people on a + /// day). covers department admins and department-wide shift managers; otherwise + /// holds the groups (and their child groups) the user is a group admin of, which is how a + /// contracted provider manages only their own teams. + /// + public class ShiftManagementScope + { + public bool AllGroups { get; set; } + + public HashSet GroupIds { get; set; } = new HashSet(); + + public bool IsSupervisor => AllGroups || GroupIds.Count > 0; + + public bool CanManageGroup(int? departmentGroupId) + { + if (AllGroups) + return true; + + return departmentGroupId.HasValue && GroupIds.Contains(departmentGroupId.Value); + } + + /// + /// May edit the shift itself (details, days, groups): every group on the shift is one this user manages. + /// + public bool CanManageShift(Shift shift) + { + if (AllGroups) + return true; + + var groups = shift?.Groups?.Where(x => x != null).ToList(); + + return groups != null && groups.Any() && groups.All(x => GroupIds.Contains(x.DepartmentGroupId)); + } + + /// + /// Supervises at least one group on the shift, so has something to approve or edit on its days. + /// + public bool CanSuperviseShift(Shift shift) + { + if (AllGroups) + return true; + + return shift?.Groups != null && shift.Groups.Any(x => x != null && GroupIds.Contains(x.DepartmentGroupId)); + } + + /// + /// May act on a slot in the given group of the shift. A slot with no group belongs to the whole shift, so it + /// needs the whole shift. + /// + public bool CanManageShiftGroup(Shift shift, int? departmentGroupId) + { + return departmentGroupId.HasValue ? CanManageGroup(departmentGroupId) : CanManageShift(shift); + } + + public static ShiftManagementScope None() + { + return new ShiftManagementScope(); + } + } +} diff --git a/Core/Resgrid.Model/ShiftSignup.cs b/Core/Resgrid.Model/ShiftSignup.cs index 83032cab0..9fbda1a1f 100644 --- a/Core/Resgrid.Model/ShiftSignup.cs +++ b/Core/Resgrid.Model/ShiftSignup.cs @@ -40,6 +40,24 @@ public class ShiftSignup : IEntity public virtual DepartmentGroup Group { get; set; } + /// + /// The shift requires approval and a supervisor has not yet approved or denied this signup. A pending + /// signup is shown on the day but does not put the person on duty or count toward the day's needs. + /// + public bool ApprovalPending { get; set; } + + /// + /// Set when a supervisor put the person on this day (a single-day roster edit) rather than the person + /// signing up themselves. Null for self signups. + /// + public string AssignedByUserId { get; set; } + + public string ReviewedByUserId { get; set; } + + public DateTime? ReviewedOn { get; set; } + + public string ReviewNote { get; set; } + [NotMapped] public virtual ShiftSignupTrade Trade { get; set; } @@ -72,5 +90,13 @@ public ShiftTradeTypes GetTradeType() return ShiftTradeTypes.Target; } + + /// + /// The signup puts its person on the day: not denied and not waiting for a supervisor. + /// + public bool IsActive() + { + return !Denied && !ApprovalPending; + } } } diff --git a/Core/Resgrid.Model/ShiftSignupTrade.cs b/Core/Resgrid.Model/ShiftSignupTrade.cs index e90b1eaa3..0787c4f35 100644 --- a/Core/Resgrid.Model/ShiftSignupTrade.cs +++ b/Core/Resgrid.Model/ShiftSignupTrade.cs @@ -39,6 +39,18 @@ public class ShiftSignupTrade : IEntity public string Note { get; set; } + /// + /// The requester picked an offer (UserId or TargetShiftSignupId is set) on a shift that requires approval, and + /// a supervisor has not reviewed it yet. The swap does not change the roster until it is approved. + /// + public bool ApprovalPending { get; set; } + + public string ReviewedByUserId { get; set; } + + public DateTime? ReviewedOn { get; set; } + + public string ReviewNote { get; set; } + [NotMapped] [JsonIgnore]public object IdValue { @@ -58,31 +70,50 @@ [JsonIgnore]public object IdValue [NotMapped] public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "SourceShiftSignup", "TargetShiftSignup", "User", "Users" }; - public bool IsTradeComplete() + /// + /// An offer has been picked (someone takes the source slot outright, or a swap-back signup was chosen). + /// This is true while the pick is still waiting on a supervisor; use to know + /// whether the roster has actually changed. + /// + public bool HasSelection() { - if (UserId == null && TargetShiftSignupId == null) - return false; + return !String.IsNullOrWhiteSpace(UserId) || TargetShiftSignupId.HasValue; + } - return true; + /// + /// The trade has taken effect: an offer was picked, and it is neither waiting for nor denied by a supervisor. + /// + public bool IsTradeComplete() + { + return HasSelection() && !ApprovalPending && !Denied; } public ShiftSignupTradeStates GetState(string userId) { - var userSignup = Users.FirstOrDefault(x => x.UserId == userId); + // User ids are GUID strings that arrive in either case depending on where they were read from. + var userSignup = Users?.FirstOrDefault(x => SameUser(x.UserId, userId)); if (userSignup != null && userSignup.Declined) return ShiftSignupTradeStates.Declined; - if (!String.IsNullOrWhiteSpace(UserId) && UserId == userId) + if (Denied) + return ShiftSignupTradeStates.Denied; + + var pickedUserId = !String.IsNullOrWhiteSpace(UserId) ? UserId : TargetShiftSignup?.UserId; + + if (ApprovalPending && SameUser(pickedUserId, userId)) + return ShiftSignupTradeStates.PendingApproval; + + if (!String.IsNullOrWhiteSpace(UserId) && SameUser(UserId, userId)) return ShiftSignupTradeStates.Accepted; - if (!String.IsNullOrWhiteSpace(UserId) && UserId != userId) + if (!String.IsNullOrWhiteSpace(UserId) && !SameUser(UserId, userId)) return ShiftSignupTradeStates.Filled; - if (TargetShiftSignup != null && TargetShiftSignup.UserId == userId) + if (TargetShiftSignup != null && SameUser(TargetShiftSignup.UserId, userId)) return ShiftSignupTradeStates.Accepted; - if (TargetShiftSignup != null && TargetShiftSignup.UserId != userId) + if (TargetShiftSignup != null && !SameUser(TargetShiftSignup.UserId, userId)) return ShiftSignupTradeStates.Filled; if (userSignup != null && userSignup.Offered) @@ -90,5 +121,10 @@ public ShiftSignupTradeStates GetState(string userId) return ShiftSignupTradeStates.Open; } + + private static bool SameUser(string a, string b) + { + return String.Equals(a, b, StringComparison.OrdinalIgnoreCase); + } } } diff --git a/Core/Resgrid.Model/ShiftSignupTradeStates.cs b/Core/Resgrid.Model/ShiftSignupTradeStates.cs index acb2ee5b0..7607a3b6b 100644 --- a/Core/Resgrid.Model/ShiftSignupTradeStates.cs +++ b/Core/Resgrid.Model/ShiftSignupTradeStates.cs @@ -6,6 +6,8 @@ public enum ShiftSignupTradeStates Filled = 1, Declined = 2, Accepted = 3, - Proposed = 4 + Proposed = 4, + PendingApproval = 5, + Denied = 6 } } \ No newline at end of file diff --git a/Core/Resgrid.Model/UdfField.cs b/Core/Resgrid.Model/UdfField.cs index cdbc8ac81..507c229bd 100644 --- a/Core/Resgrid.Model/UdfField.cs +++ b/Core/Resgrid.Model/UdfField.cs @@ -75,6 +75,13 @@ public class UdfField : IEntity /// Published RMS classification: 0 unrestricted, 1 restricted; unknown legacy fields require restricted access. public int? RmsClassification { get; set; } + /// + /// Release sensitivity for Protected Workflows, see : none (0), restricted (1) + /// or 42 CFR Part 2 (2). Releasing a restricted or Part 2 field needs its own attestation, and a change here sends + /// every release that references the field back for approval. + /// + public int Sensitivity { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Model/UdfFieldDataType.cs b/Core/Resgrid.Model/UdfFieldDataType.cs index c9ab44fcb..0811994eb 100644 --- a/Core/Resgrid.Model/UdfFieldDataType.cs +++ b/Core/Resgrid.Model/UdfFieldDataType.cs @@ -50,7 +50,16 @@ public enum UdfFieldDataType [Display(Name = "URL")] [Description("Web address / URL")] - Url = 10 + Url = 10, + + /// + /// A text entry with the field's options offered as suggestions. An entry matching an option's + /// key or label (case-insensitive) is stored as that option's key, exactly like a Dropdown; + /// anything else is stored as the typed text and is subject to the text length/format rules. + /// + [Display(Name = "Combo Box")] + [Description("Suggestion list with free-text entry")] + ComboBox = 11 } } diff --git a/Core/Resgrid.Model/UdfFieldSensitivity.cs b/Core/Resgrid.Model/UdfFieldSensitivity.cs new file mode 100644 index 000000000..bcc9dc5a5 --- /dev/null +++ b/Core/Resgrid.Model/UdfFieldSensitivity.cs @@ -0,0 +1,18 @@ +namespace Resgrid.Model +{ + /// How carefully a call custom field may be released by a Protected Workflow (UdfField.Sensitivity). + public enum UdfFieldSensitivity + { + /// No extra attestation. + None = 0, + + /// Released only when the release attests the recipient is authorized to receive restricted fields. + Restricted = 1, + + /// + /// 42 CFR Part 2 substance use disorder information: released only with the Part 2 redisclosure attestation, and + /// sent only for calls with Part2ConsentOnFile. + /// + Part2 = 2 + } +} diff --git a/Core/Resgrid.Model/WorkflowCredential.cs b/Core/Resgrid.Model/WorkflowCredential.cs index fa2c47ca0..32eccafc8 100644 --- a/Core/Resgrid.Model/WorkflowCredential.cs +++ b/Core/Resgrid.Model/WorkflowCredential.cs @@ -29,6 +29,12 @@ public class WorkflowCredential : IEntity [Required] public string EncryptedData { get; set; } + /// + /// OAuth2 private_key_jwt only: the PUBLIC signing keys as JSON (see WorkflowCredentialKeySet), served unauthenticated as + /// the credential's JWKS. Never contains private key material, which lives only inside EncryptedData. + /// + public string PublicJwks { get; set; } + [Required] public string CreatedByUserId { get; set; } diff --git a/Core/Resgrid.Model/WorkflowCredentialType.cs b/Core/Resgrid.Model/WorkflowCredentialType.cs index acfde629c..48d4fe08b 100644 --- a/Core/Resgrid.Model/WorkflowCredentialType.cs +++ b/Core/Resgrid.Model/WorkflowCredentialType.cs @@ -15,7 +15,14 @@ public enum WorkflowCredentialType Discord = 10, AzureBlobStorage = 11, Box = 12, - Dropbox = 13 + Dropbox = 13, + + /// + /// OAuth2 client credentials grant (token URL, client id, client secret, scope, optional audience). The executor + /// fetches a bearer token and caches it until 60 seconds before it expires. Needed for Dataverse and + /// Entra-protected endpoints; in a protected step the token URL host is pinned on the release (TokenHost). + /// + OAuth2ClientCredentials = 14 } } diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index f03f1230f..548831503 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -83,11 +83,20 @@ public static class WorkflowTemplateVariableCatalog new TemplateVariableDescriptor("call.form_data", "Call form data JSON", "string", false), new TemplateVariableDescriptor("call.is_deleted", "Whether the call is deleted", "bool", false), new TemplateVariableDescriptor("call.deleted_reason", "Reason for deletion", "string", false), + new TemplateVariableDescriptor("call.part2_consent_on_file", "42 CFR Part 2 consent (or another Part 2 basis) is on file for this call", "bool", false), + }; + + /// run.* is set for every step of every run. + private static readonly List CommonRunVariables = new List + { + new TemplateVariableDescriptor("run.id", "Workflow run ID", "string", true), + new TemplateVariableDescriptor("run.attempt", "Attempt number of this run (1 on the first try)", "int", true), + new TemplateVariableDescriptor("run.idempotency_key", "Stable key for this step's delivery: the same on every retry, different for every event. Use it as an Idempotency-Key, a FHIR identifier or HL7 MSH-10", "string", true), }; private static List GetCommon() => - new List(CommonDeptVariables.Count + CommonTimestampVariables.Count + CommonUserVariables.Count) - .Also(l => { l.AddRange(CommonDeptVariables); l.AddRange(CommonTimestampVariables); l.AddRange(CommonUserVariables); }); + new List(CommonDeptVariables.Count + CommonTimestampVariables.Count + CommonUserVariables.Count + CommonRunVariables.Count) + .Also(l => { l.AddRange(CommonDeptVariables); l.AddRange(CommonTimestampVariables); l.AddRange(CommonUserVariables); l.AddRange(CommonRunVariables); }); // 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 diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index ac082d554..cb4d1b1d5 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -78,7 +78,7 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => Text("Calls", "GeoLocationData"), Text("Calls", "W3W"), Text("Calls", "ContactName"), Text("Calls", "ContactNumber"), Text("Calls", "SourceIdentifier"), Text("Calls", "IncidentNumber"), Text("Calls", "ExternalIdentifier"), Text("Calls", "ReferenceNumber"), Text("Calls", "CallFormData"), - Text("Calls", "DeletedReason") + Text("Calls", "DeletedReason"), Text("Calls", "SubjectIdentifiers") }), AdpTableBinding.ViaParent("CallNotes", "CallNoteId", pkIsNumeric: true, "CallId", "Calls", "CallId", new[] diff --git a/Core/Resgrid.Services/AuthorizationService.cs b/Core/Resgrid.Services/AuthorizationService.cs index 9801232fc..17771bb03 100644 --- a/Core/Resgrid.Services/AuthorizationService.cs +++ b/Core/Resgrid.Services/AuthorizationService.cs @@ -5,6 +5,7 @@ using Microsoft.VisualBasic; using MongoDB.Driver; using Resgrid.Model; +using Resgrid.Model.Helpers; using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Services; @@ -34,6 +35,7 @@ public class AuthorizationService : IAuthorizationService private readonly ICacheProvider _cacheProvider; private readonly IContactsService _contactsService; private readonly IEventAggregator _eventAggregator; + private readonly IDispatchScopeService _dispatchScopeService; private static string WhoCanViewUnitsCacheKey = "ViewUnitsSecurityMaxtix_{0}"; private static string WhoCanViewUnitLocationsCacheKey = "ViewUnitLocationsSecurityMaxtix_{0}"; @@ -53,7 +55,7 @@ public AuthorizationService(IDepartmentsService departmentsService, IInvitesServ IPermissionsService permissionsService, ICalendarService calendarService, IProtocolsService protocolsService, IShiftsService shiftsService, ICustomStateService customStateService, ICertificationService certificationService, IDocumentsService documentsService, INotesService notesService, ICacheProvider cacheProvider, IContactsService contactsService, - IEventAggregator eventAggregator) + IEventAggregator eventAggregator, IDispatchScopeService dispatchScopeService) { _departmentsService = departmentsService; _invitesService = invitesService; @@ -75,6 +77,7 @@ public AuthorizationService(IDepartmentsService departmentsService, IInvitesServ _cacheProvider = cacheProvider; _contactsService = contactsService; _eventAggregator = eventAggregator; + _dispatchScopeService = dispatchScopeService; } /// @@ -142,6 +145,10 @@ public async Task CanUserViewCallAsync(string userId, int callId) if (call.DepartmentId != department.DepartmentId) return false; + // Group-scoped dispatch (off by default): outside the user's area and not on the call means no access. + if (!await _dispatchScopeService.CanUserAccessCallAsync(department.DepartmentId, userId, call)) + return false; + return true; } @@ -645,6 +652,70 @@ public async Task CanUserDeleteShiftSignupAsync(string userId, int departm return false; } + public async Task GetShiftManagementScopeAsync(string userId, int departmentId) + { + var scope = new ShiftManagementScope(); + + if (String.IsNullOrWhiteSpace(userId)) + return scope; + + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + + if (department == null) + return scope; + + var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, false); + + if (member == null || member.IsDeleted) + return scope; + + if (department.IsUserAnAdmin(userId)) + { + scope.AllGroups = true; + return scope; + } + + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionTypes.CreateShift); + + if (permission != null) + { + if (permission.Action == (int)PermissionActions.Everyone) + { + scope.AllGroups = true; + return scope; + } + + if ((permission.Action == (int)PermissionActions.DepartmentAdminsAndSelectRoles || + permission.Action == (int)PermissionActions.DepartmentAndGroupAdminsAndSelectRoles) && + !String.IsNullOrWhiteSpace(permission.Data)) + { + var roleIds = permission.Data.Split(',') + .Select(x => int.TryParse(x.Trim(), out var id) ? id : (int?)null) + .Where(x => x.HasValue) + .Select(x => x.Value) + .ToList(); + + var roles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId); + + if (roles != null && roles.Any(x => roleIds.Contains(x.PersonnelRoleId))) + { + scope.AllGroups = true; + return scope; + } + } + } + + // Group admins supervise their own teams (and any child groups) whatever the Create Shift permission says, + // the same way they could already remove signups in their group. This is how a contracted provider manages + // only its own groups while the department sees everything. + var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId) ?? new System.Collections.Generic.List(); + + foreach (var group in groups.Where(x => x != null && x.IsUserGroupAdmin(userId))) + scope.GroupIds.UnionWith(DepartmentGroupHierarchy.GetSelfAndDescendantIds(groups, group.DepartmentGroupId)); + + return scope; + } + public async Task CanUserViewUnitLocationAsync(string userId, int unitId, int departmentId) { var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionTypes.CanSeeUnitLocations); @@ -965,6 +1036,9 @@ public async Task CanUserDeleteCallAsync(string userId, int callId, int de callGroupId = userGroupId; } + if (!await _dispatchScopeService.CanUserAccessCallAsync(departmentId, userId, call)) + return false; + return _permissionsService.IsUserAllowed(permission, departmentId, callGroupId, userGroupId, department.IsUserAnAdmin(userId), isGroupAdmin, roles); } @@ -1004,6 +1078,9 @@ public async Task CanUserCloseCallAsync(string userId, int callId, int dep callGroupId = userGroupId; } + if (!await _dispatchScopeService.CanUserAccessCallAsync(departmentId, userId, call)) + return false; + return _permissionsService.IsUserAllowed(permission, departmentId, callGroupId, userGroupId, department.IsUserAnAdmin(userId), isGroupAdmin, roles); } @@ -1043,6 +1120,9 @@ public async Task CanUserAddCallDataAsync(string userId, int callId, int d callGroupId = userGroupId; } + if (!await _dispatchScopeService.CanUserAccessCallAsync(departmentId, userId, call)) + return false; + return _permissionsService.IsUserAllowed(permission, departmentId, callGroupId, userGroupId, department.IsUserAnAdmin(userId), isGroupAdmin, roles); } diff --git a/Core/Resgrid.Services/CallDispatchStatusService.cs b/Core/Resgrid.Services/CallDispatchStatusService.cs index 680218ec3..2fc038a77 100644 --- a/Core/Resgrid.Services/CallDispatchStatusService.cs +++ b/Core/Resgrid.Services/CallDispatchStatusService.cs @@ -200,24 +200,12 @@ private async Task> ResolveUnitStatusesAsync(int department private async Task> GetShiftUserIdsAsync(Call call, Department department, IReadOnlyCollection groupIds) { - var shiftUserIds = new HashSet(); - var shiftDate = GetShiftDate(call, department); + // Whoever is on duty for each group at dispatch time: the resolved shift roster (assigned staff, approved + // signups, single-day edits and trades), so a night shift is still covered after midnight. + var onDuty = await _shiftsService.GetOnDutyUserIdsForGroupsAsync(call.DepartmentId, groupIds, GetReferenceDate(call)); - foreach (var groupId in groupIds) - { - var signups = await _shiftsService.GetShiftSignupsByDepartmentGroupIdAndDayAsync(groupId, shiftDate); - - if (signups == null) - continue; - - foreach (var signup in signups) - { - if (!String.IsNullOrWhiteSpace(signup.UserId)) - shiftUserIds.Add(signup.UserId); - } - } - - return shiftUserIds; + return new HashSet((onDuty ?? new Dictionary>()).Values.Where(x => x != null).SelectMany(x => x) + .Where(x => !String.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase); } private static List GetDistinctIds(IEnumerable primaryIds, IEnumerable fallbackIds) @@ -225,14 +213,6 @@ private static List GetDistinctIds(IEnumerable primaryIds, IEnumerable return (primaryIds ?? fallbackIds ?? Enumerable.Empty()).Distinct().ToList(); } - private static DateTime GetShiftDate(Call call, Department department) - { - var referenceDate = GetReferenceDate(call); - var localizedDate = department != null ? TimeConverterHelper.TimeConverter(referenceDate, department) : referenceDate; - - return new DateTime(localizedDate.Year, localizedDate.Month, localizedDate.Day); - } - private static DateTime GetReferenceDate(Call call) { if (call.LastDispatchedOn.HasValue) diff --git a/Core/Resgrid.Services/DepartmentGroupsService.cs b/Core/Resgrid.Services/DepartmentGroupsService.cs index a8441c6b7..99e87b7b7 100644 --- a/Core/Resgrid.Services/DepartmentGroupsService.cs +++ b/Core/Resgrid.Services/DepartmentGroupsService.cs @@ -470,6 +470,19 @@ public async Task GetMapCenterCoordinatesForGroupAsync(int departme if (departmentGroup == null) return null; + // A station saved by coordinates (or What3Words) has no Address, and an Organizational + // group's only location is its boundary; both used to open the map on the department. + var stored = GeoMath.ParseCoordinatePair(departmentGroup.Latitude, departmentGroup.Longitude); + if (stored.HasValue) + return new Coordinates { Latitude = stored.Value.Latitude, Longitude = stored.Value.Longitude }; + + var boundary = GeoMath.ParseGeofence(departmentGroup.Geofence); + if (boundary != null) + { + var centroid = GeoMath.Centroid(boundary); + return new Coordinates { Latitude = centroid.Latitude, Longitude = centroid.Longitude }; + } + var department = await _departmentsService.GetDepartmentByIdAsync(departmentGroup.DepartmentId); if (departmentGroup.Address != null) @@ -574,6 +587,25 @@ public async Task> GetAllAdminsForGroupAsync(int gro return members.Where(x => x.IsAdmin.Equals(true)).ToList(); } + public async Task> GetAllAdminsForGroupAndAncestorsAsync(int groupId) + { + var admins = new List(); + var visited = new HashSet(); + int? currentId = groupId; + + // Parent chains are short (a service area over its stations); the visited set stops a + // corrupt parent cycle from looping. + while (currentId.HasValue && visited.Add(currentId.Value)) + { + admins.AddRange(await GetAllAdminsForGroupAsync(currentId.Value) ?? new List()); + + var group = await GetGroupByIdAsync(currentId.Value, false); + currentId = group?.ParentDepartmentGroupId; + } + + return admins; + } + public async Task GetGroupByDispatchEmailCodeAsync(string code) { var group = await _departmentGroupsRepository.GetGroupByDispatchCodeAsync(code); diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index 38e4d0515..5780127f8 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -32,6 +32,7 @@ public partial class DepartmentSettingsService : IDepartmentSettingsService private static string DispatchRecommendationModeCacheKey = "DSetDispatchRecMode_{0}"; private static string DispatchRecommendationAutoDispatchCacheKey = "DSetDispatchRecAuto_{0}"; private static string DispatchRecommendationConfigCacheKey = "DSetDispatchRecConfig_{0}"; + private static string GroupDispatchScopeConfigCacheKey = "DSetGroupDispatchScope_{0}"; private static string NewCallFieldPolicyCacheKey = "DSetNewCallFieldPolicy_{0}"; private static string UnitStatusThresholdsCacheKey = "DSetUnitStatusThresholds_{0}"; private static TimeSpan LongCacheLength = TimeSpan.FromDays(14); @@ -1156,6 +1157,54 @@ private static int ClampToRange(int value, int maximum) DepartmentSettingTypes.DispatchRecommendationConfig, cancellationToken); } + public async Task GetGroupDispatchScopeConfigAsync(int departmentId, bool bypassCache = false) + { + async Task getSetting() + { + var s = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.GroupDispatchScopeConfig); + return s?.Setting ?? string.Empty; + } + + // This setting decides who can see which calls, so it rides the shorter security window. + string value; + if (Config.SystemBehaviorConfig.CacheEnabled && !bypassCache) + value = await _cacheProvider.RetrieveAsync(string.Format(GroupDispatchScopeConfigCacheKey, departmentId), getSetting, SecuritySettingCacheLength); + else + value = await getSetting(); + + if (!String.IsNullOrWhiteSpace(value)) + { + try + { + var config = ObjectSerialization.Deserialize(value); + + if (config != null) + { + // ProtoBuf leaves an empty repeated field null. + config.DepartmentWideRoleIds = config.DepartmentWideRoleIds ?? new List(); + return config; + } + } + catch (Exception) + { + // A corrupt blob falls back to the default (scoping off) -- today's department-wide view. + } + } + + return new GroupDispatchScopeConfig(); + } + + public async Task SetGroupDispatchScopeConfigAsync(int departmentId, GroupDispatchScopeConfig config, CancellationToken cancellationToken = default(CancellationToken)) + { + if (config == null) + config = new GroupDispatchScopeConfig(); + + config.DepartmentWideRoleIds = (config.DepartmentWideRoleIds ?? new List()).Where(x => x > 0).Distinct().ToList(); + + return await SaveOrUpdateSettingAsync(departmentId, ObjectSerialization.Serialize(config), + DepartmentSettingTypes.GroupDispatchScopeConfig, cancellationToken); + } + public async Task GetPersonnelOnUnitSetUnitStatusAsync(int departmentId, bool bypassCache = false) { async Task getSetting() @@ -1409,6 +1458,9 @@ private async Task InvalidateSettingCacheAsync(int departmentId, DepartmentSetti case DepartmentSettingTypes.DispatchRecommendationConfig: cacheKey = string.Format(DispatchRecommendationConfigCacheKey, departmentId); break; + case DepartmentSettingTypes.GroupDispatchScopeConfig: + cacheKey = string.Format(GroupDispatchScopeConfigCacheKey, departmentId); + break; case DepartmentSettingTypes.NewCallFieldPolicy: cacheKey = string.Format(NewCallFieldPolicyCacheKey, departmentId); break; diff --git a/Core/Resgrid.Services/DispatchRecommendationService.cs b/Core/Resgrid.Services/DispatchRecommendationService.cs index 79c89044e..df142e61d 100644 --- a/Core/Resgrid.Services/DispatchRecommendationService.cs +++ b/Core/Resgrid.Services/DispatchRecommendationService.cs @@ -681,20 +681,16 @@ private async Task>> BuildStationRostersAsync(Re return rosters; } - // Shift-based departments dispatch today's shift roster instead of the whole - // group (same policy CallDispatchStatusService applies to group dispatches). + // Shift-based departments dispatch whoever is on duty now instead of the whole group (same policy + // CallDispatchStatusService applies to group dispatches). This used to read signups for the UTC date, which + // missed assigned staff and trades and picked the wrong day near midnight. + var onDuty = await _shiftsService.GetOnDutyUserIdsForGroupsAsync(context.Request.DepartmentId, + stations.Select(x => x.Station.DepartmentGroupId), context.Now) ?? new Dictionary>(); + foreach (var station in stations) { - var signups = await _shiftsService.GetShiftSignupsByDepartmentGroupIdAndDayAsync(station.Station.DepartmentGroupId, context.Now.Date); - var roster = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var signup in signups ?? new List()) - { - if (!string.IsNullOrWhiteSpace(signup.UserId)) - roster.Add(signup.UserId); - } - - rosters[station.Station.DepartmentGroupId] = roster; + onDuty.TryGetValue(station.Station.DepartmentGroupId, out var userIds); + rosters[station.Station.DepartmentGroupId] = new HashSet(userIds ?? new List(), StringComparer.OrdinalIgnoreCase); } return rosters; diff --git a/Core/Resgrid.Services/DispatchScopeService.cs b/Core/Resgrid.Services/DispatchScopeService.cs new file mode 100644 index 000000000..7d49eb03c --- /dev/null +++ b/Core/Resgrid.Services/DispatchScopeService.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public class DispatchScopeService : IDispatchScopeService + { + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IDepartmentsService _departmentsService; + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IPersonnelRolesService _personnelRolesService; + private readonly IUnitsService _unitsService; + private readonly ICallsService _callsService; + + /// + /// The department's groups and units, loaded at most once per operation so a list filter doesn't + /// reload them for every call. Deliberately not a field: worker code resolves services from the + /// root container, where an instance cache would never see a group or boundary change. + /// + private sealed class ScopeData + { + public List Groups { get; set; } + + public List Units { get; set; } + } + + public DispatchScopeService(IDepartmentSettingsService departmentSettingsService, IDepartmentsService departmentsService, + IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, + IUnitsService unitsService, ICallsService callsService) + { + _departmentSettingsService = departmentSettingsService; + _departmentsService = departmentsService; + _departmentGroupsService = departmentGroupsService; + _personnelRolesService = personnelRolesService; + _unitsService = unitsService; + _callsService = callsService; + } + + public async Task GetScopeForUserAsync(int departmentId, string userId) + { + var config = await _departmentSettingsService.GetGroupDispatchScopeConfigAsync(departmentId); + + if (config == null || !config.Enabled) + return DispatchScope.DepartmentWide(departmentId, userId, DispatchScopeReasons.ScopingDisabled); + + var scope = new DispatchScope { DepartmentId = departmentId, UserId = userId, Reason = DispatchScopeReasons.NoGroup }; + + if (string.IsNullOrWhiteSpace(userId)) + return scope; + + // Scoping on means the open default is gone: a non-member or disabled member gets an empty scope. + var membership = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, false); + if (membership == null || membership.IsDisabled.GetValueOrDefault() || membership.IsDeleted) + return scope; + + var isDepartmentAdmin = membership.IsAdmin.GetValueOrDefault(); + + if (!isDepartmentAdmin) + { + // The managing user is always an admin, the same carve-out the permission gates make. + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + isDepartmentAdmin = department != null && string.Equals(department.ManagingUserId, userId, StringComparison.OrdinalIgnoreCase); + } + + if (isDepartmentAdmin) + return DispatchScope.DepartmentWide(departmentId, userId, DispatchScopeReasons.DepartmentAdmin); + + if (config.DepartmentWideRoleIds != null && config.DepartmentWideRoleIds.Any()) + { + // Read uncached on purpose: assigning the dispatch-center role at shift change has to + // widen the view on the next request. + var roles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId) ?? new List(); + + if (roles.Any(r => r != null && config.DepartmentWideRoleIds.Contains(r.PersonnelRoleId))) + return DispatchScope.DepartmentWide(departmentId, userId, DispatchScopeReasons.DepartmentWideRole); + } + + var groups = await GetGroupsAsync(departmentId, new ScopeData()); + var memberships = groups.Where(g => g.IsUserInGroup(userId)).ToList(); + + if (!memberships.Any()) + return scope; + + // Membership is one group per user in the UI, but take every row the data holds so a + // stray second membership widens the scope instead of silently hiding calls. + var adminOf = memberships.FirstOrDefault(g => g.IsUserGroupAdmin(userId)); + scope.Reason = adminOf != null ? DispatchScopeReasons.GroupAdmin : DispatchScopeReasons.GroupMember; + scope.AnchorGroupId = (adminOf ?? memberships.First()).DepartmentGroupId; + + foreach (var group in memberships) + scope.GroupIds.UnionWith(DepartmentGroupHierarchy.GetSelfAndDescendantIds(groups, group.DepartmentGroupId)); + + return scope; + } + + public Task IsCallInScopeAsync(DispatchScope scope, Call call) + { + return IsCallInScopeAsync(scope, call, new ScopeData()); + } + + private async Task IsCallInScopeAsync(DispatchScope scope, Call call, ScopeData data) + { + if (scope == null || call == null || call.DepartmentId != scope.DepartmentId) + return false; + + if (scope.IsDepartmentWide) + return true; + + if (!string.IsNullOrWhiteSpace(scope.UserId) && string.Equals(call.ReportingUserId, scope.UserId, StringComparison.OrdinalIgnoreCase)) + return true; + + var groups = scope.GroupIds.Count > 0 ? await GetGroupsAsync(scope.DepartmentId, data) : new List(); + + // Cheapest test first: a call located inside one of the scope's boundaries needs no dispatch lookups. + var point = GeoMath.ParseLatLonString(call.GeoLocationData); + if (point.HasValue && groups.Any(g => scope.GroupIds.Contains(g.DepartmentGroupId) + && GeoMath.IsPointInPolygon(point.Value.Latitude, point.Value.Longitude, GeoMath.ParseGeofence(g.Geofence)))) + return true; + + await _callsService.PopulateCallData(call, true, false, false, true, true, false, false, false, false); + + if (call.Dispatches != null && call.Dispatches.Any(d => string.Equals(d.UserId, scope.UserId, StringComparison.OrdinalIgnoreCase))) + return true; + + if (scope.GroupIds.Count == 0) + return false; + + if (call.GroupDispatches != null && call.GroupDispatches.Any(d => scope.GroupIds.Contains(d.DepartmentGroupId))) + return true; + + if (call.UnitDispatches != null && call.UnitDispatches.Any()) + { + var units = await GetUnitsAsync(scope.DepartmentId, data); + var scopedUnitIds = new HashSet(units.Where(u => scope.IncludesGroup(u.StationGroupId)).Select(u => u.UnitId)); + + if (call.UnitDispatches.Any(d => scopedUnitIds.Contains(d.UnitId))) + return true; + } + + if (call.Dispatches != null && call.Dispatches.Any()) + { + var scopedMemberIds = new HashSet(groups + .Where(g => scope.GroupIds.Contains(g.DepartmentGroupId) && g.Members != null) + .SelectMany(g => g.Members) + .Where(m => !string.IsNullOrWhiteSpace(m.UserId)) + .Select(m => m.UserId), StringComparer.OrdinalIgnoreCase); + + if (call.Dispatches.Any(d => d.UserId != null && scopedMemberIds.Contains(d.UserId))) + return true; + } + + return false; + } + + public async Task CanUserAccessCallAsync(int departmentId, string userId, Call call) + { + return await IsCallInScopeAsync(await GetScopeForUserAsync(departmentId, userId), call); + } + + public async Task> FilterCallsAsync(DispatchScope scope, List calls) + { + if (calls == null) + return new List(); + + if (scope != null && scope.IsDepartmentWide) + return calls; + + var inScope = new List(); + var data = new ScopeData(); + + foreach (var call in calls) + { + if (await IsCallInScopeAsync(scope, call, data)) + inScope.Add(call); + } + + return inScope; + } + + public async Task> FilterCallsForUserAsync(int departmentId, string userId, List calls) + { + return await FilterCallsAsync(await GetScopeForUserAsync(departmentId, userId), calls); + } + + private async Task> GetGroupsAsync(int departmentId, ScopeData data) + { + if (data.Groups == null) + data.Groups = (await _departmentGroupsService.GetAllGroupsForDepartmentUnlimitedAsync(departmentId) ?? new List()) + .Where(g => g != null).ToList(); + + return data.Groups; + } + + private async Task> GetUnitsAsync(int departmentId, ScopeData data) + { + if (data.Units == null) + data.Units = (await _unitsService.GetUnitsForDepartmentUnlimitedAsync(departmentId) ?? new List()) + .Where(u => u != null).ToList(); + + return data.Units; + } + } +} diff --git a/Core/Resgrid.Services/GeoService.cs b/Core/Resgrid.Services/GeoService.cs index 9655ac9c6..dce8c35e5 100644 --- a/Core/Resgrid.Services/GeoService.cs +++ b/Core/Resgrid.Services/GeoService.cs @@ -96,7 +96,9 @@ public async Task GetEtaInSecondsAsync(string start, string destination) RouteInformation route = await _geoLocationProvider.GetRoute(start, destination); - if (route != null) + // A failed lookup comes back (and is cached) with Seconds = 0; reporting that as a + // zero-second ETA ranked the failure ahead of every real route. + if (route != null && route.Successful) { return route.Seconds; } @@ -132,6 +134,20 @@ public async Task> GetStationsContainingPointAsync(i return stations.Where(s => s.ContainsPoint).ToList(); } + public async Task> GetGroupsWithBoundaryContainingPointAsync(int departmentId, double latitude, double longitude) + { + // Any group type can own a boundary: a Station group's first-due area or an + // Organizational group's service area. + var groups = await _departmentGroupsService.GetAllGroupsForDepartmentUnlimitedAsync(departmentId); + + if (groups == null) + return new List(); + + return groups + .Where(g => g != null && GeoMath.IsPointInPolygon(latitude, longitude, GeoMath.ParseGeofence(g.Geofence))) + .ToList(); + } + public async Task> OrderStationsByDistanceAsync(int departmentId, double latitude, double longitude) { var results = new List(); diff --git a/Core/Resgrid.Services/NearestUnitService.cs b/Core/Resgrid.Services/NearestUnitService.cs new file mode 100644 index 000000000..99a636f72 --- /dev/null +++ b/Core/Resgrid.Services/NearestUnitService.cs @@ -0,0 +1,678 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Reporting; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public class NearestUnitService : INearestUnitService + { + /// Roads run roughly a third longer than the straight line between two points. + public const double EstimatedRoadDistanceFactor = 1.3; + + /// A blended urban/suburban response speed (40 km/h) for straight-line ETA estimates. + public const double EstimatedSpeedMetersPerSecond = 40000d / 3600d; + + private readonly IDispatchScopeService _dispatchScopeService; + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IUnitsService _unitsService; + private readonly IUsersService _usersService; + private readonly IPersonnelRolesService _personnelRolesService; + private readonly IActionLogsService _actionLogsService; + private readonly IUserStateService _userStateService; + private readonly ICustomStateService _customStateService; + private readonly IPersonnelLocationResolver _personnelLocationResolver; + private readonly IShiftsService _shiftsService; + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IGeoService _geoService; + private readonly IAuthorizationService _authorizationService; + + public NearestUnitService(IDispatchScopeService dispatchScopeService, IDepartmentGroupsService departmentGroupsService, + IUnitsService unitsService, IUsersService usersService, IPersonnelRolesService personnelRolesService, + IActionLogsService actionLogsService, IUserStateService userStateService, ICustomStateService customStateService, + IPersonnelLocationResolver personnelLocationResolver, IShiftsService shiftsService, + IDepartmentSettingsService departmentSettingsService, IGeoService geoService, IAuthorizationService authorizationService) + { + _dispatchScopeService = dispatchScopeService; + _departmentGroupsService = departmentGroupsService; + _unitsService = unitsService; + _usersService = usersService; + _personnelRolesService = personnelRolesService; + _actionLogsService = actionLogsService; + _userStateService = userStateService; + _customStateService = customStateService; + _personnelLocationResolver = personnelLocationResolver; + _shiftsService = shiftsService; + _departmentSettingsService = departmentSettingsService; + _geoService = geoService; + _authorizationService = authorizationService; + } + + public async Task GetBoardAsync(NearestUnitRequest request, CancellationToken cancellationToken = default(CancellationToken)) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + var now = DateTime.UtcNow; + var board = new NearestUnitBoard { Latitude = request.Latitude, Longitude = request.Longitude, GeneratedOn = now }; + + if (!IsUsableCoordinate(request.Latitude, request.Longitude)) + { + board.Notes.Add("The incident has no usable location, so nothing can be ranked by distance."); + return board; + } + + var departmentId = request.DepartmentId; + var scope = await _dispatchScopeService.GetScopeForUserAsync(departmentId, request.UserId); + board.IsDepartmentWide = scope.IsDepartmentWide; + board.ScopeReason = scope.Reason; + + var config = await _departmentSettingsService.GetDispatchRecommendationConfigAsync(departmentId) ?? new DispatchRecommendationConfig(); + + var groups = (await _departmentGroupsService.GetAllGroupsForDepartmentUnlimitedAsync(departmentId) ?? new List()) + .Where(g => g != null) + .GroupBy(g => g.DepartmentGroupId) + .Select(g => g.First()) + .ToList(); + var groupsById = groups.ToDictionary(g => g.DepartmentGroupId); + + var boundaryGroupIds = new HashSet(groups + .Where(g => GeoMath.IsPointInPolygon(request.Latitude, request.Longitude, GeoMath.ParseGeofence(g.Geofence))) + .Select(g => g.DepartmentGroupId)); + + // Every containing boundary is listed, in scope or not: a supervisor needs to see that an + // incident sits in another area even though that area's units aren't on their board. + board.ContainingBoundaries = boundaryGroupIds + .Select(id => groupsById[id]) + .OrderByDescending(g => DepartmentGroupHierarchy.GetAncestorIds(groups, g.DepartmentGroupId).Count) + .ThenBy(g => g.Name) + .Select(g => new NearestBoundaryGroup { DepartmentGroupId = g.DepartmentGroupId, Name = g.Name, GroupType = g.Type }) + .ToList(); + + var units = (await _unitsService.GetUnitsForDepartmentUnlimitedAsync(departmentId) ?? new List()).Where(u => u != null).ToList(); + var crewByUnit = BuildCrewByUnit(await _unitsService.GetAllActiveRolesForUnitsByDepartmentIdAsync(departmentId)); + var people = await BuildPeopleAsync(departmentId, config, crewByUnit, units, now); + + var unitCandidates = await BuildUnitCandidatesAsync(request, scope, config, units, groupsById, now); + var personnelCandidates = await BuildPersonnelCandidatesAsync(request, scope, people, groupsById); + + foreach (var candidate in unitCandidates.Cast().Concat(personnelCandidates).Where(c => c.Latitude.HasValue && c.Longitude.HasValue)) + { + var distance = GeoMath.HaversineMeters(request.Latitude, request.Longitude, candidate.Latitude.Value, candidate.Longitude.Value); + candidate.DistanceMeters = distance; + candidate.EtaSeconds = EstimateEtaSeconds(distance); + candidate.EtaSource = EtaSources.Estimated; + } + + var useRoadEta = request.UseRoadEta ?? config.UseRoutedEta; + if (useRoadEta) + await ApplyRoadEtasAsync(request, config, unitCandidates, personnelCandidates, board, cancellationToken); + else + board.Notes.Add("ETAs are straight-line estimates; turn on routed ETAs in the dispatch settings for drive times."); + + foreach (var candidate in unitCandidates) + await AttachCrewAsync(request, candidate, crewByUnit, people); + + board.Units = unitCandidates + .Select(c => c.ToResult(groups, groupsById, boundaryGroupIds)) + .OrderBy(u => !u.IsAvailable) + .ThenBy(u => u.PositionSource == UnitPositionSources.None) + .ThenBy(u => u.PositionIsStale) + .ThenBy(u => u.EtaSeconds ?? double.MaxValue) + .ThenBy(u => u.DistanceMeters ?? double.MaxValue) + .ThenBy(u => u.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + board.Personnel = personnelCandidates + .Select(c => c.ToResult()) + .OrderBy(p => !p.IsAvailable) + .ThenBy(p => !p.DistanceMeters.HasValue) + .ThenBy(p => p.LocationIsStale) + .ThenBy(p => p.EtaSeconds ?? double.MaxValue) + .ThenBy(p => p.DistanceMeters ?? double.MaxValue) + .ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return board; + } + + #region Candidates + + /// A ranked row plus the raw coordinates it was ranked on (withheld from the output when hidden). + private abstract class Candidate + { + public double? Latitude { get; set; } + + public double? Longitude { get; set; } + + public bool LocationHidden { get; set; } + + public bool IsAvailable { get; set; } + + public bool LocationIsStale { get; set; } + + public double? DistanceMeters { get; set; } + + public double? EtaSeconds { get; set; } + + public EtaSources EtaSource { get; set; } + } + + private sealed class UnitCandidate : Candidate + { + public Unit Unit { get; set; } + + public string StatusText { get; set; } + + public DateTime? PositionTimestamp { get; set; } + + public UnitPositionSources PositionSource { get; set; } + + public UnitCrewSources CrewSource { get; set; } + + public List CrewNames { get; } = new List(); + + public List Crew { get; } = new List(); + + public NearestUnitResult ToResult(List groups, Dictionary groupsById, HashSet boundaryGroupIds) + { + DepartmentGroup station = null; + DepartmentGroup parent = null; + + if (Unit.StationGroupId.HasValue && groupsById.TryGetValue(Unit.StationGroupId.Value, out station) && station.ParentDepartmentGroupId.HasValue) + groupsById.TryGetValue(station.ParentDepartmentGroupId.Value, out parent); + + var ancestors = station == null ? new List() : DepartmentGroupHierarchy.GetAncestorIds(groups, station.DepartmentGroupId); + + return new NearestUnitResult + { + UnitId = Unit.UnitId, + Name = Unit.Name, + UnitType = Unit.Type, + DepartmentGroupId = Unit.StationGroupId, + GroupName = station?.Name, + ParentGroupId = parent?.DepartmentGroupId, + ParentGroupName = parent?.Name, + IncidentInGroupBoundary = station != null && boundaryGroupIds.Contains(station.DepartmentGroupId), + IncidentInParentBoundary = ancestors.Any(boundaryGroupIds.Contains), + StatusText = StatusText, + IsAvailable = IsAvailable, + // A position the viewer may not see is withheld here, after it has fed the ranking. + Latitude = LocationHidden ? null : Latitude, + Longitude = LocationHidden ? null : Longitude, + PositionTimestamp = PositionTimestamp, + PositionIsStale = LocationIsStale, + PositionSource = PositionSource, + LocationHidden = LocationHidden, + DistanceMeters = DistanceMeters, + EtaSeconds = EtaSeconds, + EtaSource = EtaSource, + CrewSource = CrewSource, + Crew = CrewNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList(), + CrewCount = Crew.Count, + CrewAvailableCount = Crew.Count(p => p.IsAvailable), + OnShiftCount = Crew.Count(p => p.IsOnShift), + ShiftNames = Crew.Where(p => p.IsOnShift) + .SelectMany(p => p.ShiftNames) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(), + RoleMix = Crew + .SelectMany(p => p.Roles) + .GroupBy(r => r.PersonnelRoleId) + .Select(g => new RoleCount { PersonnelRoleId = g.Key, Name = g.First().Name, Count = g.Count() }) + .OrderByDescending(r => r.Count) + .ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase) + .ToList() + }; + } + } + + private sealed class PersonnelCandidate : Candidate + { + public Person Person { get; set; } + + public int? CoveringGroupId { get; set; } + + public string GroupName { get; set; } + + public DateTime? LocationTimestamp { get; set; } + + public NearestPersonnelResult ToResult() + { + return new NearestPersonnelResult + { + UserId = Person.UserId, + Name = Person.Name, + DepartmentGroupId = CoveringGroupId, + GroupName = GroupName, + UnitId = Person.UnitId, + UnitName = Person.UnitName, + StatusText = Person.StatusText, + StaffingText = Person.StaffingText, + IsAvailable = IsAvailable, + IsOnShift = Person.IsOnShift, + Roles = Person.Roles.Where(r => !string.IsNullOrWhiteSpace(r.Name)).Select(r => r.Name) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList(), + Latitude = LocationHidden ? null : Latitude, + Longitude = LocationHidden ? null : Longitude, + LocationTimestamp = LocationTimestamp, + LocationIsStale = LocationIsStale, + LocationHidden = LocationHidden, + DistanceMeters = DistanceMeters, + EtaSeconds = EtaSeconds, + EtaSource = EtaSource + }; + } + } + + /// Everything the board knows about one member, department-wide (before scope and visibility). + private sealed class Person + { + public string UserId { get; set; } + + public string Name { get; set; } + + public int? HomeGroupId { get; set; } + + public bool IsOnShift { get; set; } + + /// The group a running shift has the person covering; null when the shift doesn't say. + public int? ShiftGroupId { get; set; } + + public SortedSet ShiftNames { get; } = new SortedSet(StringComparer.OrdinalIgnoreCase); + + public List Roles { get; set; } = new List(); + + public string StatusText { get; set; } + + public string StaffingText { get; set; } + + public bool IsAvailable { get; set; } + + public ResolvedPersonnelLocation Location { get; set; } + + public int? UnitId { get; set; } + + public string UnitName { get; set; } + + /// Someone covering a running shift for another group counts toward that group. + public int? CoveringGroupId => ShiftGroupId ?? HomeGroupId; + } + + private static Dictionary> BuildCrewByUnit(List activeRoles) + { + return (activeRoles ?? new List()) + .Where(r => r != null && !string.IsNullOrWhiteSpace(r.UserId)) + .GroupBy(r => r.UnitId) + .ToDictionary(g => g.Key, g => g.Select(r => r.UserId).Distinct(StringComparer.OrdinalIgnoreCase).ToList()); + } + + private async Task> BuildPeopleAsync(int departmentId, DispatchRecommendationConfig config, + Dictionary> crewByUnit, List units, DateTime now) + { + var rows = await _usersService.GetUserGroupAndRolesByDepartmentIdAsync(departmentId, false, false, false) ?? new List(); + var rolesByUser = await _personnelRolesService.GetAllRolesForUsersInDepartmentAsync(departmentId) ?? new Dictionary>(); + var actionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(departmentId) ?? new List(); + var userStates = await _userStateService.GetLatestStatesForDepartmentAsync(departmentId) ?? new List(); + var personnelDetails = BuildCustomDetailMap(await _customStateService.GetActivePersonnelStateForDepartmentAsync(departmentId)); + var staffingDetails = BuildCustomDetailMap(await _customStateService.GetActiveStaffingLevelsForDepartmentAsync(departmentId)); + var locations = await _personnelLocationResolver.GetLatestLocationsAsync(departmentId, config.PersonnelMaxLocationAgeSeconds, now) + ?? new Dictionary(); + var onShift = await _shiftsService.GetOnShiftPersonnelAsync(departmentId, now) ?? new List(); + + var logByUser = actionLogs.Where(l => !string.IsNullOrWhiteSpace(l?.UserId)) + .GroupBy(l => l.UserId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderByDescending(l => l.Timestamp).First(), StringComparer.OrdinalIgnoreCase); + var stateByUser = userStates.Where(s => !string.IsNullOrWhiteSpace(s?.UserId)) + .GroupBy(s => s.UserId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderByDescending(s => s.Timestamp).First(), StringComparer.OrdinalIgnoreCase); + var unitNames = units.ToDictionary(u => u.UnitId, u => u.Name); + var unitByUser = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var crew in crewByUnit) + foreach (var userId in crew.Value) + unitByUser.TryAdd(userId, crew.Key); + + var people = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows.Where(r => !string.IsNullOrWhiteSpace(r?.UserId))) + { + if (people.ContainsKey(row.UserId)) + continue; + + logByUser.TryGetValue(row.UserId, out var lastLog); + stateByUser.TryGetValue(row.UserId, out var lastState); + locations.TryGetValue(row.UserId, out var location); + + var person = new Person + { + UserId = row.UserId, + Name = row.Name?.Trim(), + HomeGroupId = row.DepartmentGroupId, + Roles = rolesByUser.TryGetValue(row.UserId, out var roles) ? roles.Where(r => r != null).ToList() : new List(), + StatusText = GetPersonnelStatusText(lastLog, personnelDetails), + StaffingText = GetStaffingText(lastState, staffingDetails), + IsAvailable = IsPersonnelStatusAvailable(lastLog, personnelDetails) && IsPersonnelStaffingAvailable(lastState, staffingDetails), + Location = location != null && IsUsableCoordinate(location.Latitude, location.Longitude) ? location : null + }; + + if (unitByUser.TryGetValue(row.UserId, out var unitId)) + { + person.UnitId = unitId; + person.UnitName = unitNames.TryGetValue(unitId, out var unitName) ? unitName : null; + } + + people[row.UserId] = person; + } + + foreach (var assignment in onShift.Where(a => !string.IsNullOrWhiteSpace(a?.UserId))) + { + if (!people.TryGetValue(assignment.UserId, out var person)) + continue; + + // A shift that names the group wins over one that doesn't. + if (!person.IsOnShift || (!person.ShiftGroupId.HasValue && assignment.DepartmentGroupId.HasValue)) + person.ShiftGroupId = assignment.DepartmentGroupId; + + person.IsOnShift = true; + + if (!string.IsNullOrWhiteSpace(assignment.ShiftName)) + person.ShiftNames.Add(assignment.ShiftName); + } + + return people; + } + + private async Task> BuildUnitCandidatesAsync(NearestUnitRequest request, DispatchScope scope, + DispatchRecommendationConfig config, List units, Dictionary groupsById, DateTime now) + { + var departmentId = request.DepartmentId; + var candidates = new List(); + + var states = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(departmentId) ?? new List(); + var customDetails = BuildCustomDetailMap(await _customStateService.GetAllActiveUnitStatesForDepartmentAsync(departmentId)); + var locations = await _unitsService.GetLatestUnitLocationsAsync(departmentId) ?? new List(); + + var stateByUnit = states.Where(s => s != null).GroupBy(s => s.UnitId).ToDictionary(g => g.Key, g => g.OrderByDescending(s => s.Timestamp).First()); + var locationByUnit = locations + .Where(l => l != null && l.IsValidFix != false && !(l.Latitude == 0 && l.Longitude == 0)) + .GroupBy(l => l.UnitId) + .ToDictionary(g => g.Key, g => g.OrderByDescending(l => l.Timestamp).First()); + var stationPoints = new Dictionary(); + + foreach (var unit in units) + { + if (!scope.IncludesGroup(unit.StationGroupId)) + continue; + + if (!await _authorizationService.CanUserViewUnitViaMatrixAsync(unit.UnitId, request.UserId, departmentId)) + continue; + + var stateId = stateByUnit.TryGetValue(unit.UnitId, out var state) ? state.State : (int)UnitStateTypes.Available; + var isCustom = customDetails.ContainsKey(stateId); + + var candidate = new UnitCandidate + { + Unit = unit, + StatusText = GetUnitStatusText(stateId, isCustom, customDetails), + IsAvailable = IsUnitAvailable(stateId, isCustom, customDetails) + }; + + if (locationByUnit.TryGetValue(unit.UnitId, out var location)) + { + candidate.Latitude = (double)location.Latitude; + candidate.Longitude = (double)location.Longitude; + candidate.PositionTimestamp = location.Timestamp; + candidate.PositionSource = UnitPositionSources.Live; + candidate.LocationIsStale = config.MaxLocationAgeSeconds > 0 && (now - location.Timestamp).TotalSeconds > config.MaxLocationAgeSeconds; + candidate.LocationHidden = !await _authorizationService.CanUserViewUnitLocationViaMatrixAsync(unit.UnitId, request.UserId, departmentId); + } + else if (unit.StationGroupId.HasValue && groupsById.TryGetValue(unit.StationGroupId.Value, out var station)) + { + // No GPS on the unit: it is taken to be at its station, which is public knowledge, not a tracked position. + if (!stationPoints.TryGetValue(station.DepartmentGroupId, out var point)) + stationPoints[station.DepartmentGroupId] = point = await _geoService.GetStationCoordinatesAsync(station); + + if (point.HasValue) + { + candidate.Latitude = point.Value.Latitude; + candidate.Longitude = point.Value.Longitude; + candidate.PositionSource = UnitPositionSources.Station; + } + } + + candidates.Add(candidate); + } + + return candidates; + } + + private async Task> BuildPersonnelCandidatesAsync(NearestUnitRequest request, DispatchScope scope, + Dictionary people, Dictionary groupsById) + { + var candidates = new List(); + + foreach (var person in people.Values) + { + if (!scope.IncludesGroup(person.CoveringGroupId)) + continue; + + if (!await _authorizationService.CanUserViewPersonViaMatrixAsync(person.UserId, request.UserId, request.DepartmentId)) + continue; + + var candidate = new PersonnelCandidate + { + Person = person, + CoveringGroupId = person.CoveringGroupId, + GroupName = person.CoveringGroupId.HasValue && groupsById.TryGetValue(person.CoveringGroupId.Value, out var group) ? group.Name : null, + IsAvailable = person.IsAvailable + }; + + if (person.Location != null) + { + candidate.Latitude = person.Location.Latitude; + candidate.Longitude = person.Location.Longitude; + candidate.LocationTimestamp = person.Location.Timestamp; + candidate.LocationIsStale = person.Location.IsStale; + candidate.LocationHidden = !await _authorizationService.CanUserViewPersonLocationViaMatrixAsync(person.UserId, request.UserId, request.DepartmentId); + } + + candidates.Add(candidate); + } + + return candidates; + } + + #endregion + + #region Crew and ETAs + + private async Task AttachCrewAsync(NearestUnitRequest request, UnitCandidate candidate, Dictionary> crewByUnit, + Dictionary people) + { + List crew; + + if (crewByUnit.TryGetValue(candidate.Unit.UnitId, out var assigned) && assigned.Any()) + { + crew = assigned.Where(people.ContainsKey).Select(id => people[id]).ToList(); + candidate.CrewSource = UnitCrewSources.Assigned; + } + else + { + // Nobody in the unit's seats: whoever is on a running shift at its station is its crew. + crew = candidate.Unit.StationGroupId.HasValue + ? people.Values.Where(p => p.IsOnShift && p.CoveringGroupId == candidate.Unit.StationGroupId).ToList() + : new List(); + candidate.CrewSource = crew.Any() ? UnitCrewSources.StationShift : UnitCrewSources.None; + } + + candidate.Crew.AddRange(crew); + + // Counts and roles describe the unit; names are only shown for people the viewer may see. + foreach (var person in crew) + { + if (await _authorizationService.CanUserViewPersonViaMatrixAsync(person.UserId, request.UserId, request.DepartmentId)) + candidate.CrewNames.Add(person.Name); + } + } + + private async Task ApplyRoadEtasAsync(NearestUnitRequest request, DispatchRecommendationConfig config, List units, + List personnel, NearestUnitBoard board, CancellationToken cancellationToken) + { + // Each lookup is a mapping-provider call made while the dispatcher waits, so the count is + // bounded by the department's ETA shortlist size (itself capped). + var budget = config.EtaShortlistSize > 0 + ? Math.Min(config.EtaShortlistSize, DispatchRecommendationConfig.MaximumEtaShortlistSize) + : DispatchRecommendationConfig.DefaultEtaShortlistSize; + + // Units are what gets dispatched, so they take the budget first; individual responders get what's left. + var shortlist = units + .Where(c => c.IsAvailable && c.DistanceMeters.HasValue && !c.LocationIsStale) + .OrderBy(c => c.DistanceMeters.Value) + .Cast() + .Concat(personnel + .Where(c => c.IsAvailable && c.DistanceMeters.HasValue && !c.LocationIsStale) + .OrderBy(c => c.DistanceMeters.Value)) + .Take(budget) + .ToList(); + + var destination = FormatPoint(request.Latitude, request.Longitude); + + foreach (var candidate in shortlist) + { + cancellationToken.ThrowIfCancellationRequested(); + + var eta = await _geoService.GetEtaInSecondsAsync(FormatPoint(candidate.Latitude.Value, candidate.Longitude.Value), destination); + + if (eta >= 0) + { + candidate.EtaSeconds = eta; + candidate.EtaSource = EtaSources.Road; + } + } + + if (shortlist.Any()) + board.Notes.Add($"Drive times were looked up for the {shortlist.Count} closest available units and responders; the rest are straight-line estimates."); + } + + public static double EstimateEtaSeconds(double distanceMeters) + { + return Math.Round(distanceMeters * EstimatedRoadDistanceFactor / EstimatedSpeedMetersPerSecond); + } + + private static string FormatPoint(double latitude, double longitude) + { + return string.Format(CultureInfo.InvariantCulture, "{0},{1}", latitude, longitude); + } + + private static bool IsUsableCoordinate(double latitude, double longitude) + { + return !double.IsNaN(latitude) && !double.IsNaN(longitude) + && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180 + && !(latitude == 0 && longitude == 0); + } + + #endregion + + #region Availability + // The run card engine's default path (no run card status selections): the shared availability + // matrix decides, and Delayed still counts as available. + + private static Dictionary BuildCustomDetailMap(IEnumerable states) + { + var map = new Dictionary(); + + foreach (var state in states ?? Enumerable.Empty()) + { + if (state == null) + continue; + + foreach (var detail in state.GetActiveDetails() ?? new List()) + map[detail.CustomStateDetailId] = detail; + } + + return map; + } + + private static Dictionary BuildCustomDetailMap(CustomState state) + { + return BuildCustomDetailMap(state == null ? null : new[] { state }); + } + + private static bool IsAvailableClass(AvailabilityClass availability) + { + return availability == AvailabilityClass.Available || availability == AvailabilityClass.Delayed; + } + + private static bool IsUnitAvailable(int stateId, bool isCustom, Dictionary customDetails) + { + return IsAvailableClass(isCustom + ? AvailabilityMatrix.ForCustomBaseType((int)customDetails[stateId].BaseType) + : AvailabilityMatrix.ForUnitStateType(stateId)); + } + + private static bool IsPersonnelStatusAvailable(ActionLog lastLog, Dictionary customDetails) + { + // No status on file: the member never set one and is treated as standing by. + if (lastLog == null) + return true; + + return IsAvailableClass(customDetails.TryGetValue(lastLog.ActionTypeId, out var detail) + ? AvailabilityMatrix.ForCustomBaseType((int)detail.BaseType) + : AvailabilityMatrix.ForBuiltInPersonnelActionType(lastLog.ActionTypeId)); + } + + private static bool IsPersonnelStaffingAvailable(UserState lastState, Dictionary customDetails) + { + // No staffing row defaults to Available (UserStateService semantics). + if (lastState == null) + return true; + + if (customDetails.TryGetValue(lastState.State, out var detail)) + return IsAvailableClass(AvailabilityMatrix.ForCustomBaseType((int)detail.BaseType)); + + return lastState.State == (int)UserStateTypes.Available + || lastState.State == (int)UserStateTypes.OnShift + || lastState.State == (int)UserStateTypes.Delayed; + } + + private static string GetUnitStatusText(int stateId, bool isCustom, Dictionary customDetails) + { + if (isCustom && customDetails.TryGetValue(stateId, out var detail)) + return detail.ButtonText; + + return Enum.IsDefined(typeof(UnitStateTypes), stateId) ? ((UnitStateTypes)stateId).ToString() : stateId.ToString(CultureInfo.InvariantCulture); + } + + private static string GetPersonnelStatusText(ActionLog lastLog, Dictionary customDetails) + { + if (lastLog == null) + return ActionTypes.StandingBy.ToString(); + + if (customDetails.TryGetValue(lastLog.ActionTypeId, out var detail)) + return detail.ButtonText; + + return Enum.IsDefined(typeof(ActionTypes), lastLog.ActionTypeId) ? ((ActionTypes)lastLog.ActionTypeId).ToString() : lastLog.ActionTypeId.ToString(CultureInfo.InvariantCulture); + } + + private static string GetStaffingText(UserState lastState, Dictionary customDetails) + { + if (lastState == null) + return UserStateTypes.Available.ToString(); + + if (customDetails.TryGetValue(lastState.State, out var detail)) + return detail.ButtonText; + + return Enum.IsDefined(typeof(UserStateTypes), lastState.State) ? ((UserStateTypes)lastState.State).ToString() : lastState.State.ToString(CultureInfo.InvariantCulture); + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index c86d11ac5..340364b7d 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -86,6 +86,14 @@ public class ProtectedFieldCatalog : IProtectedFieldCatalog /// public const int PreventionCatalogVersion = 13; + /// + /// Protected Workflows for EHR integration (catalog 29, registered with M0233): Calls.SubjectIdentifiers, the JSON + /// object of external subject and record identifiers (EHR client id, encounter id). The id calls.subjectidentifiers + /// is part of the envelope AAD and never changes. A department pinned below 29 keeps the column plaintext until the + /// catalog upgrade sweep reaches it. + /// + public const int SubjectIdentifiersCatalogVersion = 29; + private static readonly IReadOnlyList Entries = BuildV1(); private static readonly Dictionary ById = Entries.ToDictionary(e => e.FieldId, StringComparer.OrdinalIgnoreCase); @@ -752,6 +760,12 @@ void Prevention(string table, string column, ProtectedFieldClassification classi list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", PersonnelFamily, table, column, binary ? ProtectedFieldStorageKind.Binary : ProtectedFieldStorageKind.Text, ProtectedFieldClassification.Pii, PermissionTypes.ViewProtectedPersonnelData, PermissionTypes.ViewProtectedPersonnelData, Resgrid.Model.Workforce.WorkforceProtectedFields.CatalogVersion)); + + // Protected Workflows for EHR integration (catalog 29): the subject identifiers identify a person in another + // system (EHR client id, encounter id), so they are PII in the Calls family. + list.Add(new ProtectedFieldDefinition("calls.subjectidentifiers", CallsFamily, "Calls", "SubjectIdentifiers", + ProtectedFieldStorageKind.Text, ProtectedFieldClassification.Pii, PermissionTypes.ViewProtectedCallData, + PermissionTypes.EditProtectedCallData, SubjectIdentifiersCatalogVersion)); return list; } } diff --git a/Core/Resgrid.Services/ProtectedReadService.cs b/Core/Resgrid.Services/ProtectedReadService.cs index 152ba2a5a..295211732 100644 --- a/Core/Resgrid.Services/ProtectedReadService.cs +++ b/Core/Resgrid.Services/ProtectedReadService.cs @@ -43,7 +43,8 @@ public class ProtectedReadService : IProtectedReadService, IProtectedWriteServic ["calls.externalidentifier"] = (c => c.ExternalIdentifier, (c, v) => c.ExternalIdentifier = v), ["calls.referencenumber"] = (c => c.ReferenceNumber, (c, v) => c.ReferenceNumber = v), ["calls.callformdata"] = (c => c.CallFormData, (c, v) => c.CallFormData = v), - ["calls.deletedreason"] = (c => c.DeletedReason, (c, v) => c.DeletedReason = v) + ["calls.deletedreason"] = (c => c.DeletedReason, (c, v) => c.DeletedReason = v), + ["calls.subjectidentifiers"] = (c => c.SubjectIdentifiers, (c, v) => c.SubjectIdentifiers = v) }; /// CallNotes text columns (parity-pinned). diff --git a/Core/Resgrid.Services/ProtectedWorkflowRuntime.cs b/Core/Resgrid.Services/ProtectedWorkflowRuntime.cs new file mode 100644 index 000000000..a34fb15ae --- /dev/null +++ b/Core/Resgrid.Services/ProtectedWorkflowRuntime.cs @@ -0,0 +1,544 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Scriban.Runtime; + +namespace Resgrid.Services +{ + /// + /// Unattended half of Protected Workflows. See . Every check reads fresh + /// (bypassing caches) because it gates a disclosure; every failure is closed. Plaintext produced here lives only + /// in the returned ScriptObject and is never logged: log lines carry ids, codes and exception type names only. + /// + public class ProtectedWorkflowRuntime : IProtectedWorkflowRuntime + { + private readonly IWorkflowProtectedReleaseRepository _releases; + private readonly IWorkflowStepRepository _steps; + private readonly IWorkflowCredentialRepository _credentials; + private readonly IDepartmentDataProtectionService _dataProtection; + private readonly IProtectedDataBrokerClient _broker; + private readonly IProtectedWorkflowService _service; + private readonly ICallsRepository _calls; + private readonly IUdfFieldValueRepository _udfValues; + private readonly IProtectedWriteService _protectedWrites; + + /// Attempts at the conditional subject-identifier write before a capture gives up (a concurrent edit won each time). + private const int CaptureWriteAttempts = 3; + + public ProtectedWorkflowRuntime(IWorkflowProtectedReleaseRepository releases, IWorkflowStepRepository steps, + IWorkflowCredentialRepository credentials, IDepartmentDataProtectionService dataProtection, + IProtectedDataBrokerClient broker, IProtectedWorkflowService service, ICallsRepository calls, + IUdfFieldValueRepository udfValues, IProtectedWriteService protectedWrites) + { + _calls = calls; + _udfValues = udfValues; + _protectedWrites = protectedWrites; + _releases = releases; + _steps = steps; + _credentials = credentials; + _dataProtection = dataProtection; + _broker = broker; + _service = service; + } + + public async Task GetRunGateAsync(Workflow workflow, CancellationToken cancellationToken = default) + { + if (workflow == null || string.IsNullOrWhiteSpace(workflow.WorkflowId)) + return ProtectedRunGate.NotProtected; + + var release = await _releases.GetLatestByWorkflowIdAsync(workflow.WorkflowId); + if (release == null) + return ProtectedRunGate.NotProtected; + + if (release.DepartmentId != workflow.DepartmentId) + return new ProtectedRunGate { SkipReason = "protected_release_invalid", Release = release }; + + if (release.ReleaseState == ProtectedReleaseState.Active) + return new ProtectedRunGate { IsProtected = true, Release = release }; + + // Never run unprotected: the destination expects plaintext, and REDACTED would overwrite the real record. + return new ProtectedRunGate { SkipReason = SkipReasonFor(release.ReleaseState), Release = release }; + } + + public static string SkipReasonFor(ProtectedReleaseState state) => state switch + { + ProtectedReleaseState.Draft => "protected_release_draft", + ProtectedReleaseState.PendingApproval => "protected_release_pending_approval", + ProtectedReleaseState.Suspended => "protected_release_suspended", + ProtectedReleaseState.Expired => "protected_release_expired", + ProtectedReleaseState.Revoked => "protected_release_revoked", + _ => "protected_release_invalid" + }; + + public async Task AuthorizeStepAsync(Workflow workflow, WorkflowStep step, string renderedActionConfig, + CancellationToken cancellationToken = default) + { + if (workflow == null || step == null) + return ProtectedStepAuthorization.Block(null, ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.ReleaseNotActive); + + var release = await _releases.GetLatestByWorkflowIdAsync(workflow.WorkflowId); + if (release == null || release.DepartmentId != workflow.DepartmentId || release.ReleaseState != ProtectedReleaseState.Active) + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.ReleaseNotActive); + + var departmentId = workflow.DepartmentId; + + // 1. ADP must be actively protecting the department (Enabled or Rotating). Offboarding or disabled revokes. + var policy = await _dataProtection.GetPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + if (ProtectedWorkflowService.IsOffboardingOrDisabled(policy)) + { + await _service.RevokeAllForDepartmentAsync(departmentId, ProtectedWorkflowSuspendReasons.AdpOffboarding, + ProtectedWorkflowService.SystemActor, cancellationToken); + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedDepartment, ProtectedWorkflowErrorCodes.AdpNotEnabled); + } + + var state = (DepartmentDataProtectionState)policy.State; + if (state != DepartmentDataProtectionState.Enabled && state != DepartmentDataProtectionState.Rotating) + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedDepartment, ProtectedWorkflowErrorCodes.AdpNotEnabled); + + // 2. The department toggle. + var egress = await _dataProtection.GetEgressPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + if (egress == null || !egress.ProtectedWorkflowsEnabled) + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedDepartment, ProtectedWorkflowErrorCodes.DepartmentDisabled); + + // 3. Unexpired. + if (!release.ExpiresOn.HasValue || release.ExpiresOn.Value <= DateTime.UtcNow) + { + await _service.MarkExpiredAsync(release, cancellationToken); + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.ReleaseExpired); + } + + // 4. The configuration is exactly what was approved (the save-time hook is the fast path; this is the backstop). + var steps = (await _steps.GetAllByWorkflowIdAsync(workflow.WorkflowId))?.ToList() ?? new List(); + var fingerprint = await _service.ComputeCurrentFingerprintAsync(workflow, steps, release); + if (!string.Equals(fingerprint, release.ConfigFingerprint, StringComparison.Ordinal)) + { + await _service.OnWorkflowConfigurationChangedAsync(workflow.WorkflowId, ProtectedWorkflowService.SystemActor, cancellationToken); + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.ConfigChanged); + } + + // 5. Only API POST or PUT. + if (!ProtectedWorkflowValidator.IsAllowedActionType(step.ActionType)) + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.ActionNotAllowed); + + // 6. The pinned credential, still of an allowed type. + if (string.IsNullOrWhiteSpace(step.WorkflowCredentialId) || + !string.Equals(step.WorkflowCredentialId.Trim(), release.WorkflowCredentialId, StringComparison.OrdinalIgnoreCase)) + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.CredentialNotAllowed); + + var credential = await _credentials.GetByIdAsync(step.WorkflowCredentialId.Trim()); + if (credential == null || credential.DepartmentId != departmentId || + !ProtectedWorkflowValidator.IsAllowedCredentialType(credential.CredentialType, DataProtectionConfig.ProtectedWorkflowAllowHttpBasicCredentials)) + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.CredentialNotAllowed); + + // 6b. An OAuth2 credential still authenticates the pinned way, against the pinned token host (the save hook + // suspends on a change; this is the backstop for an edit that bypassed it). + if (credential.CredentialType == (int)WorkflowCredentialType.OAuth2ClientCredentials) + { + var pins = await _service.GetCredentialPinsAsync(departmentId, credential.WorkflowCredentialId); + if (pins?.TokenHost == null || !string.Equals(pins.TokenHost, release.TokenHost, StringComparison.Ordinal)) + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.TokenHostMismatch); + if (!string.Equals(pins.AuthMethod, release.AuthMethod, StringComparison.Ordinal)) + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.AuthMethodMismatch); + } + + // 7. The URL as rendered: https, and exactly the pinned host. + if (!ProtectedWorkflowValidator.TryGetRenderedHttpsHost(ProtectedWorkflowValidator.ReadUrl(renderedActionConfig), out var host, out var urlError)) + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedHost, + urlError == ProtectedWorkflowValidator.SchemeNotHttps ? ProtectedWorkflowErrorCodes.SchemeNotHttps : ProtectedWorkflowErrorCodes.HostMismatch); + + if (!string.Equals(host, release.DestinationHost, StringComparison.Ordinal)) + return ProtectedStepAuthorization.Block(release, ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.HostMismatch); + + return ProtectedStepAuthorization.Allow(release); + } + + public async Task ResolveReleasedValuesAsync(Workflow workflow, WorkflowProtectedRelease release, string eventPayloadJson, + CancellationToken cancellationToken = default) + { + var requestId = Guid.NewGuid().ToString("N"); + var entityType = ProtectedWorkflowFieldCatalog.EntityTypeFor(workflow?.TriggerEventType ?? -1); + if (workflow == null || release == null || entityType == null) + return Failed(ProtectedWorkflowErrorCodes.EntityUnavailable, null, entityType, null); + + // The queued payload is the SAFE projection (every cataloged value already reads REDACTED), so it only ever + // supplies the call id. The values come from the stored row, where they are still envelopes. + var callId = ReadCall(eventPayloadJson)?.CallId ?? 0; + var entityId = callId > 0 ? callId.ToString(CultureInfo.InvariantCulture) : null; + if (callId <= 0) + return Failed(ProtectedWorkflowErrorCodes.EntityUnavailable, null, entityType, entityId); + + var call = await _calls.GetByIdAsync(callId); + if (call == null || call.DepartmentId != release.DepartmentId) + return Failed(ProtectedWorkflowErrorCodes.EntityUnavailable, null, entityType, entityId); + + // Minimum necessary: ONLY the allow-listed fields are read, sent to the broker or rendered. + var plan = ProtectedWorkflowFieldCatalog.Plan(workflow.TriggerEventType, release.GetAllowedFieldIds()); + if (plan.IsEmpty || plan.Unknown.Count > 0 || plan.HasConflict) + return Failed(ProtectedWorkflowErrorCodes.EntityUnavailable, null, entityType, entityId); + + // Custom fields: the tag each one carries NOW decides whether this call may be sent at all — before anything + // is decrypted. A Part 2 field needs Part 2 consent on file for this call. + var customValues = new List<(string Name, UdfField Field, UdfFieldValue Value)>(); + if (plan.UdfNames.Count > 0) + { + var customFields = await _service.GetCallCustomFieldsAsync(release.DepartmentId); + foreach (var name in plan.UdfNames) + { + var field = customFields.FirstOrDefault(f => string.Equals(f.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase)); + if (field == null || + (field.Sensitivity == (int)UdfFieldSensitivity.Restricted && !release.AllowsRestricted) || + (field.Sensitivity == (int)UdfFieldSensitivity.Part2 && !release.AllowsPart2)) + return Failed(ProtectedWorkflowErrorCodes.ConfigChanged, null, entityType, entityId, ProtectedWorkflowDisclosureOutcomes.BlockedRelease); + if (field.Sensitivity == (int)UdfFieldSensitivity.Part2 && !call.Part2ConsentOnFile) + return Failed(ProtectedWorkflowErrorCodes.ConsentMissing, null, entityType, entityId, ProtectedWorkflowDisclosureOutcomes.BlockedConsent); + customValues.Add((name, field, null)); + } + + var definitionId = customValues[0].Field.UdfDefinitionId; + var stored = ((await _udfValues.GetFieldValuesByEntityAsync((int)UdfEntityType.Call, entityId, definitionId)) ?? Enumerable.Empty()) + .Where(v => !string.IsNullOrEmpty(v.UdfFieldId)) + .GroupBy(v => v.UdfFieldId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Last(), StringComparer.Ordinal); + customValues = customValues + .Select(c => (c.Name, c.Field, stored.TryGetValue(c.Field.UdfFieldId ?? string.Empty, out var value) ? value : null)) + .ToList(); + } + + // Gather what has to go to the broker; plaintext stored before enrollment needs no decrypt. + var plain = new Dictionary<(string RowKey, string FieldId), string>(); + var items = new List(); + bool Queue(string fieldId, string rowKey, string raw) + { + if (string.Equals(raw, ProtectedDataEnvelope.RedactionValue, StringComparison.Ordinal)) + return false; // never send the placeholder + if (string.IsNullOrEmpty(raw)) + plain[(rowKey, fieldId)] = string.Empty; + else if (ProtectedDataEnvelope.HasEnvelopePrefix(raw)) + items.Add(new ProtectedFieldOperationItem { FieldId = fieldId, RowKey = rowKey, Value = raw }); + else + plain[(rowKey, fieldId)] = raw; + return true; + } + + foreach (var column in plan.Columns) + if (!Queue(column.FieldId, entityId, column.GetCallValue(call))) + return Failed(ProtectedWorkflowErrorCodes.EntityUnavailable, null, entityType, entityId); + if (plan.ReadsSubjectIdentifiers && !Queue(ProtectedWorkflowFieldCatalog.SubjectIdentifiersFieldId, entityId, call.SubjectIdentifiers)) + return Failed(ProtectedWorkflowErrorCodes.EntityUnavailable, null, entityType, entityId); + foreach (var custom in customValues.Where(c => c.Value != null)) + if (!Queue(ProtectedWorkflowFieldCatalog.UdfValueCatalogFieldId, custom.Value.UdfFieldValueId, custom.Value.Value)) + return Failed(ProtectedWorkflowErrorCodes.EntityUnavailable, null, entityType, entityId); + + if (items.Count > 0) + { + var decrypted = await DecryptAsync(workflow, release, requestId, items, cancellationToken); + if (decrypted.ErrorCode != null) + return Failed(decrypted.ErrorCode, requestId, entityType, entityId); + foreach (var pair in decrypted.Values) + plain[pair.Key] = pair.Value; + } + + // Projection. The subject identifiers are parsed immediately and cut down to the allow-listed keys; the full + // plaintext string is not kept. Malformed identifiers fail the step: never a fall back to the raw text. + ScriptObject subjectIds = null; + if (plan.ReadsSubjectIdentifiers) + { + plain.TryGetValue((entityId, ProtectedWorkflowFieldCatalog.SubjectIdentifiersFieldId), out var identifiersJson); + if (!CallSubjectIdentifiers.TryParse(identifiersJson, out var identifiers)) + return Failed(ProtectedWorkflowErrorCodes.ProjectionFailed, items.Count > 0 ? requestId : null, entityType, entityId, + ProtectedWorkflowDisclosureOutcomes.FailedProjection); + plain.Remove((entityId, ProtectedWorkflowFieldCatalog.SubjectIdentifiersFieldId)); + + subjectIds = new ScriptObject(); + foreach (var pair in identifiers.Where(p => plan.SubjectIdentifiersWhole || plan.SubjectIdentifierKeys.Contains(p.Key))) + subjectIds[pair.Key] = pair.Value; + } + + var columnValues = plan.Columns.ToDictionary(c => c.FieldId, + c => plain.TryGetValue((entityId, c.FieldId), out var value) ? value : string.Empty, StringComparer.Ordinal); + var customResults = customValues.Select(c => (c.Field.Name.Trim(), + c.Value != null && plain.TryGetValue((c.Value.UdfFieldValueId, ProtectedWorkflowFieldCatalog.UdfValueCatalogFieldId), out var value) ? value : string.Empty)); + + return new ProtectedReleasedValues + { + Success = true, + Namespace = BuildNamespace(plan.Columns, columnValues, subjectIds, plan.UdfNames.Count > 0 ? customResults : null), + FieldIds = release.GetAllowedFieldIds(), + EntityType = entityType, + EntityId = entityId, + BrokerRequestId = items.Count > 0 ? requestId : null + }; + } + + /// One workload decrypt of the queued items (fresh request id). Any missing or failed item fails the whole call. + private async Task<(string ErrorCode, Dictionary<(string RowKey, string FieldId), string> Values)> DecryptAsync(Workflow workflow, + WorkflowProtectedRelease release, string requestId, List items, CancellationToken cancellationToken) + { + if (!_broker.IsConfigured) + return (ProtectedWorkflowErrorCodes.BrokerFailed, null); + + int catalogVersion; + try + { + catalogVersion = (await _dataProtection.GetPolicyByDepartmentIdAsync(release.DepartmentId, bypassCache: true))?.CatalogVersion ?? 0; + } + catch (Exception ex) + { + Logging.LogError($"Protected workflow {workflow.WorkflowId}: protection lookup failed ({ex.GetType().FullName})."); + return (ProtectedWorkflowErrorCodes.BrokerFailed, null); + } + + foreach (var item in items) + item.CatalogVersion = catalogVersion; + + ProtectedDataBrokerResult result; + try + { + // A fresh request id per attempt: the broker's replay protection refuses a reused one. + result = await _broker.DecryptForWorkloadAsync(release.DepartmentId, ProtectedWorkflowDefaults.WorkloadPurpose, requestId, items, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogError($"Protected workflow {workflow.WorkflowId}: broker call failed ({ex.GetType().FullName})."); + return (ProtectedWorkflowErrorCodes.BrokerFailed, null); + } + + if (result == null || !result.Success) + return (BrokerCode(result?.ErrorCode), null); + + var decrypted = (result.Items ?? new List()) + .Where(i => i?.FieldId != null && i.RowKey != null) + .GroupBy(i => (i.RowKey, FieldId: i.FieldId.ToLowerInvariant())) + .ToDictionary(g => g.Key, g => g.First()); + + var values = new Dictionary<(string RowKey, string FieldId), string>(); + foreach (var item in items) + { + // Every requested field must come back; one missing value fails the whole send (never partial). + if (!decrypted.TryGetValue((item.RowKey, item.FieldId), out var outcome) || outcome.ErrorCode != null || outcome.Value == null) + return (BrokerCode(outcome?.ErrorCode), null); + values[(item.RowKey, item.FieldId)] = outcome.Value; + } + + return (null, values); + } + + public ProtectedReleasedValues BuildSampleValues(int triggerEventType, IEnumerable fieldIds) + { + var plan = ProtectedWorkflowFieldCatalog.Plan(triggerEventType, fieldIds); + var values = plan.Columns.ToDictionary(f => f.FieldId, f => SampleValue(f), StringComparer.Ordinal); + var subjectIds = plan.ReadsSubjectIdentifiers + ? WorkflowSampleDataGenerator.SampleSubjectIdentifiers(plan.SubjectIdentifiersWhole ? null : plan.SubjectIdentifierKeys) + : null; + var custom = plan.UdfNames.Select(n => (n, WorkflowSampleDataGenerator.SampleCustomFieldValue(n))); + + return new ProtectedReleasedValues + { + Success = true, + Namespace = BuildNamespace(plan.Columns, values, subjectIds, plan.UdfNames.Count > 0 ? custom : null), + FieldIds = WorkflowProtectedRelease.NormalizeFieldIds(fieldIds), + EntityType = ProtectedWorkflowFieldCatalog.EntityTypeFor(triggerEventType), + EntityId = "sample" + }; + } + + public async Task WriteCapturedValuesAsync(Workflow workflow, WorkflowProtectedRelease release, string entityId, + IReadOnlyDictionary values, CancellationToken cancellationToken = default) + { + if (values == null || values.Count == 0) + return new ProtectedCaptureWriteResult { Success = true }; + if (workflow == null || release == null) + return CaptureFailed(); + + // Merging means reading the stored set: only a release that already reads the subject identifiers may capture. + if (!ProtectedWorkflowFieldCatalog.Plan(workflow.TriggerEventType, release.GetAllowedFieldIds()).ReadsSubjectIdentifiers) + return CaptureFailed(ProtectedWorkflowErrorCodes.CaptureRequiresSubjectIdentifiers); + + var captured = new SortedDictionary(StringComparer.Ordinal); + foreach (var pair in values) + captured[pair.Key] = pair.Value?.Trim(); + if (CallSubjectIdentifiers.Validate(captured).Count > 0) + return CaptureFailed(); + + if (!int.TryParse(entityId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var callId) || callId <= 0) + return CaptureFailed(ProtectedWorkflowErrorCodes.EntityUnavailable); + + for (var attempt = 0; attempt < CaptureWriteAttempts; attempt++) + { + var call = await _calls.GetByIdAsync(callId); + if (call == null || call.DepartmentId != release.DepartmentId) + return CaptureFailed(ProtectedWorkflowErrorCodes.EntityUnavailable); + + var stored = call.SubjectIdentifiers; + if (string.Equals(stored, ProtectedDataEnvelope.RedactionValue, StringComparison.Ordinal)) + return CaptureFailed(ProtectedWorkflowErrorCodes.EntityUnavailable); + + var currentJson = stored; + if (ProtectedDataEnvelope.HasEnvelopePrefix(stored)) + { + var decrypted = await DecryptAsync(workflow, release, Guid.NewGuid().ToString("N"), new List + { + new ProtectedFieldOperationItem { FieldId = ProtectedWorkflowFieldCatalog.SubjectIdentifiersFieldId, RowKey = entityId, Value = stored } + }, cancellationToken); + if (decrypted.ErrorCode != null) + return CaptureFailed(ProtectedWorkflowErrorCodes.BrokerFailed); + currentJson = decrypted.Values.Values.FirstOrDefault(); + } + + if (!CallSubjectIdentifiers.TryParse(currentJson, out var merged)) + return CaptureFailed(ProtectedWorkflowErrorCodes.ProjectionFailed); + foreach (var pair in captured) + merged[pair.Key] = pair.Value; + if (CallSubjectIdentifiers.Validate(merged).Count > 0) + return CaptureFailed(); + + // Encrypt through the workload lane exactly as any system write of a call field would (plaintext only for a + // department that is not encrypting new writes, or is pinned below the catalog that added the field). + var scratch = new Call { CallId = callId, DepartmentId = call.DepartmentId, SubjectIdentifiers = CallSubjectIdentifiers.Serialize(merged) }; + var write = await _protectedWrites.PrepareCallWriteAsync(call.DepartmentId, scratch, null, null, ProtectedWorkflowService.SystemActor, + workloadCaller: true, cancellationToken); + if (!write.Success) + return CaptureFailed(ProtectedWorkflowErrorCodes.BrokerFailed); + + if (await _calls.TryUpdateSubjectIdentifiersAsync(callId, call.DepartmentId, stored, scratch.SubjectIdentifiers, cancellationToken)) + return new ProtectedCaptureWriteResult { Success = true, WrittenKeys = captured.Keys.ToList() }; + } + + return CaptureFailed(ProtectedWorkflowErrorCodes.ConcurrentChange); + } + + /// + /// { protected: { call: { completed_notes, form_data, form, subject_ids: { key }, udf: { name } } } } — only what + /// was released. Custom fields render under their defined (machine) name, so a template can also loop over them. + /// + private static ScriptObject BuildNamespace(IEnumerable columns, IReadOnlyDictionary values, + ScriptObject subjectIds, IEnumerable<(string Name, string Value)> customFields) + { + var entity = new ScriptObject(); + foreach (var field in columns) + { + values.TryGetValue(field.FieldId, out var value); + entity[field.TemplateName] = value ?? string.Empty; + + if (field.FieldId == ProtectedWorkflowFieldCatalog.FormDataFieldId) + entity[ProtectedWorkflowFieldCatalog.ParsedFormName] = ParseForm(value); + } + + if (subjectIds != null) + entity[ProtectedWorkflowFieldCatalog.SubjectIdentifiersTemplateName] = subjectIds; + + if (customFields != null) + { + var udf = new ScriptObject(); + foreach (var (name, value) in customFields) + udf[name] = value ?? string.Empty; + entity[ProtectedWorkflowFieldCatalog.UdfTemplateName] = udf; + } + + return new ScriptObject + { + [ProtectedWorkflowFieldCatalog.NamespaceRoot] = new ScriptObject { [ProtectedWorkflowDefaults.CallEntityType] = entity } + }; + } + + private static object ParseForm(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return new ScriptObject(); + try + { + using var reader = new JsonTextReader(new System.IO.StringReader(json)) { DateParseHandling = DateParseHandling.None }; + var token = JToken.ReadFrom(reader); + if (token is JArray fields) + return FormValues(fields); + return token.Type == JTokenType.Object ? WorkflowTemplateContextBuilder.ToScriptValue(token) : new ScriptObject(); + } + catch (JsonException) + { + return new ScriptObject(); + } + } + + /// + /// Call form data is the form builder's field array with each answer in userData; the template sees it as + /// { fieldName: value } (a single answer as a string, several as an array). + /// + public static ScriptObject FormValues(JArray fields) + { + var values = new ScriptObject(); + foreach (var field in fields.OfType()) + { + var name = field.Value("name"); + if (string.IsNullOrWhiteSpace(name)) + continue; + + var answers = field["userData"] as JArray; + if (answers == null || answers.Count == 0) + values[name] = string.Empty; + else if (answers.Count == 1) + values[name] = answers[0]?.ToString() ?? string.Empty; + else + { + var list = new ScriptArray(); + foreach (var answer in answers) + list.Add(answer?.ToString() ?? string.Empty); + values[name] = list; + } + } + + return values; + } + + private static string SampleValue(ProtectedWorkflowField field) => field.FieldId switch + { + ProtectedWorkflowFieldCatalog.FormDataFieldId => "{\"outcome\":\"Referred to follow-up care\",\"follow_up_required\":\"yes\",\"sample\":true}", + "calls.completednotes" => "SAMPLE: client stabilized on scene, referred to county follow-up (synthetic test data)", + "calls.contactnumber" => "555-0100", + "calls.geolocationdata" => "0,0", + _ => $"SAMPLE {field.TemplateName} (synthetic test data)" + }; + + private static Call ReadCall(string eventPayloadJson) + { + if (string.IsNullOrWhiteSpace(eventPayloadJson)) + return null; + + return TryDeserialize(eventPayloadJson)?.Call + ?? TryDeserialize(eventPayloadJson)?.Call + ?? TryDeserialize(eventPayloadJson)?.Call; + } + + private static T TryDeserialize(string json) where T : class + { + try { return JsonConvert.DeserializeObject(json); } + catch { return null; } + } + + private static string BrokerCode(string brokerErrorCode) => + string.IsNullOrWhiteSpace(brokerErrorCode) + ? ProtectedWorkflowErrorCodes.BrokerFailed + : $"{ProtectedWorkflowErrorCodes.BrokerFailed}:{new string(brokerErrorCode.Where(c => char.IsLetterOrDigit(c) || c == '_').Take(48).ToArray())}"; + + private static ProtectedReleasedValues Failed(string errorCode, string requestId, string entityType, string entityId, string outcome = null) => + new ProtectedReleasedValues { Success = false, ErrorCode = errorCode, BrokerRequestId = requestId, EntityType = entityType, EntityId = entityId, Outcome = outcome }; + + private static ProtectedCaptureWriteResult CaptureFailed(string code = null) => + new ProtectedCaptureWriteResult { Success = false, ErrorCode = code ?? ProtectedWorkflowErrorCodes.CaptureFailed }; + } +} diff --git a/Core/Resgrid.Services/ProtectedWorkflowService.cs b/Core/Resgrid.Services/ProtectedWorkflowService.cs new file mode 100644 index 000000000..91650134b --- /dev/null +++ b/Core/Resgrid.Services/ProtectedWorkflowService.cs @@ -0,0 +1,1350 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Localization.Areas.User.ProtectedWorkflows; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Administrative half of Protected Workflows. See for the contract. + /// Holds no plaintext and performs no decryption; the unattended half is . + /// + public class ProtectedWorkflowService : IProtectedWorkflowService + { + /// Actor recorded on transitions the system makes on its own (sweep, lifecycle hooks without a user). + public const string SystemActor = "system:protected-workflows"; + + private static readonly TimeSpan StepUpClockSkew = TimeSpan.FromMinutes(1); + + private readonly IWorkflowProtectedReleaseRepository _releases; + private readonly IProtectedWorkflowDisclosureRepository _disclosures; + private readonly IWorkflowRepository _workflows; + private readonly IWorkflowStepRepository _steps; + private readonly IWorkflowCredentialRepository _credentials; + private readonly IDepartmentDataProtectionService _dataProtection; + private readonly IDepartmentsService _departments; + private readonly IPermissionsService _permissions; + private readonly IDepartmentGroupsService _groups; + private readonly IPersonnelRolesService _roles; + private readonly IEncryptionService _encryption; + private readonly IUserProfileService _profiles; + private readonly Lazy _communication; + private readonly IUdfDefinitionRepository _udfDefinitions; + private readonly IUdfFieldRepository _udfFields; + + /// Subject identifier keys the release panel always offers (EHR integration conventions). + public static readonly string[] WellKnownSubjectKeys = { "ehr_client_id", "ehr_encounter_id", "dynamics_case_id" }; + + public ProtectedWorkflowService(IWorkflowProtectedReleaseRepository releases, IProtectedWorkflowDisclosureRepository disclosures, + IWorkflowRepository workflows, IWorkflowStepRepository steps, IWorkflowCredentialRepository credentials, + IDepartmentDataProtectionService dataProtection, IDepartmentsService departments, IPermissionsService permissions, + IDepartmentGroupsService groups, IPersonnelRolesService roles, IEncryptionService encryption, + IUserProfileService profiles, Lazy communication, IUdfDefinitionRepository udfDefinitions, + IUdfFieldRepository udfFields) + { + _udfDefinitions = udfDefinitions; + _udfFields = udfFields; + _releases = releases; + _disclosures = disclosures; + _workflows = workflows; + _steps = steps; + _credentials = credentials; + _dataProtection = dataProtection; + _departments = departments; + _permissions = permissions; + _groups = groups; + _roles = roles; + _encryption = encryption; + _profiles = profiles; + _communication = communication; + } + + // ── Authorization ───────────────────────────────────────────────────────────────────────────── + + public async Task CanAdministerAsync(int departmentId, string userId) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) + return false; + + var department = await _departments.GetDepartmentByIdAsync(departmentId); + if (department == null) + return false; + + // ADP permissions never use the wide-open "missing row means allowed" convention. + var permission = await _permissions.GetPermissionByDepartmentTypeAsync(departmentId, PermissionTypes.ConfigureProtectedDataEgress) + ?? new Permission + { + DepartmentId = departmentId, + PermissionType = (int)PermissionTypes.ConfigureProtectedDataEgress, + Action = (int)AdpPermissionDefaults.For(PermissionTypes.ConfigureProtectedDataEgress) + }; + + var isDepartmentAdmin = department.IsUserAnAdmin(userId) || + string.Equals(department.ManagingUserId, userId, StringComparison.OrdinalIgnoreCase); + var group = await _groups.GetGroupForUserAsync(userId, departmentId); + var isGroupAdmin = group != null && group.IsUserGroupAdmin(userId); + var roles = await _roles.GetRolesForUserAsync(userId, departmentId) ?? new List(); + + return _permissions.IsUserAllowed(permission, isDepartmentAdmin, isGroupAdmin, roles); + } + + /// Null when the actor may run the command; otherwise a ProtectedWorkflowErrorCodes value. + private async Task AuthorizeAsync(int departmentId, ProtectedWorkflowActor actor, bool requireStepUp) + { + if (actor == null || string.IsNullOrWhiteSpace(actor.UserId)) + return ProtectedWorkflowErrorCodes.PermissionDenied; + + if (requireStepUp) + { + // API keys, client-credentials tokens and workers can never approve, renew or toggle. + if (!actor.IsInteractive) + return ProtectedWorkflowErrorCodes.InteractiveRequired; + if (!IsStepUpFresh(actor.StepUpVerifiedAtUtc, DateTime.UtcNow)) + return ProtectedWorkflowErrorCodes.StepUpRequired; + } + + return await CanAdministerAsync(departmentId, actor.UserId) ? null : ProtectedWorkflowErrorCodes.PermissionDenied; + } + + public static bool IsStepUpFresh(DateTime? verifiedAtUtc, DateTime utcNow) + { + if (!verifiedAtUtc.HasValue) + return false; + + var freshness = TimeSpan.FromMinutes(Math.Max(1, DataProtectionConfig.ProtectedWorkflowStepUpFreshnessMinutes)); + var age = utcNow - verifiedAtUtc.Value; + return age <= freshness && age >= -StepUpClockSkew; + } + + // ── Department settings ─────────────────────────────────────────────────────────────────────── + + public async Task GetDepartmentSettingsAsync(int departmentId, bool bypassCache = false) + { + var egress = await _dataProtection.GetEgressPolicyByDepartmentIdAsync(departmentId, bypassCache); + var state = await _dataProtection.GetStateAsync(departmentId, bypassCache); + return new ProtectedWorkflowDepartmentSettings + { + DepartmentId = departmentId, + Enabled = egress?.ProtectedWorkflowsEnabled ?? false, + RequireSecondApprover = egress?.ProtectedWorkflowsRequireSecondApprover ?? false, + AckVersion = egress?.ProtectedWorkflowsAckVersion, + AckByUserId = egress?.ProtectedWorkflowsAckByUserId, + AckOn = egress?.ProtectedWorkflowsAckOn, + AdpState = state, + RelaxRequestedByUserId = egress?.ProtectedWorkflowsRelaxRequestedByUserId, + RelaxRequestedOn = egress?.ProtectedWorkflowsRelaxRequestedOn + }; + } + + /// How long a request to turn the two-person rule off waits for a second administrator. + private static readonly TimeSpan RelaxRequestLifetime = TimeSpan.FromDays(7); + + public async Task SetDepartmentSettingsAsync(int departmentId, bool enabled, bool requireSecondApprover, + string acknowledgedVersion, ProtectedWorkflowActor actor, CancellationToken cancellationToken = default) + { + var denied = await AuthorizeAsync(departmentId, actor, requireStepUp: true); + if (denied != null) + return ProtectedWorkflowCommandResult.Fail(denied); + + var state = await _dataProtection.GetStateAsync(departmentId, bypassCache: true); + var policy = await _dataProtection.GetEgressPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + var now = DateTime.UtcNow; + var wasEnabled = policy.ProtectedWorkflowsEnabled; + var wasSecondApprover = policy.ProtectedWorkflowsRequireSecondApprover; + var changed = false; + var events = new List<(string Type, string Detail)>(); + + if (enabled) + { + if (state != DepartmentDataProtectionState.Enabled && state != DepartmentDataProtectionState.Rotating) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.AdpNotEnabled); + + // Turning it on (or keeping it on after the warning text changed) needs the CURRENT text acknowledged. + var alreadyCurrent = wasEnabled && string.Equals(policy.ProtectedWorkflowsAckVersion, ProtectedWorkflowDefaults.WarningTextVersion, StringComparison.Ordinal); + if (!alreadyCurrent) + { + if (!string.Equals(acknowledgedVersion, ProtectedWorkflowDefaults.WarningTextVersion, StringComparison.Ordinal)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.AckVersionMismatch); + + policy.ProtectedWorkflowsAckVersion = ProtectedWorkflowDefaults.WarningTextVersion; + policy.ProtectedWorkflowsAckByUserId = actor.UserId; + policy.ProtectedWorkflowsAckOn = now; + changed = true; + } + } + + // The two-person rule: tightening is immediate; relaxing is itself two-person. The first administrator's request + // is parked on the policy and only a DIFFERENT administrator can confirm it within RelaxRequestLifetime. + var pendingConfirmation = false; + if (requireSecondApprover) + { + if (!wasSecondApprover || policy.ProtectedWorkflowsRelaxRequestedByUserId != null) + { + policy.ProtectedWorkflowsRequireSecondApprover = true; + policy.ProtectedWorkflowsRelaxRequestedByUserId = null; + policy.ProtectedWorkflowsRelaxRequestedOn = null; + changed = true; + } + } + else if (wasSecondApprover) + { + var pendingBy = policy.ProtectedWorkflowsRelaxRequestedByUserId; + var pendingLive = pendingBy != null && policy.ProtectedWorkflowsRelaxRequestedOn.HasValue && + now - policy.ProtectedWorkflowsRelaxRequestedOn.Value <= RelaxRequestLifetime; + + if (pendingLive && !string.Equals(pendingBy, actor.UserId, StringComparison.OrdinalIgnoreCase)) + { + policy.ProtectedWorkflowsRequireSecondApprover = false; + policy.ProtectedWorkflowsRelaxRequestedByUserId = null; + policy.ProtectedWorkflowsRelaxRequestedOn = null; + events.Add((ProtectedWorkflowAdminEventTypes.SecondApproverRelaxed, $"requested_by={pendingBy}")); + changed = true; + } + else if (pendingLive) + { + // The requester asking again changes nothing: someone else has to confirm. + if (wasEnabled == enabled && !changed) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.SelfApproval); + pendingConfirmation = true; + } + else + { + policy.ProtectedWorkflowsRelaxRequestedByUserId = actor.UserId; + policy.ProtectedWorkflowsRelaxRequestedOn = now; + events.Add((ProtectedWorkflowAdminEventTypes.SecondApproverRelaxRequested, null)); + pendingConfirmation = true; + changed = true; + } + } + + if (wasEnabled != enabled) + changed = true; + + if (!changed) + return new ProtectedWorkflowCommandResult { Success = true, PendingConfirmation = pendingConfirmation }; + + policy.ProtectedWorkflowsEnabled = enabled; + + try + { + // Saving the egress policy bumps the PolicyEpoch and drops the cached copy. + await _dataProtection.SaveEgressPolicyAsync(policy, actor.UserId, cancellationToken); + } + catch (ArgumentException) + { + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ValidationFailed); + } + + if (enabled && (!wasEnabled || wasSecondApprover != policy.ProtectedWorkflowsRequireSecondApprover)) + await RecordAdminEventAsync(departmentId, ProtectedWorkflowAdminEventTypes.DepartmentEnabled, actor.UserId, null, null, + $"second_approver={(policy.ProtectedWorkflowsRequireSecondApprover ? "on" : "off")};ack={ProtectedWorkflowDefaults.WarningTextVersion}", cancellationToken); + + foreach (var (type, detail) in events) + await RecordAdminEventAsync(departmentId, type, actor.UserId, null, null, detail, cancellationToken); + + if (!enabled && wasEnabled) + { + await RecordAdminEventAsync(departmentId, ProtectedWorkflowAdminEventTypes.DepartmentDisabled, actor.UserId, null, null, null, cancellationToken); + foreach (var release in (await _releases.GetAllByDepartmentIdAsync(departmentId) ?? Enumerable.Empty()) + .Where(r => r.ReleaseState == ProtectedReleaseState.Active || r.ReleaseState == ProtectedReleaseState.PendingApproval)) + await SuspendInternalAsync(release, ProtectedWorkflowSuspendReasons.DepartmentDisabled, actor.UserId, cancellationToken); + } + + return new ProtectedWorkflowCommandResult { Success = true, PendingConfirmation = pendingConfirmation }; + } + + // ── Releases ────────────────────────────────────────────────────────────────────────────────── + + public Task GetCurrentReleaseAsync(string workflowId) => + string.IsNullOrWhiteSpace(workflowId) ? Task.FromResult(null) : _releases.GetLatestByWorkflowIdAsync(workflowId); + + public async Task> GetReleasesForDepartmentAsync(int departmentId) => + (await _releases.GetAllByDepartmentIdAsync(departmentId))?.ToList() ?? new List(); + + public async Task GetReleaseViewAsync(int departmentId, string workflowId, string userId, + CancellationToken cancellationToken = default) + { + var workflow = await LoadWorkflowAsync(departmentId, workflowId); + if (workflow == null) + return null; + + var steps = await LoadStepsAsync(workflowId); + var credentialTypes = await LoadCredentialTypesAsync(departmentId); + var validation = Validate(workflow, steps, credentialTypes); + var pins = await ResolvePinsAsync(departmentId, validation); + var tokenHost = pins?.TokenHost; + var release = await _releases.GetLatestByWorkflowIdAsync(workflowId); + var customFields = ProtectedWorkflowFieldCatalog.IsSupportedTrigger(workflow.TriggerEventType) + ? await GetCallCustomFieldsAsync(departmentId) + : new List(); + var current = release == null ? null : ComputeFingerprint(workflow, steps, release, customFields); + var sensitivities = release == null ? new Dictionary() : Sensitivities(release.GetAllowedFieldIds(), customFields); + var suggestedKeys = WellKnownSubjectKeys + .Concat(steps.SelectMany(s => ProtectedStepOptions.Read(s.ActionConfig, out _).ResponseCapture.Select(c => c.Key))) + .Concat(release == null ? Enumerable.Empty() : ProtectedWorkflowFieldCatalog.Plan(workflow.TriggerEventType, release.GetAllowedFieldIds()).SubjectIdentifierKeys) + .Where(k => !string.IsNullOrWhiteSpace(k) && ProtectedStepOptions.SubjectKeyPattern.IsMatch(k)) + .Distinct(StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ToList(); + + return new ProtectedWorkflowReleaseView + { + Workflow = workflow, + Release = release, + Department = await GetDepartmentSettingsAsync(departmentId), + TriggerSupported = ProtectedWorkflowFieldCatalog.IsSupportedTrigger(workflow.TriggerEventType), + CanAdminister = await CanAdministerAsync(departmentId, userId), + Validation = validation, + AvailableFields = ProtectedWorkflowFieldCatalog.FieldsFor(workflow.TriggerEventType), + CurrentFingerprint = current, + FingerprintMatches = release != null && !string.IsNullOrEmpty(release.ConfigFingerprint) && + string.Equals(release.ConfigFingerprint, current, StringComparison.Ordinal), + StepsFingerprint = ComputeStepsFingerprint(workflow, steps, validation.DestinationHost, tokenHost, pins?.AuthMethod), + TokenHost = tokenHost, + AuthMethod = pins?.AuthMethod, + AvailableUdfFields = customFields, + SuggestedSubjectKeys = suggestedKeys, + NeedsRestrictedAttestation = sensitivities.Values.Contains((int)UdfFieldSensitivity.Restricted), + NeedsPart2Attestation = sensitivities.Values.Contains((int)UdfFieldSensitivity.Part2) + }; + } + + public async Task> GetCallCustomFieldsAsync(int departmentId) + { + var definition = await _udfDefinitions.GetActiveDefinitionByDepartmentAndEntityTypeAsync(departmentId, (int)UdfEntityType.Call); + if (definition == null || definition.DepartmentId != departmentId) + return new List(); + + return ((await _udfFields.GetFieldsByDefinitionIdAsync(definition.UdfDefinitionId)) ?? Enumerable.Empty()) + .Where(f => f.IsEnabled && !string.IsNullOrWhiteSpace(f.Name)) + .OrderBy(f => f.SortOrder) + .ToList(); + } + + public async Task ComputeCurrentFingerprintAsync(Workflow workflow, IEnumerable steps, WorkflowProtectedRelease release) + { + if (workflow == null || release == null) + return null; + + // Custom field tags are only read when the release carries a custom field. + var customFields = ProtectedWorkflowFieldCatalog.Plan(workflow.TriggerEventType, release.GetAllowedFieldIds()).UdfNames.Count > 0 + ? await GetCallCustomFieldsAsync(release.DepartmentId) + : new List(); + return ComputeFingerprint(workflow, steps, release, customFields); + } + + public async Task SaveDraftAsync(int departmentId, string workflowId, ProtectedReleaseDraft draft, + ProtectedWorkflowActor actor, CancellationToken cancellationToken = default) + { + var denied = await AuthorizeAsync(departmentId, actor, requireStepUp: false); + if (denied != null) + return ProtectedWorkflowCommandResult.Fail(denied); + + var settings = await GetDepartmentSettingsAsync(departmentId, bypassCache: true); + if (!settings.AdpActive) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.AdpNotEnabled); + if (!settings.Enabled) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.DepartmentDisabled); + + var workflow = await LoadWorkflowAsync(departmentId, workflowId); + if (workflow == null) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.NotFound); + if (!ProtectedWorkflowFieldCatalog.IsSupportedTrigger(workflow.TriggerEventType)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.TriggerNotSupported); + + draft ??= new ProtectedReleaseDraft(); + var fields = WorkflowProtectedRelease.NormalizeFieldIds(draft.FieldIds); + var fieldError = ValidateFieldIds(workflow.TriggerEventType, fields, requireAny: false, await GetCallCustomFieldsAsync(departmentId)); + if (fieldError != null) + return ProtectedWorkflowCommandResult.Fail(fieldError); + + var now = DateTime.UtcNow; + var release = await _releases.GetLatestByWorkflowIdAsync(workflowId); + var isNew = release == null || release.ReleaseState == ProtectedReleaseState.Revoked; + if (isNew) + { + release = new WorkflowProtectedRelease + { + WorkflowProtectedReleaseId = Guid.NewGuid().ToString(), + WorkflowId = workflowId, + DepartmentId = departmentId, + State = (int)ProtectedReleaseState.Draft, + DestinationScheme = "https", + CreatedOn = now, + Version = 1 + }; + } + + var changed = isNew || + !fields.SequenceEqual(release.GetAllowedFieldIds(), StringComparer.Ordinal) || + release.RecipientType != draft.RecipientType || + !string.Equals(release.RecipientName ?? string.Empty, Trim(draft.RecipientName, 200) ?? string.Empty, StringComparison.Ordinal) || + !string.Equals(release.Purpose ?? string.Empty, Trim(draft.Purpose, 500) ?? string.Empty, StringComparison.Ordinal); + + if (!changed) + return ProtectedWorkflowCommandResult.Ok(release); + + release.SetAllowedFieldIds(fields); + release.RecipientType = draft.RecipientType; + release.RecipientName = Trim(draft.RecipientName, 200); + release.Purpose = Trim(draft.Purpose, 500); + release.UpdatedOn = now; + + // Changing what is released (or to whom, or why) on an approved or pending release is a change that + // needs re-approval; nothing opens until an administrator requests and approves again. + var reapproval = !isNew && (release.ReleaseState == ProtectedReleaseState.Active || release.ReleaseState == ProtectedReleaseState.PendingApproval); + if (reapproval) + { + release.State = (int)ProtectedReleaseState.PendingApproval; + release.SuspendedReason = ProtectedWorkflowSuspendReasons.ConfigChanged; + } + + if (isNew) + await _releases.InsertAsync(release, cancellationToken); + else if (!await _releases.TryUpdateAsync(release, cancellationToken)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ConcurrentChange); + + if (reapproval) + await RecordAdminEventAsync(departmentId, ProtectedWorkflowAdminEventTypes.ReleaseSuspended, actor.UserId, workflowId, + release.WorkflowProtectedReleaseId, ProtectedWorkflowSuspendReasons.ConfigChanged, cancellationToken); + + return ProtectedWorkflowCommandResult.Ok(release); + } + + public async Task RequestApprovalAsync(int departmentId, string workflowId, bool attested, + string acknowledgedVersion, string reviewedStepsFingerprint, ProtectedWorkflowActor actor, ProtectedSensitiveAttestation sensitive = null, + CancellationToken cancellationToken = default) + { + var release = await _releases.GetLatestByWorkflowIdAsync(workflowId); + if (release == null || release.DepartmentId != departmentId || + release.ReleaseState == ProtectedReleaseState.Revoked || release.ReleaseState == ProtectedReleaseState.Active) + { + var denied = await AuthorizeAsync(departmentId, actor, requireStepUp: true); + return ProtectedWorkflowCommandResult.Fail(denied ?? ProtectedWorkflowErrorCodes.InvalidState); + } + + return await SubmitAsync(departmentId, release, attested, acknowledgedVersion, reviewedStepsFingerprint, actor, sensitive, renewal: false, cancellationToken); + } + + public async Task RenewAsync(int departmentId, string releaseId, bool attested, + string acknowledgedVersion, string reviewedFingerprint, ProtectedWorkflowActor actor, ProtectedSensitiveAttestation sensitive = null, + CancellationToken cancellationToken = default) + { + var release = await LoadReleaseAsync(departmentId, releaseId); + if (release == null) + return ProtectedWorkflowCommandResult.Fail(await AuthorizeAsync(departmentId, actor, true) ?? ProtectedWorkflowErrorCodes.NotFound); + + if (release.ReleaseState != ProtectedReleaseState.Active && release.ReleaseState != ProtectedReleaseState.Expired) + return ProtectedWorkflowCommandResult.Fail(await AuthorizeAsync(departmentId, actor, true) ?? ProtectedWorkflowErrorCodes.InvalidState); + + return await SubmitAsync(departmentId, release, attested, acknowledgedVersion, reviewedFingerprint, actor, sensitive, renewal: true, cancellationToken); + } + + /// + /// The request path shared by first requests, re-requests after a change, suspension or expiry, and renewals. + /// Re-validates everything from fresh reads, pins host, credential and token host, binds the fingerprint and + /// records the attestation. Single approver: Active now. Two-person rule: waits for a different administrator + /// (an Active release being renewed keeps sending until its current ExpiresOn while the renewal waits). + /// + /// What the administrator was shown: the StepsFingerprint for a request, the release's + /// ConfigFingerprint for a renewal. A mismatch means the configuration changed since they looked, and nothing is written. + private async Task SubmitAsync(int departmentId, WorkflowProtectedRelease release, bool attested, + string acknowledgedVersion, string reviewed, ProtectedWorkflowActor actor, ProtectedSensitiveAttestation sensitive, bool renewal, + CancellationToken cancellationToken) + { + var denied = await AuthorizeAsync(departmentId, actor, requireStepUp: true); + if (denied != null) + return ProtectedWorkflowCommandResult.Fail(denied); + if (!attested) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.AttestationRequired); + if (!string.Equals(acknowledgedVersion, ProtectedWorkflowDefaults.WarningTextVersion, StringComparison.Ordinal)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.AckVersionMismatch); + + var settings = await GetDepartmentSettingsAsync(departmentId, bypassCache: true); + if (!settings.AdpActive) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.AdpNotEnabled); + if (!settings.Enabled) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.DepartmentDisabled); + + var workflow = await LoadWorkflowAsync(departmentId, release.WorkflowId); + if (workflow == null) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.NotFound); + + var fields = release.GetAllowedFieldIds().ToList(); + var customFields = await GetCallCustomFieldsAsync(departmentId); + var fieldError = ValidateFieldIds(workflow.TriggerEventType, fields, requireAny: true, customFields); + if (fieldError != null) + return ProtectedWorkflowCommandResult.Fail(fieldError); + if (!Enum.IsDefined(typeof(ProtectedReleaseRecipientType), release.RecipientType) || string.IsNullOrWhiteSpace(release.RecipientName)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.RecipientRequired); + if (string.IsNullOrWhiteSpace(release.Purpose)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.PurposeRequired); + + var steps = await LoadStepsAsync(release.WorkflowId); + var validation = Validate(workflow, steps, await LoadCredentialTypesAsync(departmentId)); + var pins = await ResolvePinsAsync(departmentId, validation); + var tokenHost = pins?.TokenHost; + if (!validation.IsValid) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ValidationFailed, validation.Errors); + + // A step that writes response values back into the subject identifiers merges into the stored set, which + // means reading it: only a release that already reads the subject identifiers may do that. + var plan = ProtectedWorkflowFieldCatalog.Plan(workflow.TriggerEventType, fields); + if (validation.CapturesResponse && !plan.ReadsSubjectIdentifiers) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.CaptureRequiresSubjectIdentifiers); + + // Restricted and 42 CFR Part 2 custom fields need their own, current attestation. + var sensitivities = Sensitivities(fields, customFields); + var needsRestricted = sensitivities.Values.Contains((int)UdfFieldSensitivity.Restricted); + var needsPart2 = sensitivities.Values.Contains((int)UdfFieldSensitivity.Part2); + if (needsRestricted && !(sensitive?.RestrictedValid ?? false)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.RestrictedAttestationRequired); + if (needsPart2 && !(sensitive?.Part2Valid ?? false)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.Part2AttestationRequired); + + // Bind the request to what the administrator actually reviewed. + var shown = renewal ? release.ConfigFingerprint : ComputeStepsFingerprint(workflow, steps, validation.DestinationHost, tokenHost, pins?.AuthMethod); + if (string.IsNullOrEmpty(reviewed) || !string.Equals(reviewed, shown, StringComparison.Ordinal)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ConfigChanged); + + var now = DateTime.UtcNow; + var wasActive = release.ReleaseState == ProtectedReleaseState.Active; + var approvedFingerprint = release.ConfigFingerprint; + release.DestinationScheme = "https"; + release.DestinationHost = validation.DestinationHost; + release.WorkflowCredentialId = validation.WorkflowCredentialId; + release.TokenHost = tokenHost; + release.AuthMethod = pins?.AuthMethod; + release.AllowsRestricted = needsRestricted; + release.RestrictedAckVersion = needsRestricted ? ProtectedWorkflowDefaults.RestrictedAttestationVersion : null; + release.RestrictedAckByUserId = needsRestricted ? actor.UserId : null; + release.AllowsPart2 = needsPart2; + release.Part2AckVersion = needsPart2 ? ProtectedWorkflowDefaults.Part2AttestationVersion : null; + release.Part2AckByUserId = needsPart2 ? actor.UserId : null; + release.ConfigFingerprint = ComputeFingerprint(workflow, steps, release, customFields); + + // A renewal only extends what was already approved. If the configuration moved underneath an Active release + // (a save hook that did not run), keeping it Active would let one administrator re-bind the approval to a + // configuration nobody else has seen: it goes back through a full approval instead. + if (renewal && wasActive && !string.Equals(approvedFingerprint, release.ConfigFingerprint, StringComparison.Ordinal)) + wasActive = false; + release.AckVersion = ProtectedWorkflowDefaults.WarningTextVersion; + release.RequestedByUserId = actor.UserId; + release.RequestedOn = now; + release.UpdatedOn = now; + + if (settings.RequireSecondApprover) + { + if (!(renewal && wasActive)) + { + release.State = (int)ProtectedReleaseState.PendingApproval; + release.SuspendedReason = null; + release.ApprovedByUserId = null; + release.ApprovedOn = null; + release.ExpiresOn = null; + } + } + else + { + Activate(release, actor.UserId, now); + } + + if (!await _releases.TryUpdateAsync(release, cancellationToken)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ConcurrentChange); + + await RecordAdminEventAsync(departmentId, ProtectedWorkflowAdminEventTypes.ReleaseRequested, actor.UserId, release.WorkflowId, + release.WorkflowProtectedReleaseId, DescribeRequest(release, renewal), cancellationToken); + if (!settings.RequireSecondApprover) + await RecordAdminEventAsync(departmentId, ProtectedWorkflowAdminEventTypes.ReleaseApproved, actor.UserId, release.WorkflowId, + release.WorkflowProtectedReleaseId, $"expires={release.ExpiresOn:yyyy-MM-dd};single_approver", cancellationToken); + + return ProtectedWorkflowCommandResult.Ok(release); + } + + public async Task ApproveAsync(int departmentId, string releaseId, bool attested, + string acknowledgedVersion, string reviewedFingerprint, ProtectedWorkflowActor actor, ProtectedSensitiveAttestation sensitive = null, + CancellationToken cancellationToken = default) + { + var denied = await AuthorizeAsync(departmentId, actor, requireStepUp: true); + if (denied != null) + return ProtectedWorkflowCommandResult.Fail(denied); + if (!attested) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.AttestationRequired); + if (!string.Equals(acknowledgedVersion, ProtectedWorkflowDefaults.WarningTextVersion, StringComparison.Ordinal)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.AckVersionMismatch); + + var release = await LoadReleaseAsync(departmentId, releaseId); + if (release == null) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.NotFound); + + // A pending first request, or a renewal of an Active release that is waiting for its second approver. + var pendingRequest = release.ReleaseState == ProtectedReleaseState.PendingApproval && release.SuspendedReason == null; + var pendingRenewal = release.ReleaseState == ProtectedReleaseState.Active && release.RequestedOn.HasValue && + (!release.ApprovedOn.HasValue || release.RequestedOn.Value > release.ApprovedOn.Value); + if (!pendingRequest && !pendingRenewal) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.InvalidState); + + // Two-person rule: whoever asked can never be the one who approves. + if (string.Equals(release.RequestedByUserId, actor.UserId, StringComparison.OrdinalIgnoreCase)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.SelfApproval); + + // The approval binds to the request the approver was shown; a re-request in between changes the fingerprint. + if (string.IsNullOrEmpty(reviewedFingerprint) || !string.Equals(reviewedFingerprint, release.ConfigFingerprint, StringComparison.Ordinal)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ConfigChanged); + + // The approver makes the same restricted / Part 2 attestations the requester made. + if (release.AllowsRestricted && !(sensitive?.RestrictedValid ?? false)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.RestrictedAttestationRequired); + if (release.AllowsPart2 && !(sensitive?.Part2Valid ?? false)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.Part2AttestationRequired); + + var settings = await GetDepartmentSettingsAsync(departmentId, bypassCache: true); + if (!settings.AdpActive) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.AdpNotEnabled); + if (!settings.Enabled) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.DepartmentDisabled); + + // The approver approves exactly what was requested: re-validate and re-fingerprint from fresh reads. + var workflow = await LoadWorkflowAsync(departmentId, release.WorkflowId); + if (workflow == null) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.NotFound); + var steps = await LoadStepsAsync(release.WorkflowId); + var validation = Validate(workflow, steps, await LoadCredentialTypesAsync(departmentId)); + var pins = await ResolvePinsAsync(departmentId, validation); + if (!validation.IsValid) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ValidationFailed, validation.Errors); + + if (!string.Equals(validation.DestinationHost, release.DestinationHost, StringComparison.Ordinal) || + !string.Equals(validation.WorkflowCredentialId, release.WorkflowCredentialId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(pins?.TokenHost, release.TokenHost, StringComparison.Ordinal) || + !string.Equals(pins?.AuthMethod, release.AuthMethod, StringComparison.Ordinal) || + !string.Equals(await ComputeCurrentFingerprintAsync(workflow, steps, release), release.ConfigFingerprint, StringComparison.Ordinal)) + { + if (pendingRequest) + { + release.State = (int)ProtectedReleaseState.PendingApproval; + release.SuspendedReason = ProtectedWorkflowSuspendReasons.ConfigChanged; + release.UpdatedOn = DateTime.UtcNow; + await _releases.TryUpdateAsync(release, cancellationToken); + } + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ConfigChanged); + } + + Activate(release, actor.UserId, DateTime.UtcNow); + if (!await _releases.TryUpdateAsync(release, cancellationToken)) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ConcurrentChange); + await RecordAdminEventAsync(departmentId, ProtectedWorkflowAdminEventTypes.ReleaseApproved, actor.UserId, release.WorkflowId, + release.WorkflowProtectedReleaseId, $"expires={release.ExpiresOn:yyyy-MM-dd};second_approver", cancellationToken); + + return ProtectedWorkflowCommandResult.Ok(release); + } + + public async Task SuspendAsync(int departmentId, string releaseId, ProtectedWorkflowActor actor, + CancellationToken cancellationToken = default) + { + var denied = await AuthorizeAsync(departmentId, actor, requireStepUp: false); + if (denied != null) + return ProtectedWorkflowCommandResult.Fail(denied); + + var release = await LoadReleaseAsync(departmentId, releaseId); + if (release == null) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.NotFound); + if (release.ReleaseState != ProtectedReleaseState.Active && release.ReleaseState != ProtectedReleaseState.PendingApproval) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.InvalidState); + + return await SuspendInternalAsync(release, ProtectedWorkflowSuspendReasons.AdminSuspended, actor.UserId, cancellationToken) + ? ProtectedWorkflowCommandResult.Ok(release) + : ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ConcurrentChange); + } + + public async Task RevokeAsync(int departmentId, string releaseId, ProtectedWorkflowActor actor, + CancellationToken cancellationToken = default) + { + var denied = await AuthorizeAsync(departmentId, actor, requireStepUp: false); + if (denied != null) + return ProtectedWorkflowCommandResult.Fail(denied); + + var release = await LoadReleaseAsync(departmentId, releaseId); + if (release == null) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.NotFound); + if (release.ReleaseState == ProtectedReleaseState.Revoked) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.InvalidState); + + return await RevokeInternalAsync(release, ProtectedWorkflowSuspendReasons.AdminRevoked, actor.UserId, cancellationToken) + ? ProtectedWorkflowCommandResult.Ok(release) + : ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.ConcurrentChange); + } + + public async Task DiscardDraftAsync(int departmentId, string releaseId, ProtectedWorkflowActor actor, + CancellationToken cancellationToken = default) + { + var denied = await AuthorizeAsync(departmentId, actor, requireStepUp: false); + if (denied != null) + return ProtectedWorkflowCommandResult.Fail(denied); + + var release = await LoadReleaseAsync(departmentId, releaseId); + if (release == null) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.NotFound); + + // Only a release that was never requested can disappear; anything else is part of the audit trail. + if (release.ReleaseState != ProtectedReleaseState.Draft || release.RequestedOn.HasValue) + return ProtectedWorkflowCommandResult.Fail(ProtectedWorkflowErrorCodes.InvalidState); + + await _releases.DeleteAsync(release, cancellationToken); + return ProtectedWorkflowCommandResult.Ok(); + } + + // ── Lifecycle hooks ─────────────────────────────────────────────────────────────────────────── + + public async Task OnWorkflowConfigurationChangedAsync(string workflowId, string actorUserId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(workflowId)) + return; + + var release = await _releases.GetLatestByWorkflowIdAsync(workflowId); + if (release == null) + return; + + // Only approved or pending releases carry a fingerprint that a change can invalidate; a draft, suspended + // or expired release is re-validated and re-fingerprinted when it is next requested anyway. + var active = release.ReleaseState == ProtectedReleaseState.Active; + var pending = release.ReleaseState == ProtectedReleaseState.PendingApproval && release.SuspendedReason == null; + if (!active && !pending) + return; + + var workflow = await _workflows.GetByIdAsync(workflowId); + if (workflow == null) + return; + + var current = await ComputeCurrentFingerprintAsync(workflow, await LoadStepsAsync(workflowId), release); + if (string.Equals(current, release.ConfigFingerprint, StringComparison.Ordinal)) + return; + + release.State = (int)ProtectedReleaseState.PendingApproval; + release.SuspendedReason = ProtectedWorkflowSuspendReasons.ConfigChanged; + release.UpdatedOn = DateTime.UtcNow; + if (!await _releases.TryUpdateAsync(release, cancellationToken)) + return; // someone else moved it first; the runtime re-checks the fingerprint on every send anyway + await RecordAdminEventAsync(release.DepartmentId, ProtectedWorkflowAdminEventTypes.ReleaseSuspended, + string.IsNullOrWhiteSpace(actorUserId) ? SystemActor : actorUserId, workflowId, release.WorkflowProtectedReleaseId, + ProtectedWorkflowSuspendReasons.ConfigChanged, cancellationToken); + } + + public async Task OnWorkflowDeletedAsync(Workflow workflow, string actorUserId, CancellationToken cancellationToken = default) + { + if (workflow == null) + return; + + foreach (var release in (await _releases.GetAllByWorkflowIdAsync(workflow.WorkflowId) ?? Enumerable.Empty()) + .Where(r => r.ReleaseState != ProtectedReleaseState.Revoked)) + { + if (release.ReleaseState == ProtectedReleaseState.Draft && !release.RequestedOn.HasValue) + { + await _releases.DeleteAsync(release, cancellationToken); + continue; + } + + await RevokeInternalAsync(release, ProtectedWorkflowSuspendReasons.WorkflowDeleted, + string.IsNullOrWhiteSpace(actorUserId) ? SystemActor : actorUserId, cancellationToken); + } + } + + public async Task OnCredentialSavedAsync(WorkflowCredential credential, bool typeChanged, bool secretChanged, string previousTokenHost, + string currentTokenHost, string actorUserId, CancellationToken cancellationToken = default, string previousAuthMethod = null, + string currentAuthMethod = null, string rotatedKeyId = null) + { + if (credential == null || string.IsNullOrWhiteSpace(credential.WorkflowCredentialId)) + return; + + var actor = string.IsNullOrWhiteSpace(actorUserId) ? SystemActor : actorUserId; + var rotationDetail = rotatedKeyId == null + ? $"credential={credential.WorkflowCredentialId}" + : $"credential={credential.WorkflowCredentialId};kid={rotatedKeyId}"; + + var pinned = (await _releases.GetAllByCredentialIdAsync(credential.WorkflowCredentialId) ?? Enumerable.Empty()) + .Where(r => r.DepartmentId == credential.DepartmentId && r.ReleaseState != ProtectedReleaseState.Revoked) + .ToList(); + if (pinned.Count == 0) + { + // A signing key rotation is always on the record for a department using Protected Workflows, even before any + // release pins the credential: the EHR will have been given the old key. + if (rotatedKeyId != null && (await GetDepartmentSettingsAsync(credential.DepartmentId, bypassCache: true)).Enabled) + await RecordAdminEventAsync(credential.DepartmentId, ProtectedWorkflowAdminEventTypes.CredentialRotated, actor, null, null, + rotationDetail, cancellationToken); + return; + } + + var tokenHostChanged = !string.Equals(ProtectedWorkflowFingerprint.NormalizeHost(previousTokenHost), + ProtectedWorkflowFingerprint.NormalizeHost(currentTokenHost), StringComparison.Ordinal); + var authMethodChanged = !string.Equals(previousAuthMethod, currentAuthMethod, StringComparison.Ordinal); + + foreach (var release in pinned) + { + if (typeChanged || tokenHostChanged || authMethodChanged) + { + if (release.ReleaseState == ProtectedReleaseState.Active || release.ReleaseState == ProtectedReleaseState.PendingApproval) + await SuspendInternalAsync(release, ProtectedWorkflowSuspendReasons.CredentialChanged, actor, cancellationToken); + } + else if (secretChanged) + { + // The id is pinned, the secret is not: rotation never forces re-approval, but it is on the record. + await RecordAdminEventAsync(release.DepartmentId, ProtectedWorkflowAdminEventTypes.CredentialRotated, actor, release.WorkflowId, + release.WorkflowProtectedReleaseId, rotationDetail, cancellationToken); + } + } + } + + public async Task OnCallCustomFieldsChangedAsync(int departmentId, string actorUserId, CancellationToken cancellationToken = default) + { + var affected = ((await _releases.GetAllByDepartmentIdAsync(departmentId)) ?? Enumerable.Empty()) + .Where(r => r.ReleaseState == ProtectedReleaseState.Active || + (r.ReleaseState == ProtectedReleaseState.PendingApproval && r.SuspendedReason == null)) + .Where(r => r.GetAllowedFieldIds().Any(id => ProtectedWorkflowFieldCatalog.ParseUdfName(id) != null)) + .Select(r => r.WorkflowId) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + // The fingerprint carries every released custom field's tag: a retag, a removal or a disabled field moves it. + foreach (var workflowId in affected) + await OnWorkflowConfigurationChangedAsync(workflowId, actorUserId, cancellationToken); + } + + public async Task OnCredentialDeletedAsync(WorkflowCredential credential, string actorUserId, CancellationToken cancellationToken = default) + { + if (credential == null || string.IsNullOrWhiteSpace(credential.WorkflowCredentialId)) + return; + + foreach (var release in (await _releases.GetAllByCredentialIdAsync(credential.WorkflowCredentialId) ?? Enumerable.Empty()) + .Where(r => r.DepartmentId == credential.DepartmentId && + (r.ReleaseState == ProtectedReleaseState.Active || r.ReleaseState == ProtectedReleaseState.PendingApproval))) + await SuspendInternalAsync(release, ProtectedWorkflowSuspendReasons.CredentialChanged, + string.IsNullOrWhiteSpace(actorUserId) ? SystemActor : actorUserId, cancellationToken); + } + + public async Task ValidateStepTemplatesAsync(WorkflowStep step, CancellationToken cancellationToken = default) + { + if (step == null) + return null; + + // Branching on plaintext, or putting it in a URL or header, is never allowed — protected or not. + if (ProtectedWorkflowValidator.ReferencesProtectedNamespace(step.ConditionExpression)) + return ProtectedWorkflowValidator.ProtectedInCondition; + if (ProtectedWorkflowValidator.ReferencesProtectedNamespace(step.ActionConfig)) + return ProtectedWorkflowValidator.ProtectedInActionConfig; + + if (ProtectedWorkflowValidator.ReferencesProtectedNamespace(step.OutputTemplate)) + { + var release = string.IsNullOrWhiteSpace(step.WorkflowId) ? null : await _releases.GetLatestByWorkflowIdAsync(step.WorkflowId); + if (release == null || release.ReleaseState == ProtectedReleaseState.Revoked) + return ProtectedWorkflowValidator.ProtectedWithoutRelease; + } + + return null; + } + + // ── Disclosure chain ────────────────────────────────────────────────────────────────────────── + + public Task RecordDisclosureAsync(ProtectedWorkflowDisclosure disclosure, CancellationToken cancellationToken = default) + { + if (disclosure == null) + throw new ArgumentNullException(nameof(disclosure)); + + disclosure.RecordType = ProtectedWorkflowRecordTypes.Disclosure; + disclosure.EventType = null; + if (disclosure.OccurredOn == default) + disclosure.OccurredOn = DateTime.UtcNow; + return _disclosures.AppendAsync(disclosure, cancellationToken); + } + + public Task RecordAdminEventAsync(int departmentId, string eventType, string actorUserId, string workflowId, + string releaseId, string detail, CancellationToken cancellationToken = default) + { + return _disclosures.AppendAsync(new ProtectedWorkflowDisclosure + { + DepartmentId = departmentId, + RecordType = ProtectedWorkflowRecordTypes.AdminEvent, + EventType = eventType, + ActorUserId = actorUserId, + WorkflowId = workflowId, + WorkflowProtectedReleaseId = releaseId, + Detail = Trim(detail, 500), + OccurredOn = DateTime.UtcNow + }, cancellationToken); + } + + public async Task> GetDisclosuresAsync(int departmentId, ProtectedWorkflowDisclosureFilter filter) => + (await _disclosures.GetForDepartmentAsync(departmentId, filter))?.ToList() ?? new List(); + + public async Task VerifyChainAsync(int departmentId) + { + var chain = (await _disclosures.GetChainForDepartmentAsync(departmentId))?.ToList() ?? new List(); + var broken = ProtectedWorkflowDisclosureChain.Verify(chain); + return new ProtectedWorkflowChainVerification { IsValid = broken == null, RecordCount = chain.Count, FirstInvalidSequence = broken }; + } + + public async Task ExportDisclosuresCsvAsync(int departmentId, ProtectedWorkflowDisclosureFilter filter) + { + var rows = await GetDisclosuresAsync(departmentId, filter); + var csv = new StringBuilder(); + csv.AppendLine("Sequence,OccurredOnUtc,RecordType,EventType,Outcome,IsTest,WorkflowId,WorkflowRunId,WorkflowStepId,ReleaseId,EntityType,EntityId,FieldIds,DestinationHost,ContentType,HttpStatus,PayloadBytes,PayloadSha256,BrokerRequestId,ActorUserId,Detail,CapturedKeys,Hash"); + foreach (var row in rows.OrderBy(r => r.ChainSequence)) + { + csv.AppendLine(string.Join(",", new[] + { + row.ChainSequence.ToString(CultureInfo.InvariantCulture), + row.OccurredOn.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture), + row.RecordType, row.EventType, row.Outcome, row.IsTest ? "true" : "false", + row.WorkflowId, row.WorkflowRunId, row.WorkflowStepId, row.WorkflowProtectedReleaseId, + row.EntityType, row.EntityId, + string.Join(" ", WorkflowProtectedRelease.ParseFieldIds(row.FieldIds)), + row.DestinationHost, row.ContentType, + row.HttpStatus?.ToString(CultureInfo.InvariantCulture), + row.PayloadBytes?.ToString(CultureInfo.InvariantCulture), + row.PayloadSha256, row.BrokerRequestId, row.ActorUserId, row.Detail, + string.Join(" ", WorkflowProtectedRelease.ParseFieldIds(row.CapturedKeys)), row.Hash + }.Select(CsvCell))); + } + + return csv.ToString(); + } + + // ── Shared transitions ──────────────────────────────────────────────────────────────────────── + + public async Task MarkExpiredAsync(WorkflowProtectedRelease release, CancellationToken cancellationToken = default) + { + if (release == null || release.ReleaseState != ProtectedReleaseState.Active) + return; + + release.State = (int)ProtectedReleaseState.Expired; + release.UpdatedOn = DateTime.UtcNow; + if (!await _releases.TryUpdateAsync(release, cancellationToken)) + return; + await RecordAdminEventAsync(release.DepartmentId, ProtectedWorkflowAdminEventTypes.ReleaseExpired, SystemActor, release.WorkflowId, + release.WorkflowProtectedReleaseId, $"expires={release.ExpiresOn:yyyy-MM-dd}", cancellationToken); + } + + public async Task RevokeAllForDepartmentAsync(int departmentId, string reason, string actorUserId, CancellationToken cancellationToken = default) + { + var revoked = 0; + foreach (var release in (await _releases.GetAllByDepartmentIdAsync(departmentId) ?? Enumerable.Empty()) + .Where(r => r.ReleaseState != ProtectedReleaseState.Revoked)) + { + if (await RevokeInternalAsync(release, reason, string.IsNullOrWhiteSpace(actorUserId) ? SystemActor : actorUserId, cancellationToken)) + revoked++; + } + + return revoked; + } + + public async Task RunSweepAsync(DateTime utcNow, CancellationToken cancellationToken = default) + { + var result = new ProtectedWorkflowSweepResult(); + var open = (await _releases.GetAllByStatesAsync(new[] + { + ProtectedReleaseState.Draft, ProtectedReleaseState.PendingApproval, ProtectedReleaseState.Active, + ProtectedReleaseState.Suspended, ProtectedReleaseState.Expired + }))?.ToList() ?? new List(); + + foreach (var department in open.GroupBy(r => r.DepartmentId)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var policy = await _dataProtection.GetPolicyByDepartmentIdAsync(department.Key, bypassCache: true); + if (IsOffboardingOrDisabled(policy)) + { + result.Revoked += await RevokeAllForDepartmentAsync(department.Key, ProtectedWorkflowSuspendReasons.AdpOffboarding, SystemActor, cancellationToken); + continue; + } + + var egress = await _dataProtection.GetEgressPolicyByDepartmentIdAsync(department.Key, bypassCache: true); + foreach (var release in department) + { + if (!egress.ProtectedWorkflowsEnabled && + (release.ReleaseState == ProtectedReleaseState.Active || release.ReleaseState == ProtectedReleaseState.PendingApproval)) + { + if (await SuspendInternalAsync(release, ProtectedWorkflowSuspendReasons.DepartmentDisabled, SystemActor, cancellationToken)) + result.Suspended++; + continue; + } + + if (release.ReleaseState != ProtectedReleaseState.Active || !release.ExpiresOn.HasValue) + continue; + + if (release.ExpiresOn.Value <= utcNow) + { + var version = release.Version; + await MarkExpiredAsync(release, cancellationToken); + if (release.Version != version) + { + result.Expired++; + await NotifyAdminsAsync(release.DepartmentId, release.WorkflowId, "NoticeExpiredBody", null, cancellationToken); + } + continue; + } + + var threshold = DueNoticeThreshold(release, utcNow); + if (threshold.HasValue) + { + // Claim the notice first (a conditional write on the version the sweep read): a release that + // was revoked or suspended in the meantime is never written back as Active, and no notice goes out. + release.ExpiryNoticeSentDays = threshold; + release.UpdatedOn = utcNow; + if (!await _releases.TryUpdateAsync(release, cancellationToken)) + continue; + await NotifyAdminsAsync(release.DepartmentId, release.WorkflowId, "NoticeExpiringBody", release.ExpiresOn, cancellationToken); + result.NoticesSent++; + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + result.Failed++; + Logging.LogError($"Protected workflow sweep failed for department {department.Key}: {ex.GetType().FullName}."); + } + } + + return result; + } + + public async Task NotifyFinalFailureAsync(int departmentId, Workflow workflow, string workflowRunId, string errorCode, + CancellationToken cancellationToken = default) + { + if (workflow == null) + return; + + try + { + var department = await _departments.GetDepartmentByIdAsync(departmentId, false); + foreach (var admin in await _departments.GetActiveAdminsForDepartmentAsync(departmentId) ?? new List()) + { + var profile = await _profiles.GetProfileByUserIdAsync(admin.UserId); + var message = ProtectedWorkflowsResources.Get("NoticeFailureBody", profile?.Language, workflow.Name, workflowRunId, errorCode); + var title = ProtectedWorkflowsResources.Get("NoticeFailureTitle", profile?.Language); + await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, null, department, title, profile); + } + } + catch (Exception ex) + { + Logging.LogError($"Protected workflow failure notice could not be sent for department {departmentId}: {ex.GetType().FullName}."); + } + } + + public async Task GetCredentialTokenHostAsync(int departmentId, string workflowCredentialId) => + (await GetCredentialPinsAsync(departmentId, workflowCredentialId))?.TokenHost; + + public async Task GetCredentialPinsAsync(int departmentId, string workflowCredentialId) + { + if (string.IsNullOrWhiteSpace(workflowCredentialId)) + return null; + + var credential = await _credentials.GetByIdAsync(workflowCredentialId); + if (credential == null || credential.DepartmentId != departmentId || + credential.CredentialType != (int)WorkflowCredentialType.OAuth2ClientCredentials) + return null; + + var department = await _departments.GetDepartmentByIdAsync(departmentId); + string json; + try + { + json = _encryption.DecryptForDepartment(credential.EncryptedData, departmentId, department?.Code ?? string.Empty); + } + catch (Exception) + { + return new ProtectedCredentialPins(); + } + + return ReadPins(json); + } + + /// The token host, client authentication and signing-key presence of an OAuth2 credential's JSON. + public static ProtectedCredentialPins ReadPins(string credentialJson) + { + var authMethod = WorkflowJwtKeys.ClientSecret; + var hasKey = false; + try + { + var json = string.IsNullOrWhiteSpace(credentialJson) ? null : JObject.Parse(credentialJson); + authMethod = WorkflowJwtKeys.NormalizeAuthMethod((string)json?.GetValue("authMethod", StringComparison.OrdinalIgnoreCase)); + var keys = json?.GetValue("signingKeys", StringComparison.OrdinalIgnoreCase)?.ToObject>(); + hasKey = WorkflowJwtKeys.Current(keys) != null; + } + catch (JsonException) + { + } + + return new ProtectedCredentialPins { TokenHost = ReadTokenHost(credentialJson), AuthMethod = authMethod, HasSigningKey = hasKey }; + } + + /// The literal https host of an OAuth2 credential's tokenUrl, or null. + public static string ReadTokenHost(string credentialJson) + { + if (string.IsNullOrWhiteSpace(credentialJson)) + return null; + try + { + var token = JObject.Parse(credentialJson).GetValue("tokenUrl", StringComparison.OrdinalIgnoreCase); + var url = token?.Type == JTokenType.String ? (string)token : null; + return ProtectedWorkflowValidator.TryParseLiteralHttpsHost(url, out var host, out _) ? host : null; + } + catch (JsonException) + { + return null; + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────────────────────────── + + private static void Activate(WorkflowProtectedRelease release, string approverUserId, DateTime now) + { + release.State = (int)ProtectedReleaseState.Active; + release.SuspendedReason = null; + release.ApprovedByUserId = approverUserId; + release.ApprovedOn = now; + release.ExpiresOn = now.AddDays(Math.Max(1, DataProtectionConfig.ProtectedWorkflowReleaseLifetimeDays)); + release.ExpiryNoticeSentDays = null; + release.UpdatedOn = now; + } + + private async Task SuspendInternalAsync(WorkflowProtectedRelease release, string reason, string actorUserId, CancellationToken cancellationToken) + { + release.State = (int)ProtectedReleaseState.Suspended; + release.SuspendedReason = reason; + release.UpdatedOn = DateTime.UtcNow; + if (!await _releases.TryUpdateAsync(release, cancellationToken)) + return false; + await RecordAdminEventAsync(release.DepartmentId, ProtectedWorkflowAdminEventTypes.ReleaseSuspended, actorUserId, release.WorkflowId, + release.WorkflowProtectedReleaseId, reason, cancellationToken); + return true; + } + + private async Task RevokeInternalAsync(WorkflowProtectedRelease release, string reason, string actorUserId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + release.State = (int)ProtectedReleaseState.Revoked; + release.SuspendedReason = reason; + release.RevokedByUserId = actorUserId; + release.RevokedOn = now; + release.UpdatedOn = now; + if (!await _releases.TryUpdateAsync(release, cancellationToken)) + return false; + await RecordAdminEventAsync(release.DepartmentId, ProtectedWorkflowAdminEventTypes.ReleaseRevoked, actorUserId, release.WorkflowId, + release.WorkflowProtectedReleaseId, reason, cancellationToken); + return true; + } + + /// + /// The fingerprint of the workflow as saved now, against the release's field list, pinned hosts, OAuth2 method and + /// attestations. are the department's current call custom fields: each released + /// custom field's sensitivity tag (or its absence) is an input. + /// + public static string ComputeFingerprint(Workflow workflow, IEnumerable steps, WorkflowProtectedRelease release, + IReadOnlyList callCustomFields = null) => + ProtectedWorkflowFingerprint.Compute(workflow.TriggerEventType, steps, release.GetAllowedFieldIds(), release.DestinationHost, release.TokenHost, + new ProtectedFingerprintExtras + { + AuthMethod = release.AuthMethod, + AllowsRestricted = release.AllowsRestricted, + AllowsPart2 = release.AllowsPart2, + FieldSensitivities = Sensitivities(release.GetAllowedFieldIds(), callCustomFields) + }); + + /// The configuration an administrator reviews before requesting: steps, host, token host and OAuth2 method, no field list. + public static string ComputeStepsFingerprint(Workflow workflow, IEnumerable steps, string destinationHost, string tokenHost, + string authMethod = null) => + ProtectedWorkflowFingerprint.Compute(workflow.TriggerEventType, steps, Array.Empty(), destinationHost, tokenHost, + new ProtectedFingerprintExtras { AuthMethod = authMethod }); + + /// calls.udf#name -> the field's current sensitivity, or -1 when it no longer exists or is disabled. + public static Dictionary Sensitivities(IEnumerable fieldIds, IReadOnlyList callCustomFields) + { + var byName = (callCustomFields ?? Array.Empty()) + .Where(f => f != null && f.IsEnabled && !string.IsNullOrWhiteSpace(f.Name)) + .GroupBy(f => f.Name.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Sensitivity, StringComparer.OrdinalIgnoreCase); + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var id in WorkflowProtectedRelease.NormalizeFieldIds(fieldIds)) + { + var name = ProtectedWorkflowFieldCatalog.ParseUdfName(id); + if (name != null) + result[id] = byName.TryGetValue(name, out var sensitivity) ? sensitivity : -1; + } + + return result; + } + + /// True when ADP is on its way out (or gone): the department's releases must be revoked, not merely blocked. + public static bool IsOffboardingOrDisabled(DepartmentDataProtectionPolicy policy) + { + var state = policy == null ? DepartmentDataProtectionState.Disabled : (DepartmentDataProtectionState)policy.State; + switch (state) + { + case DepartmentDataProtectionState.OffboardingScheduled: + case DepartmentDataProtectionState.DisableRequested: + case DepartmentDataProtectionState.Decrypting: + case DepartmentDataProtectionState.Disabled: + return true; + case DepartmentDataProtectionState.Verifying: + return policy?.ActiveMigrationKind == (int)DepartmentDataProtectionMigrationKind.Offboarding; + default: + return false; + } + } + + /// The notice threshold (days) now due for an Active release, or null. Each threshold is sent once per ExpiresOn. + public static int? DueNoticeThreshold(WorkflowProtectedRelease release, DateTime utcNow) + { + if (release?.ExpiresOn == null) + return null; + + var remaining = (release.ExpiresOn.Value - utcNow).TotalDays; + var thresholds = (DataProtectionConfig.ProtectedWorkflowExpiryNoticeDays ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(t => int.TryParse(t, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d) ? d : -1) + .Where(d => d > 0) + .OrderBy(d => d) + .ToList(); + + foreach (var threshold in thresholds) + { + if (remaining <= threshold && (!release.ExpiryNoticeSentDays.HasValue || threshold < release.ExpiryNoticeSentDays.Value)) + return threshold; + } + + return null; + } + + private async Task NotifyAdminsAsync(int departmentId, string workflowId, string bodyKey, DateTime? expiresOn, CancellationToken cancellationToken) + { + try + { + var workflow = await _workflows.GetByIdAsync(workflowId); + var department = await _departments.GetDepartmentByIdAsync(departmentId, false); + foreach (var admin in await _departments.GetActiveAdminsForDepartmentAsync(departmentId) ?? new List()) + { + var profile = await _profiles.GetProfileByUserIdAsync(admin.UserId); + var message = ProtectedWorkflowsResources.Get(bodyKey, profile?.Language, workflow?.Name ?? workflowId, + expiresOn?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + var title = ProtectedWorkflowsResources.Get("NoticeExpiryTitle", profile?.Language); + await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, null, department, title, profile); + } + } + catch (Exception ex) + { + Logging.LogError($"Protected workflow expiry notice could not be sent for department {departmentId}: {ex.GetType().FullName}."); + } + } + + private async Task LoadWorkflowAsync(int departmentId, string workflowId) + { + if (string.IsNullOrWhiteSpace(workflowId)) + return null; + var workflow = await _workflows.GetByIdAsync(workflowId); + return workflow != null && workflow.DepartmentId == departmentId ? workflow : null; + } + + private async Task LoadReleaseAsync(int departmentId, string releaseId) + { + if (string.IsNullOrWhiteSpace(releaseId)) + return null; + var release = await _releases.GetByIdAsync(releaseId); + return release != null && release.DepartmentId == departmentId ? release : null; + } + + private async Task> LoadStepsAsync(string workflowId) => + (await _steps.GetAllByWorkflowIdAsync(workflowId))?.ToList() ?? new List(); + + private async Task> LoadCredentialTypesAsync(int departmentId) => + ((await _credentials.GetAllByDepartmentIdAsync(departmentId)) ?? Enumerable.Empty()) + .Where(c => !string.IsNullOrWhiteSpace(c.WorkflowCredentialId)) + .GroupBy(c => c.WorkflowCredentialId.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().CredentialType, StringComparer.OrdinalIgnoreCase); + + /// + /// Resolves (and validates) the pinned OAuth2 token host and client authentication; adds token_url_invalid when an + /// OAuth2 credential has none, and signing_key_missing when a private_key_jwt credential has no current key. + /// + private async Task ResolvePinsAsync(int departmentId, ProtectedWorkflowValidationResult validation) + { + if (validation?.CredentialType != (int)WorkflowCredentialType.OAuth2ClientCredentials) + return null; + + var pins = await GetCredentialPinsAsync(departmentId, validation.WorkflowCredentialId) ?? new ProtectedCredentialPins(); + if (pins.TokenHost == null) + validation.Add(ProtectedWorkflowValidator.TokenUrlInvalid); + if (pins.AuthMethod == WorkflowJwtKeys.PrivateKeyJwt && !pins.HasSigningKey) + validation.Add(ProtectedWorkflowValidator.SigningKeyMissing); + return pins; + } + + private static ProtectedWorkflowValidationResult Validate(Workflow workflow, IEnumerable steps, IReadOnlyDictionary credentialTypes) => + ProtectedWorkflowValidator.Validate(workflow.TriggerEventType, steps, credentialTypes, + DataProtectionConfig.ProtectedWorkflowAllowHttpBasicCredentials, DataProtectionConfig.ProtectedWorkflowMaxCaptureKeys); + + /// + /// Field ids: known call columns, calls.subjectidentifiers whole OR per key (never both), and custom fields that exist + /// (enabled) in the department's current call custom field definition. + /// + private static string ValidateFieldIds(int triggerEventType, IReadOnlyCollection fields, bool requireAny, IReadOnlyList callCustomFields) + { + if (requireAny && fields.Count == 0) + return ProtectedWorkflowErrorCodes.NoFields; + if (fields.Count > Math.Max(1, DataProtectionConfig.ProtectedWorkflowMaxFieldsPerRelease)) + return ProtectedWorkflowErrorCodes.TooManyFields; + + var plan = ProtectedWorkflowFieldCatalog.Plan(triggerEventType, fields); + if (plan.Unknown.Count > 0) + return ProtectedWorkflowErrorCodes.UnknownField; + if (plan.HasConflict) + return ProtectedWorkflowErrorCodes.FieldConflict; + if (Sensitivities(fields, callCustomFields).Values.Any(s => s < 0)) + return ProtectedWorkflowErrorCodes.UnknownField; + return null; + } + + private static string DescribeRequest(WorkflowProtectedRelease release, bool renewal) => + $"{(renewal ? "renewal;" : string.Empty)}fields={string.Join(",", release.GetAllowedFieldIds())};host={release.DestinationHost};" + + $"recipient_type={(ProtectedReleaseRecipientType)release.RecipientType};ack={release.AckVersion}" + + (release.AuthMethod != null ? $";auth={release.AuthMethod}" : string.Empty) + + (release.AllowsRestricted ? $";restricted={release.RestrictedAckVersion}" : string.Empty) + + (release.AllowsPart2 ? $";part2={release.Part2AckVersion}" : string.Empty); + + private static string Trim(string value, int max) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + value = value.Trim(); + return value.Length > max ? value.Substring(0, max) : value; + } + + private static string CsvCell(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + // Spreadsheet formula injection: a leading =, +, - or @ is neutralized with a quote. + if ("=+-@".IndexOf(value[0]) >= 0) + value = "'" + value; + + return value.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0 + ? "\"" + value.Replace("\"", "\"\"") + "\"" + : value; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsUdfService.cs b/Core/Resgrid.Services/Records/RecordsUdfService.cs index 16259ba35..79d3351f0 100644 --- a/Core/Resgrid.Services/Records/RecordsUdfService.cs +++ b/Core/Resgrid.Services/Records/RecordsUdfService.cs @@ -49,6 +49,7 @@ public async Task PublishAsync(int departmentId, string userId, s fields=Copy(fields ?? new List()); if (fields.Count>100 || fields.Any(f=>f==null)) throw new ArgumentException("A record extension supports at most 100 fields."); var errors=UdfValidationHelper.ValidateFieldNamesUnique(fields); + errors.AddRange(UdfValidationHelper.ValidateFieldOptions(fields)); foreach(var field in fields) { if (string.IsNullOrWhiteSpace(field.Label) || field.Label.Length>200 || field.Name.Length>200 || field.Description?.Length>500 || field.Placeholder?.Length>200 || field.GroupName?.Length>100 || field.DefaultValue?.Length>16000 || field.ValidationRules?.Length>16000) errors.Add("A field label or setting is missing or too long."); @@ -143,7 +144,7 @@ public async Task SaveInTransactionAsync(int departmentId, string userId if (value.Value?.Length>16000) throw new ArgumentException("A custom-field value is too long."); var optional=Copy(field.Field); optional.IsRequired=false; var errors=UdfValidationHelper.ValidateFieldValue(optional,value.Value); if(errors.Count>0) throw new ArgumentException(string.Join(" ",errors)); - section.Fields.Single(f=>f.Field.UdfFieldId==value.Key).Value=value.Value; + section.Fields.Single(f=>f.Field.UdfFieldId==value.Key).Value=UdfValidationHelper.NormalizeFieldValue(field.Field,value.Value); } await ReplaceValues(departmentId,recordId,section,userId,ct); return definition.UdfDefinitionId; } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index ce2ba1840..a2a911598 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -70,6 +70,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Protected Workflows (ADP push model): the administrative lifecycle and the unattended runtime WorkflowService + // hands every step of an approved release to. The runtime needs IProtectedDataBrokerClient + // (ProtectedDataBrokerClientModule), which every host that loads this module already registers. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -272,6 +277,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // UDF Services builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/ShiftRosterBuilder.cs b/Core/Resgrid.Services/ShiftRosterBuilder.cs new file mode 100644 index 000000000..369b8fe07 --- /dev/null +++ b/Core/Resgrid.Services/ShiftRosterBuilder.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model; + +namespace Resgrid.Services +{ + /// + /// Works out who is on a shift day from data that is already loaded, with no I/O, so every caller (day views, + /// calendars, needs, on-duty dispatch) agrees on the same answer. + /// + /// The rules, in order: + /// 1. Signups for the day that are not denied put their person on the day. A supervisor-assigned signup is a + /// single-day roster edit. A signup waiting for approval is listed but flagged pending. + /// 2. A completed trade moves a signup's slot: the source slot goes to the accepted user (or the owner of the + /// swap-back signup), and a swap-back slot goes to the requester. The original owner is off for that day. + /// 3. The standing roster (ShiftPersons) is on every day, except a person who has a denied signup for the day + /// (a supervisor took them off that day) or whose slot was traded away. Someone who also has an active signup + /// for the day is represented by that signup instead. + /// + public static class ShiftRosterBuilder + { + /// The shift, with Personnel loaded. + /// The shift day's date (department local); only the date part is used. + /// Signups for this shift; other days and shifts are ignored. + /// Trades touching this shift's signups, with Source/TargetShiftSignup populated. + public static List Build(Shift shift, DateTime day, IEnumerable signups, IEnumerable trades) + { + var entries = new List(); + + if (shift == null) + return entries; + + var date = day.Date; + var daySignups = (signups ?? Enumerable.Empty()) + .Where(x => x != null && x.ShiftId == shift.ShiftId && x.ShiftDay.Date == date) + .ToList(); + + var replacements = GetTradeReplacements(trades); + + // People on the standing roster who are off for this day. + var standingExclusions = new HashSet(StringComparer.OrdinalIgnoreCase); + // People already represented by one of their own signups for the day. + var usersWithActiveSignup = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var signup in daySignups.Where(x => x.Denied)) + standingExclusions.Add(signup.UserId); + + var standingRoster = new HashSet((shift.Personnel ?? Enumerable.Empty()) + .Where(x => x != null && !String.IsNullOrWhiteSpace(x.UserId)).Select(x => x.UserId), StringComparer.OrdinalIgnoreCase); + + foreach (var signup in daySignups.Where(x => !x.Denied).OrderBy(x => x.ShiftSignupId)) + { + usersWithActiveSignup.Add(signup.UserId); + + if (replacements.TryGetValue(signup.ShiftSignupId, out var replacement)) + { + standingExclusions.Add(signup.UserId); + + AddEntry(entries, new ShiftDayRosterEntry + { + UserId = replacement.UserId, + DepartmentGroupId = signup.DepartmentGroupId, + Source = ShiftRosterSources.Trade, + ShiftSignupId = signup.ShiftSignupId, + ApprovalPending = signup.ApprovalPending, + TradedFromUserId = signup.UserId, + ShiftSignupTradeId = replacement.ShiftSignupTradeId + }); + + continue; + } + + AddEntry(entries, new ShiftDayRosterEntry + { + UserId = signup.UserId, + DepartmentGroupId = signup.DepartmentGroupId, + Source = GetSignupSource(signup, standingRoster), + ShiftSignupId = signup.ShiftSignupId, + ApprovalPending = signup.ApprovalPending + }); + } + + if (shift.Personnel != null) + { + foreach (var person in shift.Personnel.Where(x => x != null && !String.IsNullOrWhiteSpace(x.UserId))) + { + if (standingExclusions.Contains(person.UserId) || usersWithActiveSignup.Contains(person.UserId)) + continue; + + AddEntry(entries, new ShiftDayRosterEntry + { + UserId = person.UserId, + DepartmentGroupId = person.GroupId, + Source = ShiftRosterSources.Assigned + }); + } + } + + return entries; + } + + /// + /// Remaining needs per department group and personnel role: group id to (role id to people still needed, never + /// below zero). Every shift group gets an entry, including groups with no role requirements (an empty map). + /// Only on-duty (not pending) roster entries for a group count, and each person fills at most one role + /// requirement. People who qualify for fewer of the group's roles are placed first so a multi-role person is + /// not spent on a role someone else could have filled. + /// + public static Dictionary> CalculateNeeds(Shift shift, IEnumerable roster, + IDictionary> rolesByUser) + { + var needs = new Dictionary>(); + + if (shift?.Groups == null) + return needs; + + var roles = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (rolesByUser != null) + { + foreach (var pair in rolesByUser) + if (pair.Key != null && !roles.ContainsKey(pair.Key)) + roles.Add(pair.Key, pair.Value ?? new List()); + } + + var onDuty = (roster ?? Enumerable.Empty()).Where(x => x != null && x.IsOnDuty()).ToList(); + + foreach (var group in shift.Groups.Where(x => x != null)) + { + if (!needs.TryGetValue(group.DepartmentGroupId, out var requirements)) + { + requirements = new Dictionary(); + needs.Add(group.DepartmentGroupId, requirements); + } + + if (group.Roles != null) + { + foreach (var role in group.Roles.Where(x => x != null)) + { + requirements.TryGetValue(role.PersonnelRoleId, out var existing); + requirements[role.PersonnelRoleId] = existing + Math.Max(role.Required, 0); + } + } + } + + foreach (var groupNeeds in needs) + { + var requirements = groupNeeds.Value; + + if (requirements.Count == 0) + continue; + + var candidates = onDuty + .Where(x => x.DepartmentGroupId == groupNeeds.Key) + .GroupBy(x => x.UserId, StringComparer.OrdinalIgnoreCase) + .Select(x => roles.TryGetValue(x.Key, out var userRoles) + ? userRoles.Select(r => r.PersonnelRoleId).Where(requirements.ContainsKey).Distinct().ToList() + : new List()) + .Where(x => x.Count > 0) + .OrderBy(x => x.Count) + .ToList(); + + foreach (var qualifyingRoles in candidates) + { + var open = qualifyingRoles.Where(r => requirements[r] > 0).ToList(); + + if (open.Count == 0) + continue; + + var roleToFill = open.OrderByDescending(r => requirements[r]).First(); + requirements[roleToFill]--; + } + } + + return needs; + } + + private static ShiftRosterSources GetSignupSource(ShiftSignup signup, HashSet standingRoster) + { + if (!String.IsNullOrWhiteSpace(signup.AssignedByUserId)) + return ShiftRosterSources.SupervisorAssigned; + + // A standing-roster person's own signup for a day is the per-day slot created when they asked for a trade; + // they are still there because they are scheduled, not because they signed up. + if (standingRoster.Contains(signup.UserId)) + return ShiftRosterSources.Assigned; + + return ShiftRosterSources.Signup; + } + + private static Dictionary GetTradeReplacements(IEnumerable trades) + { + var replacements = new Dictionary(); + + if (trades == null) + return replacements; + + foreach (var trade in trades.Where(x => x != null && x.IsTradeComplete() && x.SourceShiftSignup != null)) + { + var source = trade.SourceShiftSignup; + var target = trade.TargetShiftSignup; + + var sourceTaker = !String.IsNullOrWhiteSpace(trade.UserId) ? trade.UserId : target?.UserId; + + if (!String.IsNullOrWhiteSpace(sourceTaker)) + replacements[source.ShiftSignupId] = (sourceTaker, trade.ShiftSignupTradeId); + + // Swap-back: the requester works the day they took in exchange. + if (target != null && !String.IsNullOrWhiteSpace(source.UserId)) + replacements[target.ShiftSignupId] = (source.UserId, trade.ShiftSignupTradeId); + } + + return replacements; + } + + private static void AddEntry(List entries, ShiftDayRosterEntry entry) + { + var existing = entries.FirstOrDefault(x => String.Equals(x.UserId, entry.UserId, StringComparison.OrdinalIgnoreCase) && + x.DepartmentGroupId == entry.DepartmentGroupId); + + if (existing == null) + { + entries.Add(entry); + return; + } + + // The same person twice in the same group (say, traded into a slot on a day they already had): keep one + // entry, and prefer the one that actually puts them on duty. + if (existing.ApprovalPending && !entry.ApprovalPending) + entries[entries.IndexOf(existing)] = entry; + } + } +} diff --git a/Core/Resgrid.Services/ShiftsService.Scheduling.cs b/Core/Resgrid.Services/ShiftsService.Scheduling.cs new file mode 100644 index 000000000..259fc47fe --- /dev/null +++ b/Core/Resgrid.Services/ShiftsService.Scheduling.cs @@ -0,0 +1,936 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Helpers; +using Resgrid.Model.Queue; + +namespace Resgrid.Services +{ + /// + /// Day-level scheduling on top of the shift definitions: resolved rosters, open needs, who is on duty now, signups + /// with supervisor approval, single-day roster edits and trades. The roster rules themselves live in + /// ; this part loads the data and applies the workflow. + /// + public partial class ShiftsService + { + private sealed class ScheduleData + { + public Department Department { get; set; } + public List Shifts { get; set; } = new List(); + public List Signups { get; set; } = new List(); + public List Trades { get; set; } = new List(); + public Dictionary> Roles { get; set; } = new Dictionary>(); + } + + #region Schedules + + public async Task GetShiftDayScheduleAsync(int shiftDayId) + { + var day = await _shiftDaysRepository.GetShiftDayByIdAsync(shiftDayId); + + if (day == null) + return null; + + var shift = await GetShiftByIdAsync(day.ShiftId); + + if (shift == null) + return null; + + return await BuildScheduleForDayAsync(shift, day); + } + + public async Task> GetShiftDaySchedulesForDateRangeAsync(int departmentId, DateTime startDate, DateTime endDate, int? shiftId = null) + { + if (endDate.Date < startDate.Date) + (startDate, endDate) = (endDate, startDate); + + var data = await LoadScheduleDataAsync(departmentId, startDate.Date, endDate.Date); + var localNow = data.Department != null ? DateTime.UtcNow.TimeConverter(data.Department) : (DateTime?)null; + var schedules = new List(); + + foreach (var shift in data.Shifts.Where(x => !shiftId.HasValue || x.ShiftId == shiftId.Value)) + { + foreach (var day in (shift.Days ?? new List()).Where(x => x != null && x.Day.Date >= startDate.Date && x.Day.Date <= endDate.Date)) + schedules.Add(BuildSchedule(shift, day, data, localNow)); + } + + return schedules.OrderBy(x => x.Day.Start).ThenBy(x => x.Shift.Name).ToList(); + } + + public async Task> GetActiveShiftDaySchedulesAsync(int departmentId, DateTime timestampUtc) + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + + if (department == null) + return new List(); + + var localNow = timestampUtc.TimeConverter(department); + + // Look back far enough for a long shift (Hours up to 48+) that started days ago. + var data = await LoadScheduleDataAsync(departmentId, localNow.Date.AddDays(-3), localNow.Date, department); + var schedules = new List(); + + foreach (var shift in data.Shifts) + { + foreach (var day in (shift.Days ?? new List()).Where(x => x != null && + ShiftTimeWindow.IsActive(localNow, x.Day, shift.StartTime, shift.EndTime, shift.Hours))) + { + schedules.Add(BuildSchedule(shift, day, data, localNow)); + } + } + + return schedules; + } + + public async Task> GetShiftDaysStartingWithinDayAsync(DateTime timestampUtc) + { + var schedules = new List(); + + // The upcoming query covers two UTC calendar days from its reference; asking from today and tomorrow covers + // "the next 24 hours local" for every time zone. + var shifts = new Dictionary(); + foreach (var reference in new[] { timestampUtc, timestampUtc.AddDays(1) }) + { + foreach (var shift in (await _shiftsRepository.GetUpcomingShiftAndDaysAsync(reference)) ?? Enumerable.Empty()) + { + if (shift != null && !shifts.ContainsKey(shift.ShiftId)) + shifts.Add(shift.ShiftId, shift); + } + } + + foreach (var shift in shifts.Values) + { + try + { + shift.Department = shift.Department ?? await _departmentsService.GetDepartmentByIdAsync(shift.DepartmentId, false); + + if (shift.Department == null || shift.Days == null) + continue; + + var localNow = timestampUtc.TimeConverter(shift.Department); + + foreach (var day in shift.Days.Where(x => x != null)) + { + day.Shift = shift; + + if (day.Start > localNow && day.Start <= localNow.AddHours(24)) + schedules.Add(await BuildScheduleForDayAsync(shift, day)); + } + } + catch (Exception ex) + { + Logging.LogException(ex, $"DepartmentId:{shift.DepartmentId} ShiftId:{shift.ShiftId}"); + } + } + + return schedules; + } + + public async Task> GetOnDutyUserIdsForGroupAsync(int departmentId, int departmentGroupId, DateTime timestampUtc) + { + var onDuty = await GetOnDutyUserIdsForGroupsAsync(departmentId, new[] { departmentGroupId }, timestampUtc); + + return onDuty.TryGetValue(departmentGroupId, out var userIds) ? userIds : new List(); + } + + public async Task>> GetOnDutyUserIdsForGroupsAsync(int departmentId, IEnumerable departmentGroupIds, DateTime timestampUtc) + { + var groupIds = (departmentGroupIds ?? Enumerable.Empty()).Distinct().ToList(); + var result = groupIds.ToDictionary(x => x, x => new List()); + + if (!groupIds.Any()) + return result; + + var onDutyEntries = (await GetActiveShiftDaySchedulesAsync(departmentId, timestampUtc)) + .SelectMany(x => x.Roster) + .Where(x => x.IsOnDuty()) + .ToList(); + + if (!onDutyEntries.Any()) + return result; + + foreach (var groupId in groupIds) + { + var onDuty = new HashSet(onDutyEntries.Where(x => x.DepartmentGroupId == groupId).Select(x => x.UserId), StringComparer.OrdinalIgnoreCase); + + // Standing-roster people placed on a shift without a group cover their own group. + var ungrouped = onDutyEntries.Where(x => !x.DepartmentGroupId.HasValue).Select(x => x.UserId).ToList(); + + if (ungrouped.Any()) + { + var members = await _departmentGroupsService.GetAllMembersForGroupAsync(groupId) ?? new List(); + var memberIds = new HashSet(members.Select(x => x.UserId), StringComparer.OrdinalIgnoreCase); + + foreach (var userId in ungrouped.Where(memberIds.Contains)) + onDuty.Add(userId); + } + + result[groupId] = onDuty.ToList(); + } + + return result; + } + + #endregion Schedules + + #region Signups and single-day edits + + public async Task> SignupUserForShiftDayAsync(int shiftDayId, int? departmentGroupId, string userId, + CancellationToken cancellationToken = default(CancellationToken)) + { + var schedule = await GetShiftDayScheduleAsync(shiftDayId); + + if (schedule == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + if (IsOver(schedule)) + return ShiftActionResult.Fail(ShiftActionErrors.DayInPast); + + var groupId = NormalizeGroupId(departmentGroupId); + var groupCheck = ValidateGroup(schedule.Shift, groupId, requireGroupWhenShiftHasGroups: true); + + if (groupCheck != ShiftActionErrors.None) + return ShiftActionResult.Fail(groupCheck); + + if (!schedule.Shift.Groups.Any() && schedule.Shift.AssignmentType != (int)ShiftAssignmentTypes.Signup) + return ShiftActionResult.Fail(ShiftActionErrors.InvalidRequest); + + var userEntries = schedule.Roster.Where(x => SameUser(x.UserId, userId)).ToList(); + + if (userEntries.Any()) + { + var allowMultipleGroups = await _departmentSettingsService.GetAllowSignupsForMultipleShiftGroupsAsync(schedule.Shift.DepartmentId); + + if (!allowMultipleGroups || userEntries.Any(x => x.DepartmentGroupId == groupId)) + return ShiftActionResult.Fail(ShiftActionErrors.AlreadySignedUp); + } + + var signup = new ShiftSignup + { + ShiftId = schedule.Shift.ShiftId, + ShiftDay = schedule.Day.Day, + SignupTimestamp = DateTime.UtcNow, + UserId = userId, + DepartmentGroupId = groupId, + Denied = false, + ApprovalPending = schedule.Shift.RequireApproval == true + }; + + signup = await _shiftSignupRepository.SaveOrUpdateAsync(signup, cancellationToken, true); + + if (signup.ApprovalPending) + await PublishRosterChangeAsync(schedule.Shift.DepartmentId, ShiftQueueTypes.SignupPendingApproval, schedule.Shift.ShiftId, signup.ShiftSignupId, 0, userId); + + return ShiftActionResult.Ok(signup); + } + + public async Task> AssignUserToShiftDayAsync(int shiftDayId, string userId, int? departmentGroupId, string assignedByUserId, + CancellationToken cancellationToken = default(CancellationToken)) + { + if (String.IsNullOrWhiteSpace(userId)) + return ShiftActionResult.Fail(ShiftActionErrors.InvalidRequest); + + var schedule = await GetShiftDayScheduleAsync(shiftDayId); + + if (schedule == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + if (IsOver(schedule)) + return ShiftActionResult.Fail(ShiftActionErrors.DayInPast); + + var groupId = NormalizeGroupId(departmentGroupId); + var groupCheck = ValidateGroup(schedule.Shift, groupId, requireGroupWhenShiftHasGroups: false); + + if (groupCheck != ShiftActionErrors.None) + return ShiftActionResult.Fail(groupCheck); + + var member = await _departmentsService.GetDepartmentMemberAsync(userId, schedule.Shift.DepartmentId); + + if (member == null || member.IsDeleted) + return ShiftActionResult.Fail(ShiftActionErrors.NotAllowed); + + if (schedule.Roster.Any(x => SameUser(x.UserId, userId) && x.DepartmentGroupId == groupId && x.IsOnDuty())) + return ShiftActionResult.Fail(ShiftActionErrors.AlreadyOnRoster); + + var now = DateTime.UtcNow; + var userSignups = schedule.Signups.Where(x => SameUser(x.UserId, userId)).ToList(); + + // Prefer reviving the person's own record for the day: approve a pending signup for the same group, or undo an + // earlier removal, rather than stacking up duplicate rows. + var signup = userSignups.FirstOrDefault(x => x.ApprovalPending && !x.Denied && x.DepartmentGroupId == groupId) + ?? userSignups.FirstOrDefault(x => x.Denied); + + if (signup == null) + { + signup = new ShiftSignup + { + ShiftId = schedule.Shift.ShiftId, + ShiftDay = schedule.Day.Day, + SignupTimestamp = now, + UserId = userId + }; + } + + signup.DepartmentGroupId = groupId; + signup.Denied = false; + signup.ApprovalPending = false; + signup.AssignedByUserId = assignedByUserId; + signup.ReviewedByUserId = assignedByUserId; + signup.ReviewedOn = now; + signup.ReviewNote = null; + + signup = await _shiftSignupRepository.SaveOrUpdateAsync(signup, cancellationToken, true); + + await PublishRosterChangeAsync(schedule.Shift.DepartmentId, ShiftQueueTypes.DayAssigned, schedule.Shift.ShiftId, signup.ShiftSignupId, 0, assignedByUserId); + + return ShiftActionResult.Ok(signup); + } + + public async Task> RemoveUserFromShiftDayAsync(int shiftDayId, string userId, string removedByUserId, string note, + CancellationToken cancellationToken = default(CancellationToken)) + { + var schedule = await GetShiftDayScheduleAsync(shiftDayId); + + if (schedule == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + var entries = schedule.Roster.Where(x => SameUser(x.UserId, userId)).ToList(); + + if (!entries.Any()) + return ShiftActionResult.Fail(ShiftActionErrors.NotOnShift); + + var now = DateTime.UtcNow; + ShiftSignup marker = null; + + foreach (var entry in entries) + { + if (entry.ShiftSignupId.HasValue) + { + // The slot's signup is marked denied rather than deleted so the person can see what happened. For a + // trade replacement this is the original owner's signup: the slot is left open, not handed back. + var signup = schedule.Signups.FirstOrDefault(x => x.ShiftSignupId == entry.ShiftSignupId.Value); + + if (signup == null) + continue; + + MarkRemoved(signup, removedByUserId, note, now); + marker = await _shiftSignupRepository.SaveOrUpdateAsync(signup, cancellationToken, true); + + await CloseOpenTradesForSignupAsync(signup.ShiftSignupId, schedule.Trades, removedByUserId, note, now, cancellationToken); + } + else + { + // Standing roster: a denied signup for just this day takes them off it and leaves the rest of the month. + var exclusion = new ShiftSignup + { + ShiftId = schedule.Shift.ShiftId, + ShiftDay = schedule.Day.Day, + SignupTimestamp = now, + UserId = entry.UserId, + DepartmentGroupId = entry.DepartmentGroupId, + AssignedByUserId = removedByUserId + }; + + MarkRemoved(exclusion, removedByUserId, note, now); + marker = await _shiftSignupRepository.SaveOrUpdateAsync(exclusion, cancellationToken, true); + } + } + + await PublishRosterChangeAsync(schedule.Shift.DepartmentId, ShiftQueueTypes.DayRemoved, schedule.Shift.ShiftId, marker?.ShiftSignupId ?? 0, 0, removedByUserId); + + return ShiftActionResult.Ok(true); + } + + public async Task> ReviewShiftSignupAsync(int shiftSignupId, bool approve, string reviewerUserId, string note, + CancellationToken cancellationToken = default(CancellationToken)) + { + var signup = await _shiftSignupRepository.GetByIdAsync(shiftSignupId); + + if (signup == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + if (!signup.ApprovalPending || signup.Denied) + return ShiftActionResult.Fail(ShiftActionErrors.NotPending); + + var shift = await _shiftsRepository.GetByIdAsync(signup.ShiftId); + + signup.ApprovalPending = false; + signup.Denied = !approve; + signup.ReviewedByUserId = reviewerUserId; + signup.ReviewedOn = DateTime.UtcNow; + signup.ReviewNote = note; + + signup = await _shiftSignupRepository.SaveOrUpdateAsync(signup, cancellationToken, true); + + if (shift != null) + await PublishRosterChangeAsync(shift.DepartmentId, ShiftQueueTypes.SignupReviewed, shift.ShiftId, signup.ShiftSignupId, 0, reviewerUserId); + + return ShiftActionResult.Ok(signup); + } + + public async Task> GetPendingShiftSignupsAsync(int departmentId) + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + + if (department == null) + return new List(); + + var localToday = DateTime.UtcNow.TimeConverter(department).Date; + var signups = await _shiftSignupRepository.GetShiftSignupsByDepartmentIdAndDateRangeAsync(departmentId, localToday.AddDays(-1), localToday.AddYears(2)); + var pending = (signups ?? Enumerable.Empty()).Where(x => x.ApprovalPending && !x.Denied).ToList(); + + if (!pending.Any()) + return pending; + + var shifts = (await GetAllShiftsByDepartmentAsync(departmentId)).ToDictionary(x => x.ShiftId); + var groups = (await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId) ?? new List()).ToDictionary(x => x.DepartmentGroupId); + + foreach (var signup in pending) + { + if (shifts.TryGetValue(signup.ShiftId, out var shift)) + signup.Shift = shift; + + if (signup.DepartmentGroupId.HasValue && groups.TryGetValue(signup.DepartmentGroupId.Value, out var group)) + signup.Group = group; + } + + return pending.Where(x => x.Shift != null).OrderBy(x => x.ShiftDay).ToList(); + } + + #endregion Signups and single-day edits + + #region Trades + + public async Task> RequestTradeAsync(int shiftDayId, string userId, List userIds, string note, + CancellationToken cancellationToken = default(CancellationToken)) + { + var schedule = await GetShiftDayScheduleAsync(shiftDayId); + + if (schedule == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + var entry = schedule.Roster.FirstOrDefault(x => SameUser(x.UserId, userId) && x.IsOnDuty()); + + if (entry == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotOnShift); + + // A slot someone traded to you is still backed by their signup; trading it on again is not supported. + if (entry.Source == ShiftRosterSources.Trade) + return ShiftActionResult.Fail(ShiftActionErrors.InvalidRequest); + + var signupId = entry.ShiftSignupId; + + if (!signupId.HasValue) + { + if (IsOver(schedule)) + return ShiftActionResult.Fail(ShiftActionErrors.DayInPast); + + if (!NormalizeUserIds(userIds, userId).Any()) + return ShiftActionResult.Fail(ShiftActionErrors.NoUsers); + + // People on the standing roster have no row for a single day. Give them one, in the group they are + // scheduled for, so the day can be traded without touching the rest of the month. + var slot = await _shiftSignupRepository.SaveOrUpdateAsync(new ShiftSignup + { + ShiftId = schedule.Shift.ShiftId, + ShiftDay = schedule.Day.Day, + SignupTimestamp = DateTime.UtcNow, + UserId = entry.UserId, + DepartmentGroupId = entry.DepartmentGroupId + }, cancellationToken, true); + + signupId = slot.ShiftSignupId; + } + + return await RequestTradeForSignupAsync(signupId.Value, userId, userIds, note, cancellationToken); + } + + public async Task> RequestTradeForSignupAsync(int shiftSignupId, string userId, List userIds, string note, + CancellationToken cancellationToken = default(CancellationToken)) + { + var signup = await GetShiftSignupByIdAsync(shiftSignupId); + + if (signup == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + if (!SameUser(signup.UserId, userId)) + return ShiftActionResult.Fail(ShiftActionErrors.NotAllowed); + + if (!signup.IsActive()) + return ShiftActionResult.Fail(ShiftActionErrors.InvalidRequest); + + var shift = await GetShiftByIdAsync(signup.ShiftId); + + if (shift == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + shift.Department = shift.Department ?? await _departmentsService.GetDepartmentByIdAsync(shift.DepartmentId, false); + + if (IsOver(new ShiftDay { Day = signup.ShiftDay, Shift = shift, ShiftId = shift.ShiftId }, shift.Department)) + return ShiftActionResult.Fail(ShiftActionErrors.DayInPast); + + var existing = await _shiftSignupTradeRepository.GetShiftSignupTradeBySourceShiftSignupIdAsync(shiftSignupId); + + if (existing != null && !existing.Denied) + return ShiftActionResult.Fail(ShiftActionErrors.TradeExists); + + // A day this user took as a swap-back is worked by the other person now. + var swappedAway = await _shiftSignupTradeRepository.GetShiftSignupTradeByTargetShiftSignupIdAsync(shiftSignupId); + + if (swappedAway != null && swappedAway.IsTradeComplete()) + return ShiftActionResult.Fail(ShiftActionErrors.NotOnShift); + + var invited = NormalizeUserIds(userIds, userId); + + if (!invited.Any()) + return ShiftActionResult.Fail(ShiftActionErrors.NoUsers); + + var members = await _departmentsService.GetAllMembersForDepartmentAsync(shift.DepartmentId) ?? new List(); + var memberIds = new HashSet(members.Where(x => !x.IsDeleted).Select(x => x.UserId), StringComparer.OrdinalIgnoreCase); + + if (invited.Any(x => !memberIds.Contains(x))) + return ShiftActionResult.Fail(ShiftActionErrors.NotAllowed); + + var trade = new ShiftSignupTrade + { + SourceShiftSignupId = signup.ShiftSignupId, + Note = note, + Users = invited.Select(x => new ShiftSignupTradeUser { UserId = x }).ToList() + }; + + trade = await _shiftSignupTradeRepository.SaveOrUpdateAsync(trade, cancellationToken); + + await PublishAsync(shift.DepartmentId, number => _eventAggregator.SendMessage(new ShiftTradeRequestedEvent + { + DepartmentId = shift.DepartmentId, + DepartmentNumber = number, + ShiftSignupTradeId = trade.ShiftSignupTradeId + })); + + return ShiftActionResult.Ok(trade); + } + + public async Task> RespondToTradeAsync(int shiftSignupTradeId, string userId, bool accept, string note, List offeredShiftSignupIds, + CancellationToken cancellationToken = default(CancellationToken)) + { + var trade = await GetShiftTradeByIdAsync(shiftSignupTradeId); + + if (trade?.SourceShiftSignup?.Shift == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + if (trade.Users == null || !trade.Users.Any(x => SameUser(x.UserId, userId))) + return ShiftActionResult.Fail(ShiftActionErrors.NotAllowed); + + if (trade.HasSelection() && !trade.Denied) + return ShiftActionResult.Fail(ShiftActionErrors.InvalidRequest); + + var departmentId = trade.SourceShiftSignup.Shift.DepartmentId; + + if (!accept) + { + await RejectTradeRequestAsync(shiftSignupTradeId, userId, note, cancellationToken); + + await PublishAsync(departmentId, number => _eventAggregator.SendMessage(new ShiftTradeRejectedEvent + { + DepartmentId = departmentId, + DepartmentNumber = number, + ShiftSignupTradeId = shiftSignupTradeId, + UserId = userId + })); + + return ShiftActionResult.Ok(await GetShiftTradeByIdAsync(shiftSignupTradeId)); + } + + var offers = (offeredShiftSignupIds ?? new List()).Distinct().ToList(); + + foreach (var offerId in offers) + { + var offered = await _shiftSignupRepository.GetByIdAsync(offerId); + + if (offered == null || !SameUser(offered.UserId, userId) || !offered.IsActive() || offered.ShiftSignupId == trade.SourceShiftSignupId) + return ShiftActionResult.Fail(ShiftActionErrors.InvalidOffer); + } + + await ProposeShiftDaysForTradeAsync(shiftSignupTradeId, userId, note, offers, cancellationToken); + + await PublishAsync(departmentId, number => _eventAggregator.SendMessage(new ShiftTradeProposedEvent + { + DepartmentId = departmentId, + DepartmentNumber = number, + ShiftSignupTradeId = shiftSignupTradeId, + UserId = userId + })); + + return ShiftActionResult.Ok(await GetShiftTradeByIdAsync(shiftSignupTradeId)); + } + + public async Task> FinishTradeAsync(int shiftSignupTradeId, string requesterUserId, string acceptedUserId, int? targetShiftSignupId, + CancellationToken cancellationToken = default(CancellationToken)) + { + var trade = await GetShiftTradeByIdAsync(shiftSignupTradeId); + + if (trade?.SourceShiftSignup?.Shift == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + if (!SameUser(trade.SourceShiftSignup.UserId, requesterUserId)) + return ShiftActionResult.Fail(ShiftActionErrors.NotAllowed); + + // Once picked, the choice stands unless a supervisor denied it, in which case another offer can be picked. + if (trade.HasSelection() && !trade.Denied) + return ShiftActionResult.Fail(ShiftActionErrors.InvalidRequest); + + var offeredUsers = (trade.Users ?? new List()).Where(x => x.Offered && !x.Declined).ToList(); + string takerUserId; + + if (targetShiftSignupId.HasValue && targetShiftSignupId.Value > 0) + { + // Only a signup a participant actually put up for this trade can be taken as the swap-back day. + var offeringUser = offeredUsers.FirstOrDefault(x => x.Shifts != null && x.Shifts.Any(y => y.ShiftSignupId == targetShiftSignupId.Value)); + var target = offeringUser != null ? await _shiftSignupRepository.GetByIdAsync(targetShiftSignupId.Value) : null; + + if (target == null || !target.IsActive() || !SameUser(target.UserId, offeringUser.UserId)) + return ShiftActionResult.Fail(ShiftActionErrors.InvalidOffer); + + trade.TargetShiftSignupId = target.ShiftSignupId; + trade.UserId = null; + takerUserId = target.UserId; + } + else if (!String.IsNullOrWhiteSpace(acceptedUserId)) + { + var accepted = offeredUsers.FirstOrDefault(x => SameUser(x.UserId, acceptedUserId)); + + if (accepted == null) + return ShiftActionResult.Fail(ShiftActionErrors.InvalidOffer); + + trade.UserId = accepted.UserId; + trade.TargetShiftSignupId = null; + takerUserId = accepted.UserId; + } + else + { + return ShiftActionResult.Fail(ShiftActionErrors.InvalidRequest); + } + + var shift = trade.SourceShiftSignup.Shift; + + trade.Denied = false; + trade.ApprovalPending = shift.RequireApproval == true; + + await _shiftSignupTradeRepository.SaveOrUpdateAsync(trade, cancellationToken, true); + + if (trade.ApprovalPending) + { + await PublishRosterChangeAsync(shift.DepartmentId, ShiftQueueTypes.TradePendingApproval, shift.ShiftId, trade.SourceShiftSignupId, trade.ShiftSignupTradeId, requesterUserId); + } + else + { + await PublishAsync(shift.DepartmentId, number => _eventAggregator.SendMessage(new ShiftTradeFilledEvent + { + DepartmentId = shift.DepartmentId, + DepartmentNumber = number, + ShiftSignupTradeId = trade.ShiftSignupTradeId, + UserId = takerUserId + })); + } + + return ShiftActionResult.Ok(await GetShiftTradeByIdAsync(shiftSignupTradeId)); + } + + public async Task> CancelTradeAsync(int shiftSignupTradeId, string requesterUserId, + CancellationToken cancellationToken = default(CancellationToken)) + { + var trade = await GetShiftTradeByIdAsync(shiftSignupTradeId); + + if (trade?.SourceShiftSignup == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + if (!SameUser(trade.SourceShiftSignup.UserId, requesterUserId)) + return ShiftActionResult.Fail(ShiftActionErrors.NotAllowed); + + if (trade.IsTradeComplete()) + return ShiftActionResult.Fail(ShiftActionErrors.InvalidRequest); + + // Trade users and their offered days cascade with the trade. + await _shiftSignupTradeRepository.DeleteAsync(trade, cancellationToken); + + return ShiftActionResult.Ok(true); + } + + public async Task> ReviewTradeAsync(int shiftSignupTradeId, bool approve, string reviewerUserId, string note, + CancellationToken cancellationToken = default(CancellationToken)) + { + var trade = await GetShiftTradeByIdAsync(shiftSignupTradeId); + + if (trade?.SourceShiftSignup?.Shift == null) + return ShiftActionResult.Fail(ShiftActionErrors.NotFound); + + if (!trade.ApprovalPending || trade.Denied) + return ShiftActionResult.Fail(ShiftActionErrors.NotPending); + + trade.ApprovalPending = false; + trade.Denied = !approve; + trade.ReviewedByUserId = reviewerUserId; + trade.ReviewedOn = DateTime.UtcNow; + trade.ReviewNote = note; + + await _shiftSignupTradeRepository.SaveOrUpdateAsync(trade, cancellationToken, true); + + var shift = trade.SourceShiftSignup.Shift; + await PublishRosterChangeAsync(shift.DepartmentId, ShiftQueueTypes.TradeReviewed, shift.ShiftId, trade.SourceShiftSignupId, trade.ShiftSignupTradeId, reviewerUserId); + + return ShiftActionResult.Ok(await GetShiftTradeByIdAsync(shiftSignupTradeId)); + } + + public async Task> GetPendingTradesAsync(int departmentId) + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + + if (department == null) + return new List(); + + var localToday = DateTime.UtcNow.TimeConverter(department).Date; + var trades = await _shiftSignupTradeRepository.GetShiftSignupTradesByDepartmentIdAsync(departmentId, localToday.AddDays(-1)); + + return await LoadTradesAsync((trades ?? Enumerable.Empty()).Where(x => x.ApprovalPending && !x.Denied).Select(x => x.ShiftSignupTradeId)); + } + + public async Task> GetTradesForUserAsync(int departmentId, string userId) + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + + if (department == null) + return new List(); + + var localToday = DateTime.UtcNow.TimeConverter(department).Date; + var departmentTrades = await _shiftSignupTradeRepository.GetShiftSignupTradesByDepartmentIdAsync(departmentId, localToday.AddDays(-1)) ?? Enumerable.Empty(); + var incoming = await _shiftSignupTradeRepository.GetAllOpenTradeRequestsByUserIdAsync(userId) ?? Enumerable.Empty(); + + var ids = departmentTrades.Where(x => x.SourceShiftSignup != null && SameUser(x.SourceShiftSignup.UserId, userId)).Select(x => x.ShiftSignupTradeId) + .Concat(incoming.Select(x => x.ShiftSignupTradeId)); + + var trades = await LoadTradesAsync(ids); + + return trades.Where(x => x.SourceShiftSignup?.Shift != null && x.SourceShiftSignup.Shift.DepartmentId == departmentId).ToList(); + } + + #endregion Trades + + #region Helpers + + private async Task LoadScheduleDataAsync(int departmentId, DateTime startDate, DateTime endDate, Department department = null) + { + var data = new ScheduleData(); + + data.Department = department ?? await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + data.Shifts = await GetAllShiftsByDepartmentAsync(departmentId); + data.Signups = ((await _shiftSignupRepository.GetShiftSignupsByDepartmentIdAndDateRangeAsync(departmentId, startDate.Date, endDate.Date.AddDays(1))) + ?? Enumerable.Empty()).ToList(); + data.Trades = ((await _shiftSignupTradeRepository.GetShiftSignupTradesByDepartmentIdAsync(departmentId, startDate.Date)) + ?? Enumerable.Empty()).ToList(); + data.Roles = await _personnelRolesService.GetAllRolesForUsersInDepartmentAsync(departmentId) ?? new Dictionary>(); + + foreach (var shift in data.Shifts) + { + shift.Department = shift.Department ?? data.Department; + shift.Personnel = shift.Personnel ?? new List(); + shift.Groups = shift.Groups ?? new List(); + } + + return data; + } + + private async Task BuildScheduleForDayAsync(Shift shift, ShiftDay day) + { + var department = shift.Department ?? await _departmentsService.GetDepartmentByIdAsync(shift.DepartmentId, false); + shift.Department = department; + shift.Personnel = shift.Personnel ?? new List(); + shift.Groups = shift.Groups ?? new List(); + + var data = new ScheduleData + { + Department = department, + Signups = ((await _shiftSignupRepository.GetAllShiftSignupsByShiftIdAndDateAsync(shift.ShiftId, day.Day)) ?? Enumerable.Empty()).ToList(), + Trades = ((await _shiftSignupTradeRepository.GetShiftSignupTradesByDepartmentIdAsync(shift.DepartmentId, day.Day.Date)) ?? Enumerable.Empty()).ToList(), + Roles = await _personnelRolesService.GetAllRolesForUsersInDepartmentAsync(shift.DepartmentId) ?? new Dictionary>() + }; + + var localNow = department != null ? DateTime.UtcNow.TimeConverter(department) : (DateTime?)null; + + return BuildSchedule(shift, day, data, localNow); + } + + private static ShiftDaySchedule BuildSchedule(Shift shift, ShiftDay day, ScheduleData data, DateTime? localNow) + { + day.Shift = shift; + + var date = day.Day.Date; + var signups = data.Signups.Where(x => x.ShiftId == shift.ShiftId && x.ShiftDay.Date == date).ToList(); + var signupIds = new HashSet(signups.Select(x => x.ShiftSignupId)); + var trades = data.Trades.Where(x => signupIds.Contains(x.SourceShiftSignupId) || + (x.TargetShiftSignupId.HasValue && signupIds.Contains(x.TargetShiftSignupId.Value))).ToList(); + + var roster = ShiftRosterBuilder.Build(shift, date, signups, trades); + + return new ShiftDaySchedule + { + Day = day, + Shift = shift, + Signups = signups, + Trades = trades, + Roster = roster, + Needs = ShiftRosterBuilder.CalculateNeeds(shift, roster, data.Roles), + IsActive = localNow.HasValue && ShiftTimeWindow.IsActive(localNow.Value, day.Day, shift.StartTime, shift.EndTime, shift.Hours) + }; + } + + private async Task> LoadTradesAsync(IEnumerable tradeIds) + { + var trades = new List(); + + foreach (var tradeId in tradeIds.Distinct()) + { + var trade = await GetShiftTradeByIdAsync(tradeId); + + if (trade != null) + trades.Add(trade); + } + + return trades.OrderBy(x => x.SourceShiftSignup?.ShiftDay).ToList(); + } + + /// + /// Clears the two non-cascading references to a signup: a trade using it as the swap-back day goes back to + /// having no pick, and any offer that put it up is withdrawn. + /// + private async Task ReleaseTradeReferencesToSignupAsync(int shiftSignupId, CancellationToken cancellationToken) + { + for (var guard = 0; guard < 25; guard++) + { + var targeting = await _shiftSignupTradeRepository.GetShiftSignupTradeByTargetShiftSignupIdAsync(shiftSignupId); + + if (targeting == null) + break; + + targeting.TargetShiftSignupId = null; + targeting.ApprovalPending = false; + await _shiftSignupTradeRepository.SaveOrUpdateAsync(targeting, cancellationToken, true); + } + + var offers = await _shiftSignupTradeUserShiftsRepository.GetShiftSignupTradeUserShiftsBySignupIdAsync(shiftSignupId); + + foreach (var offer in offers ?? Enumerable.Empty()) + await _shiftSignupTradeUserShiftsRepository.DeleteAsync(offer, cancellationToken); + } + + /// + /// A person taken off a day can no longer hand that day to someone else, so an open or pending trade on the + /// slot is closed as denied. + /// + private async Task CloseOpenTradesForSignupAsync(int shiftSignupId, IEnumerable trades, string userId, string note, DateTime now, + CancellationToken cancellationToken) + { + foreach (var trade in trades.Where(x => x.SourceShiftSignupId == shiftSignupId && !x.Denied && !x.IsTradeComplete())) + { + trade.ApprovalPending = false; + trade.Denied = true; + trade.ReviewedByUserId = userId; + trade.ReviewedOn = now; + trade.ReviewNote = note; + + await _shiftSignupTradeRepository.SaveOrUpdateAsync(trade, cancellationToken, true); + } + } + + private static void MarkRemoved(ShiftSignup signup, string userId, string note, DateTime now) + { + signup.Denied = true; + signup.ApprovalPending = false; + signup.ReviewedByUserId = userId; + signup.ReviewedOn = now; + signup.ReviewNote = note; + } + + private static bool IsOver(ShiftDaySchedule schedule) + { + return IsOver(schedule.Day, schedule.Shift.Department); + } + + private static bool IsOver(ShiftDay day, Department department) + { + if (department == null) + return false; + + return DateTime.UtcNow.TimeConverter(department) >= day.End; + } + + private static int? NormalizeGroupId(int? departmentGroupId) + { + return departmentGroupId.HasValue && departmentGroupId.Value > 0 ? departmentGroupId : null; + } + + private static ShiftActionErrors ValidateGroup(Shift shift, int? groupId, bool requireGroupWhenShiftHasGroups) + { + var groups = shift.Groups ?? new List(); + + if (groupId.HasValue) + return groups.Any(x => x.DepartmentGroupId == groupId.Value) ? ShiftActionErrors.None : ShiftActionErrors.InvalidGroup; + + if (requireGroupWhenShiftHasGroups && groups.Any()) + return ShiftActionErrors.InvalidGroup; + + return ShiftActionErrors.None; + } + + private static List NormalizeUserIds(IEnumerable userIds, string excludeUserId) + { + return (userIds ?? Enumerable.Empty()) + .Where(x => !String.IsNullOrWhiteSpace(x) && !SameUser(x, excludeUserId)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool SameUser(string a, string b) + { + return String.Equals(a, b, StringComparison.OrdinalIgnoreCase); + } + + private Task PublishRosterChangeAsync(int departmentId, ShiftQueueTypes type, int shiftId, int shiftSignupId, int shiftSignupTradeId, string userId) + { + return PublishAsync(departmentId, number => _eventAggregator.SendMessage(new ShiftRosterChangedEvent + { + DepartmentId = departmentId, + DepartmentNumber = number, + ChangeType = type, + ShiftId = shiftId, + ShiftSignupId = shiftSignupId, + ShiftSignupTradeId = shiftSignupTradeId, + UserId = userId + })); + } + + /// + /// Notifications are best effort: the roster change has already been saved, so a failure to queue a + /// notification is logged rather than surfaced as a failed action. + /// + private async Task PublishAsync(int departmentId, Action send) + { + try + { + var number = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(departmentId); + send(number); + } + catch (Exception ex) + { + Logging.LogException(ex, $"DepartmentId:{departmentId}"); + } + } + + #endregion Helpers + } +} diff --git a/Core/Resgrid.Services/ShiftsService.cs b/Core/Resgrid.Services/ShiftsService.cs index 5c74a4d26..8bd45da68 100644 --- a/Core/Resgrid.Services/ShiftsService.cs +++ b/Core/Resgrid.Services/ShiftsService.cs @@ -6,12 +6,14 @@ using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Helpers; +using Resgrid.Model.Providers; +using Resgrid.Model.Queue; using Resgrid.Model.Repositories; using Resgrid.Model.Services; namespace Resgrid.Services { - public class ShiftsService : IShiftsService + public partial class ShiftsService : IShiftsService { private readonly IShiftsRepository _shiftsRepository; private readonly IShiftPersonRepository _shiftPersonRepository; @@ -28,13 +30,16 @@ public class ShiftsService : IShiftsService private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IShiftGroupAssignmentsRepository _shiftGroupAssignmentsRepository; private readonly IShiftGroupRolesRepository _shiftGroupRolesRepository; + private readonly IEventAggregator _eventAggregator; + private readonly IDepartmentSettingsService _departmentSettingsService; public ShiftsService(IShiftsRepository shiftsRepository, IShiftPersonRepository shiftPersonRepository, IShiftDaysRepository shiftDaysRepository, IShiftGroupsRepository shiftGroupsRepository, IShiftSignupRepository shiftSignupRepository, IShiftSignupTradeRepository shiftSignupTradeRepository, IPersonnelRolesService personnelRolesService, IShiftSignupTradeUserRepository shiftSignupTradeUserRepository, IShiftSignupTradeUserShiftsRepository shiftSignupTradeUserShiftsRepository, IShiftStaffingRepository shiftStaffingRepository, IShiftStaffingPersonRepository shiftStaffingPersonRepository, IDepartmentsService departmentsService, - IDepartmentGroupsService departmentGroupsService, IShiftGroupAssignmentsRepository shiftGroupAssignmentsRepository, IShiftGroupRolesRepository shiftGroupRolesRepositor) + IDepartmentGroupsService departmentGroupsService, IShiftGroupAssignmentsRepository shiftGroupAssignmentsRepository, IShiftGroupRolesRepository shiftGroupRolesRepositor, + IEventAggregator eventAggregator, IDepartmentSettingsService departmentSettingsService) { _shiftsRepository = shiftsRepository; _shiftPersonRepository = shiftPersonRepository; @@ -51,6 +56,8 @@ public ShiftsService(IShiftsRepository shiftsRepository, IShiftPersonRepository _departmentGroupsService = departmentGroupsService; _shiftGroupAssignmentsRepository = shiftGroupAssignmentsRepository; _shiftGroupRolesRepository = shiftGroupRolesRepositor; + _eventAggregator = eventAggregator; + _departmentSettingsService = departmentSettingsService; } public async Task> GetAllShiftsByDepartmentAsync(int departmentId) @@ -87,23 +94,49 @@ public async Task PopulateShiftData(Shift shift, bool getDepartment, bool if (getDepartment && shift.Department == null) shift.Department = await _departmentsService.GetDepartmentByIdAsync(shift.DepartmentId); + // Only load what is missing. This used to replace an already-loaded Personnel list with an empty one, which + // zeroed the API's PersonnelCount/InShift and made the edit page save the shift with nobody assigned. if (getPersonnel && shift.Personnel == null) - shift.Personnel = (await _shiftPersonRepository.GetAllShiftPersonsByShiftIdAsync(shift.ShiftId)).ToList(); - else + shift.Personnel = (await _shiftPersonRepository.GetAllShiftPersonsByShiftIdAsync(shift.ShiftId) ?? Enumerable.Empty()).ToList(); + else if (shift.Personnel == null) shift.Personnel = new List(); if (getGroups && shift.Groups == null) shift.Groups = await GetShiftGroupsForShift(shift.ShiftId); if (getSignups && shift.Signups == null) - shift.Signups = (await _shiftSignupRepository.GetAllShiftSignupsByShiftIdAsync(shift.ShiftId)).ToList(); + shift.Signups = (await _shiftSignupRepository.GetAllShiftSignupsByShiftIdAsync(shift.ShiftId) ?? Enumerable.Empty()).ToList(); return shift; } public async Task SaveShiftAsync(Shift shift, CancellationToken cancellationToken = default(CancellationToken)) { - return await _shiftsRepository.SaveOrUpdateAsync(shift, cancellationToken); + var saved = await _shiftsRepository.SaveOrUpdateAsync(shift, cancellationToken); + + // The repository cascades one level: a new shift's Groups are written, but each group's Roles are a level + // deeper and were silently dropped, so new shifts never had role requirements. Write any unsaved ones. + if (saved?.Groups != null) + { + foreach (var group in saved.Groups.Where(x => x != null && x.ShiftGroupId > 0 && x.Roles != null)) + { + foreach (var role in group.Roles.Where(x => x != null && x.ShiftGroupRoleId == 0)) + { + role.ShiftGroupId = group.ShiftGroupId; + await _shiftGroupRolesRepository.SaveOrUpdateAsync(role, cancellationToken, true); + } + } + } + + return saved; + } + + public async Task UpdateShiftAsync(Shift shift, CancellationToken cancellationToken = default(CancellationToken)) + { + if (shift == null) + return null; + + return await _shiftsRepository.SaveOrUpdateAsync(shift, cancellationToken, true); } public async Task UpdateShiftStartDayAsync(Shift shift, DateTime startDay, CancellationToken cancellationToken = default(CancellationToken)) @@ -123,7 +156,10 @@ public async Task> GetShiftGroupsForShift(int shiftId) { var groups = await _shiftGroupsRepository.GetShiftGroupsByShiftIdAsync(shiftId); - if (groups != null && groups.Any()) + if (groups == null) + return new List(); + + if (groups.Any()) { foreach (var shiftGroup in groups) { @@ -140,7 +176,12 @@ public async Task> GetShiftGroupsForShift(int shiftId) { var dbShift = await GetShiftByIdAsync(shift.ShiftId); - foreach (var shiftPerson in dbShift.Personnel) + if (dbShift == null) + return false; + + newPersonnel = newPersonnel ?? new List(); + + foreach (var shiftPerson in dbShift.Personnel ?? new List()) { await _shiftPersonRepository.DeleteAsync(shiftPerson, cancellationToken); } @@ -212,6 +253,13 @@ public async Task> GetShiftGroupsForShift(int shiftId) public async Task DeleteShift(Shift shift, CancellationToken cancellationToken = default(CancellationToken)) { + // Signups cascade with the shift, but a trade elsewhere can still point at one of them as its swap-back day + // or offer, and those two foreign keys do not cascade, so the delete would fail. + var signups = await _shiftSignupRepository.GetAllShiftSignupsByShiftIdAsync(shift.ShiftId); + + foreach (var signup in signups ?? Enumerable.Empty()) + await ReleaseTradeReferencesToSignupAsync(signup.ShiftSignupId, cancellationToken); + return await _shiftsRepository.DeleteAsync(shift, cancellationToken); } @@ -239,12 +287,15 @@ public async Task> GetShiftGroupsForShift(int shiftId) if (userTradeRequest != null) { userTradeRequest.Declined = true; + userTradeRequest.Offered = false; userTradeRequest.Reason = reason; - await _shiftSignupTradeUserRepository.SaveOrUpdateAsync(userTradeRequest, cancellationToken); + await _shiftSignupTradeUserRepository.SaveOrUpdateAsync(userTradeRequest, cancellationToken, true); + + return true; } - return true; + return false; } public async Task ProposeShiftDaysForTradeAsync(int shiftTradeId, string userId, string reason, List signups, CancellationToken cancellationToken = default(CancellationToken)) @@ -260,17 +311,26 @@ public async Task> GetShiftGroupsForShift(int shiftId) { userTradeRequest.Reason = reason; userTradeRequest.Offered = true; + userTradeRequest.Declined = false; + + await _shiftSignupTradeUserRepository.SaveOrUpdateAsync(userTradeRequest, cancellationToken, true); - await _shiftSignupTradeUserRepository.SaveOrUpdateAsync(userTradeRequest, cancellationToken); + // A second answer replaces the first rather than piling more offered days onto it. + if (userTradeRequest.Shifts != null) + { + foreach (var previousOffer in userTradeRequest.Shifts.Where(x => x != null).ToList()) + await _shiftSignupTradeUserShiftsRepository.DeleteAsync(previousOffer, cancellationToken); + } if (signups != null && signups.Any()) { var shiftSignups = new List(); - foreach (var i in signups) + foreach (var i in signups.Distinct()) { var signup = await GetShiftSignupByIdAsync(i); - if (signup != null) + // Only the proposer's own live signups can be offered back, never the day being traded. + if (signup != null && signup.UserId == userId && signup.IsActive() && signup.ShiftSignupId != trade.SourceShiftSignupId) { var shift = new ShiftSignupTradeUserShift(); shift.ShiftSignupTradeUserId = userTradeRequest.ShiftSignupTradeUserId; @@ -365,32 +425,34 @@ public async Task> GetShiftDaysForDayAsync(DateTime currentTime, { var shiftDays = new List(); - var shifts = await _shiftsRepository.GetAllByDepartmentIdAsync(departmentId); var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + if (department == null) + return shiftDays; + + var shifts = await GetAllShiftsByDepartmentAsync(departmentId); + var localNow = currentTime.TimeConverter(department); + var localDate = localNow.Date; + // The department-local day's shift days, plus a night shift from the day before that is still running. + // This used to be "starts within twelve hours of now", which mixed yesterday's and tomorrow's days in. foreach (var shift in shifts) { - shift.Days = new List(await _shiftDaysRepository.GetAllShiftDaysByShiftIdAsync(shift.ShiftId)); - - var localizedDate = currentTime.TimeConverter(department); - - var shiftStart = shift.StartTime; + if (shift.Days == null) + continue; - if (String.IsNullOrWhiteSpace(shiftStart)) - shiftStart = "12:00 AM"; + shift.Department = shift.Department ?? department; - var startTime = DateTimeHelpers.ConvertStringTime(shiftStart, localizedDate, department.Use24HourTime.GetValueOrDefault()); - - var days = from sd in shift.Days - let shiftDayTime = DateTimeHelpers.ConvertStringTime(shiftStart, sd.Day, department.Use24HourTime.GetValueOrDefault()) - let nextDayShiftTime = localizedDate - where shiftDayTime == nextDayShiftTime.Within(TimeSpan.FromHours(12)) - select sd; + foreach (var day in shift.Days.Where(x => x != null)) + { + day.Shift = shift; - shiftDays.AddRange(days); + if (day.Day.Date == localDate || + (day.Day.Date < localDate && ShiftTimeWindow.IsActive(localNow, day.Day, shift.StartTime, shift.EndTime, shift.Hours))) + shiftDays.Add(day); + } } - return shiftDays; + return shiftDays.OrderBy(x => x.Start).ToList(); } public string GenerateShiftNotificationText(Shift shift) @@ -403,25 +465,25 @@ public string GenerateShiftNotificationText(Shift shift) public string GenerateShiftTradeNotificationText(UserProfile profile, ShiftSignupTrade trade) { - return string.Format("Shift Trade Request From {0} for {1}", profile.FullName.AsFirstNameLastName, trade.SourceShiftSignup.ShiftDay.ToShortDateString()); + return string.Format("Shift Trade Request From {0} for {1}", profile?.FullName?.AsFirstNameLastName, trade?.SourceShiftSignup?.ShiftDay.ToShortDateString()); } public string GenerateShiftTradeRejectionText(UserProfile profile, ShiftSignupTrade trade) { - return string.Format("{0} Rejected Shift Trade Request for {1}", profile.FullName.AsFirstNameLastName, trade.SourceShiftSignup.ShiftDay.ToShortDateString()); + return string.Format("{0} Rejected Shift Trade Request for {1}", profile?.FullName?.AsFirstNameLastName, trade?.SourceShiftSignup?.ShiftDay.ToShortDateString()); } public string GenerateShiftTradeProposedText(UserProfile profile, ShiftSignupTrade trade) { - return string.Format("{0} Proposed Shift Trades for {1}", profile.FullName.AsFirstNameLastName, trade.SourceShiftSignup.ShiftDay.ToShortDateString()); + return string.Format("{0} Proposed Shift Trades for {1}", profile?.FullName?.AsFirstNameLastName, trade?.SourceShiftSignup?.ShiftDay.ToShortDateString()); } public string GenerateShiftTradeFilledText(UserProfile tradeProfile, ShiftSignupTrade trade) { - if (trade.TargetShiftSignup != null) - return string.Format("{0} accepted trade {1} for {2}", tradeProfile.FullName.AsFirstNameLastName, trade.SourceShiftSignup.ShiftDay.ToShortDateString(), trade.TargetShiftSignup.ShiftDay.ToShortDateString()); + if (trade?.TargetShiftSignup != null) + return string.Format("{0} accepted trade {1} for {2}", tradeProfile?.FullName?.AsFirstNameLastName, trade.SourceShiftSignup?.ShiftDay.ToShortDateString(), trade.TargetShiftSignup.ShiftDay.ToShortDateString()); else - return string.Format("{0} accepted you working {1}", tradeProfile.FullName.AsFirstNameLastName, trade.SourceShiftSignup.ShiftDay.ToShortDateString()); + return string.Format("{0} accepted you working {1}", tradeProfile?.FullName?.AsFirstNameLastName, trade?.SourceShiftSignup?.ShiftDay.ToShortDateString()); } public async Task GetShiftDayByIdAsync(int shiftDayId) @@ -431,161 +493,68 @@ public async Task GetShiftDayByIdAsync(int shiftDayId) public async Task IsShiftDayFilledAsync(int shiftDayId) { - bool isFilled = true; - var shiftGroups = await GetShiftDayNeedsAsync(shiftDayId); - - if (shiftGroups == null) - return true; + var schedule = await GetShiftDayScheduleAsync(shiftDayId); - foreach (var group in shiftGroups) - { - foreach (var role in group.Value) - { - if (role.Value > 0) - isFilled = false; - } - } - - return isFilled; + return schedule == null || schedule.IsFilled(); } public async Task IsShiftDayFilledWithObjAsync(Shift shift, ShiftDay shiftDay) { - bool isFilled = true; - var shiftGroups = await GetShiftDayNeedsObjAsync(shift, shiftDay); - - if (shiftGroups == null) - return true; - - foreach (var group in shiftGroups) - { - foreach (var role in group.Value) - { - if (role.Value > 0) - isFilled = false; - } - } + var needs = await GetShiftDayNeedsObjAsync(shift, shiftDay); - return isFilled; + return needs == null || needs.Values.All(x => x.Values.All(v => v <= 0)); } + /// + /// Needs for a day of an already-loaded shift. Returns null when the shift has no groups (nothing to fill). + /// Needs are counted against the resolved roster, so assigned staff, pending approvals, single-day removals and + /// trades are all taken into account, and each person fills one role requirement at most. + /// public async Task>> GetShiftDayNeedsObjAsync(Shift shift, ShiftDay shiftDay) { - //var shiftDay = await _shiftDaysRepository.GetShiftDayByIdAsync(shiftDayId); - var shiftGroups = new Dictionary>(); - - if (shiftDay != null) - { - if (shiftDay.Shift.AssignmentType == (int)ShiftAssignmentTypes.Assigned) - return null; - - //shiftDay.Shift.Groups = (await _shiftGroupsRepository.GetShiftGroupsByShiftIdAsync(shiftDay.ShiftId)).ToList(); - if (shiftDay.Shift.Groups == null || shiftDay.Shift.Groups.Count() <= 0) - return null; - - var shiftSignups = - (await _shiftSignupRepository.GetAllShiftSignupsByShiftIdAndDateAsync(shiftDay.ShiftId, - shiftDay.Day)).ToList(); - + shift = shift ?? shiftDay?.Shift; - foreach (var group in shiftDay.Shift.Groups) - { - var roleRequirements = new Dictionary(); - - if (group.Roles != null && group.Roles.Any()) - { - foreach (var role in group.Roles) - { - roleRequirements.Add(role.PersonnelRoleId, role.Required); - } - } - - if (shiftSignups != null && shiftSignups.Any()) - { - var groupSignups = shiftSignups.Where(x => x.DepartmentGroupId == group.DepartmentGroupId); + if (shift == null || shiftDay == null) + return null; - foreach (var signup in groupSignups) - { - var roles = await _personnelRolesService.GetRolesForUserAsync(signup.UserId, shiftDay.Shift.DepartmentId); - foreach (var personnelRole in roles) - { - if (roleRequirements.ContainsKey(personnelRole.PersonnelRoleId)) - roleRequirements[personnelRole.PersonnelRoleId]--; - } - } - } + if (shift.Groups == null || !shift.Groups.Any()) + return null; - if (shiftGroups.ContainsKey(group.DepartmentGroupId)) - shiftGroups[group.DepartmentGroupId] = roleRequirements; - else - shiftGroups.Add(group.DepartmentGroupId, roleRequirements); - } - } + var schedule = await BuildScheduleForDayAsync(shift, shiftDay); - return shiftGroups; + return schedule.Needs; } public async Task>> GetShiftDayNeedsAsync(int shiftDayId) { - var shiftDay = await _shiftDaysRepository.GetShiftDayByIdAsync(shiftDayId); - var shiftGroups = new Dictionary>(); + var schedule = await GetShiftDayScheduleAsync(shiftDayId); - if (shiftDay != null) - { - if (shiftDay.Shift.AssignmentType == (int)ShiftAssignmentTypes.Assigned) - return null; - - shiftDay.Shift.Groups = (await _shiftGroupsRepository.GetShiftGroupsByShiftIdAsync(shiftDay.ShiftId)).ToList(); - if (shiftDay.Shift.Groups == null || shiftDay.Shift.Groups.Count() <= 0) - return null; - - var shiftSignups = - (await _shiftSignupRepository.GetAllShiftSignupsByShiftIdAndDateAsync(shiftDay.ShiftId, - shiftDay.Day)).ToList(); - - - foreach (var group in shiftDay.Shift.Groups) - { - var roleRequirements = new Dictionary(); - - foreach (var role in group.Roles) - { - roleRequirements.Add(role.PersonnelRoleId, role.Required); - } - - if (shiftSignups != null && shiftSignups.Any()) - { - var groupSignups = shiftSignups.Where(x => x.DepartmentGroupId == group.DepartmentGroupId); - - foreach (var signup in groupSignups) - { - var roles = await _personnelRolesService.GetRolesForUserAsync(signup.UserId, shiftDay.Shift.DepartmentId); - foreach (var personnelRole in roles) - { - if (roleRequirements.ContainsKey(personnelRole.PersonnelRoleId)) - roleRequirements[personnelRole.PersonnelRoleId]--; - } - } - } - - shiftGroups.Add(group.DepartmentGroupId, roleRequirements); - } - } + if (schedule?.Shift?.Groups == null || !schedule.Shift.Groups.Any()) + return null; - return shiftGroups; + return schedule.Needs; } public async Task SignupForShiftDayAsync(int shiftId, DateTime shiftDay, int departmentGroupId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { + var shift = await _shiftsRepository.GetByIdAsync(shiftId); + var signup = new ShiftSignup(); signup.ShiftId = shiftId; signup.ShiftDay = shiftDay; signup.SignupTimestamp = DateTime.UtcNow; signup.UserId = userId; - signup.DepartmentGroupId = departmentGroupId; + // 0 is how callers say "no group"; storing it breaks the DepartmentGroups foreign key. + signup.DepartmentGroupId = departmentGroupId > 0 ? departmentGroupId : (int?)null; signup.Denied = false; + signup.ApprovalPending = shift?.RequireApproval == true; + + var saved = await _shiftSignupRepository.SaveOrUpdateAsync(signup, cancellationToken, true); - return await _shiftSignupRepository.SaveOrUpdateAsync(signup, cancellationToken); + if (saved != null && saved.ApprovalPending && shift != null) + await PublishRosterChangeAsync(shift.DepartmentId, ShiftQueueTypes.SignupPendingApproval, shift.ShiftId, saved.ShiftSignupId, 0, userId); + + return saved; } public async Task GetShiftSignupByIdAsync(int shiftSignupId) @@ -603,54 +572,38 @@ public async Task GetShiftSignupByIdAsync(int shiftSignupId) return signup; } + /// + /// Whether the user is on the day's resolved roster (pending approvals count, so a pending person is not offered + /// the signup again), optionally only in the given department group. + /// public async Task IsUserSignedUpForShiftDayAsync(ShiftDay shiftDay, string userId, int? departmentId) { - var signups = await GetShiftSignpsForShiftDayAsync(shiftDay.ShiftDayId); + if (shiftDay == null || String.IsNullOrWhiteSpace(userId)) + return false; - if (shiftDay.Shift.Personnel != null && shiftDay.Shift.Personnel.Any()) - { - if (shiftDay.Shift.Personnel.Any(x => x.UserId == userId)) - return true; - } + var shift = shiftDay.Shift?.Personnel != null ? shiftDay.Shift : await GetShiftByIdAsync(shiftDay.ShiftId); - if (signups == null || !signups.Any()) + if (shift == null) return false; - foreach (var shiftSignup in signups) - { - if (departmentId.HasValue) - { - if (shiftSignup.UserId == userId && shiftSignup.DepartmentGroupId == departmentId.Value) - return true; - } - else - { - if (shiftSignup.UserId == userId) - return true; - } - - if (shiftSignup.Trade != null && shiftSignup.Trade.TargetShiftSignup != null && - shiftSignup.Trade.TargetShiftSignup.UserId == userId) - return true; - } + var schedule = await BuildScheduleForDayAsync(shift, shiftDay); - return false; + return schedule.Roster.Any(x => String.Equals(x.UserId, userId, StringComparison.OrdinalIgnoreCase) && + (!departmentId.HasValue || x.DepartmentGroupId == departmentId.Value)); } public async Task> GetShiftSignpsForShiftDayAsync(int shiftDayId) { var shiftDay = await _shiftDaysRepository.GetShiftDayByIdAsync(shiftDayId); - var signups = (await _shiftSignupRepository.GetAllShiftSignupsByShiftIdAsync(shiftDay.ShiftId)).Where(x => x.ShiftDay.Year == shiftDay.Day.Year && - x.ShiftDay.Month == shiftDay.Day.Month && - x.ShiftDay.Day == shiftDay.Day.Day).ToList(); + if (shiftDay == null) + return new List(); - if (signups != null && signups.Any()) + var signups = ((await _shiftSignupRepository.GetAllShiftSignupsByShiftIdAndDateAsync(shiftDay.ShiftId, shiftDay.Day)) ?? Enumerable.Empty()).ToList(); + + foreach (var shiftSignup in signups) { - foreach (var shiftSignup in signups) - { - shiftSignup.Trade = await _shiftSignupTradeRepository.GetShiftSignupTradeBySourceShiftSignupIdAsync(shiftSignup.ShiftSignupId); - } + shiftSignup.Trade = await _shiftSignupTradeRepository.GetShiftSignupTradeBySourceShiftSignupIdAsync(shiftSignup.ShiftSignupId); } return signups; @@ -660,9 +613,12 @@ public async Task GetShiftDayForSignupAsync(int shiftSignupId) { var shiftSignup = await _shiftSignupRepository.GetByIdAsync(shiftSignupId); - var shiftDay = (await _shiftDaysRepository.GetAllShiftDaysByShiftIdAsync(shiftSignup.ShiftId)).FirstOrDefault(x => x.Day.Year == shiftSignup.ShiftDay.Year && - x.Day.Month == shiftSignup.ShiftDay.Month && - x.Day.Day == shiftSignup.ShiftDay.Day); + if (shiftSignup == null) + return null; + + var shiftDay = ((await _shiftDaysRepository.GetAllShiftDaysByShiftIdAsync(shiftSignup.ShiftId)) ?? Enumerable.Empty()) + .FirstOrDefault(x => x.Day.Date == shiftSignup.ShiftDay.Date); + return shiftDay; } @@ -678,8 +634,10 @@ public async Task> GetShiftSignupsForUserAsync(string userId) var unbalTrades = await _shiftSignupTradeRepository.GetTradeRequestsAndSourceShiftsByUserIdAsync(userId); + // Only trades that took effect put the user on someone else's day; a pick still waiting on a supervisor, or + // denied, does not. if (unbalTrades != null && unbalTrades.Any()) - signups.AddRange(unbalTrades.Select(x => x.SourceShiftSignup)); + signups.AddRange(unbalTrades.Where(x => x.IsTradeComplete() && x.SourceShiftSignup != null).Select(x => x.SourceShiftSignup)); //var trades = from trade in _shiftSignupTradeRepository.GetAll() // where trade.TargetShiftSignup != null && trade.TargetShiftSignup.UserId == userId @@ -701,7 +659,7 @@ public async Task> GetShiftSignupsForUserAsync(string userId) if (signups != null && signups.Any()) { - foreach (var signup in signups) + foreach (var signup in signups.Where(x => x != null)) { signup.Shift = await GetShiftByIdAsync(signup.ShiftId); @@ -714,11 +672,18 @@ await _shiftSignupTradeRepository.GetShiftSignupTradeBySourceShiftSignupIdAsync( } } - return signups.ToList(); + return signups.Where(x => x != null && !x.Denied).ToList(); } public async Task DeleteShiftSignupAsync(ShiftSignup signup, CancellationToken cancellationToken = default(CancellationToken)) { + if (signup == null) + return false; + + // Trades started on this signup cascade away with it; one that uses it as the swap-back day, or an offer that + // put it up, would otherwise block the delete on a non-cascading foreign key. + await ReleaseTradeReferencesToSignupAsync(signup.ShiftSignupId, cancellationToken); + return await _shiftSignupRepository.DeleteAsync(signup, cancellationToken); } @@ -730,7 +695,17 @@ await _shiftSignupTradeRepository.GetShiftSignupTradeBySourceShiftSignupIdAsync( public async Task> GetOpenTradeRequestsForUserAsync(string userId) { var trades = await _shiftSignupTradeRepository.GetAllOpenTradeRequestsByUserIdAsync(userId); - return trades.ToList(); + var result = new List(); + + foreach (var tradeId in (trades ?? Enumerable.Empty()).Select(x => x.ShiftSignupTradeId).Distinct()) + { + var trade = await GetShiftTradeByIdAsync(tradeId); + + if (trade != null) + result.Add(trade); + } + + return result; } public async Task GetShiftTradeByIdAsync(int shiftTradeId) @@ -742,7 +717,22 @@ public async Task GetShiftTradeByIdAsync(int shiftTradeId) if (trade == null) return null; - trade.Users = new List(await _shiftSignupTradeUserRepository.GetShiftSignupTradeUsersByTradeIdAsync(shiftTradeId)); + trade.Users = new List((await _shiftSignupTradeUserRepository.GetShiftSignupTradeUsersByTradeIdAsync(shiftTradeId)) ?? Enumerable.Empty()); + + // The notification worker, trade pages and API all read the two signups and their shift; the base + // GetByIdAsync populates no navigation properties. + trade.SourceShiftSignup = await _shiftSignupRepository.GetByIdAsync(trade.SourceShiftSignupId); + + if (trade.SourceShiftSignup != null) + trade.SourceShiftSignup.Shift = await _shiftsRepository.GetByIdAsync(trade.SourceShiftSignup.ShiftId); + + if (trade.TargetShiftSignupId.HasValue) + { + trade.TargetShiftSignup = await _shiftSignupRepository.GetByIdAsync(trade.TargetShiftSignupId.Value); + + if (trade.TargetShiftSignup != null) + trade.TargetShiftSignup.Shift = await _shiftsRepository.GetByIdAsync(trade.TargetShiftSignup.ShiftId); + } return trade; } @@ -802,5 +792,31 @@ public async Task> GetShiftPersonsForUserAsync(string userId) return new List(); } + + public async Task> GetOnShiftPersonnelAsync(int departmentId, DateTime utcNow) + { + var onShift = new List(); + + // Built on the resolved roster, so denied and pending signups, single-day removals and completed trades + // are all applied. One entry per person per running shift. + foreach (var schedule in await GetActiveShiftDaySchedulesAsync(departmentId, utcNow)) + { + foreach (var entry in schedule.Roster.Where(x => x.IsOnDuty())) + { + if (onShift.Any(x => x.ShiftId == schedule.Shift.ShiftId && String.Equals(x.UserId, entry.UserId, StringComparison.OrdinalIgnoreCase))) + continue; + + onShift.Add(new OnShiftAssignment + { + UserId = entry.UserId, + ShiftId = schedule.Shift.ShiftId, + ShiftName = schedule.Shift.Name, + DepartmentGroupId = entry.DepartmentGroupId + }); + } + } + + return onShift; + } } } diff --git a/Core/Resgrid.Services/UdfRenderingService.cs b/Core/Resgrid.Services/UdfRenderingService.cs index 4e54cbbbb..3af321af6 100644 --- a/Core/Resgrid.Services/UdfRenderingService.cs +++ b/Core/Resgrid.Services/UdfRenderingService.cs @@ -223,6 +223,7 @@ private static string RenderFormField(UdfField field, string currentValue) case UdfFieldDataType.Dropdown: sb.AppendLine($" "); + AppendRedactedOption(sb, currentValue); var msRules = ParseRules(field.ValidationRules); var selectedKeys = (currentValue ?? string.Empty).Split(',').Select(v => v.Trim()).ToHashSet(); if (msRules?.Options != null) @@ -246,6 +248,21 @@ private static string RenderFormField(UdfField field, string currentValue) sb.AppendLine($" "); break; + case UdfFieldDataType.ComboBox: + // The input shows and posts the option's label (the save maps it back to the key); + // data-key lets the reveal module turn a revealed key into the label it displays. + var comboRules = ParseRules(field.ValidationRules); + var listId = $"{fieldId}_options"; + var comboText = UdfValidationHelper.FindComboOption(comboRules, currentValue) is { } shown && + shown.Key == currentValue ? shown.Label : currentValue; + sb.AppendLine($" "); + sb.AppendLine($" "); + if (comboRules?.Options != null) + foreach (var opt in comboRules.Options) + sb.AppendLine($" "); + sb.AppendLine($" "); + break; + case UdfFieldDataType.Date: sb.AppendLine($" "); break; @@ -289,13 +306,44 @@ private static string RenderFormField(UdfField field, string currentValue) return sb.ToString(); } + /// + /// A protected value the viewer has not revealed matches none of a select's options, so without + /// this the select falls back to its blank entry and the save posts "" (single) or nothing + /// (multi), deleting the real value. A selected sentinel option posts REDACTED instead, which + /// the save restores to the stored value; the reveal module selects the real key over it. + /// + public string FormatDisplayValue(UdfField field, string value) + { + if (field == null) + return ProtectedDataEnvelope.SafeDisplay(value) ?? string.Empty; + + return GetDisplayValue(field, ProtectedDataEnvelope.SafeDisplay(value)); + } + + private static void AppendRedactedOption(StringBuilder sb, string currentValue) + { + if (currentValue == ProtectedDataEnvelope.RedactionValue) + sb.AppendLine($" "); + } + private static string GetDisplayValue(UdfField field, string rawValue) { if (string.IsNullOrWhiteSpace(rawValue)) return string.Empty; + // Matches no option, so the label lookup below would render it blank. + if (rawValue == ProtectedDataEnvelope.RedactionValue) + return rawValue; + var dataType = (UdfFieldDataType)field.FieldDataType; + // A stored key shows its label; free text shows as typed. + if (dataType == UdfFieldDataType.ComboBox) + { + var option = UdfValidationHelper.FindComboOption(ParseRules(field.ValidationRules), rawValue); + return option != null && option.Key == rawValue ? option.Label : rawValue; + } + if (dataType == UdfFieldDataType.Boolean) return rawValue == "true" || rawValue == "1" || rawValue.ToLower() == "yes" ? "Yes" : "No"; @@ -304,7 +352,11 @@ private static string GetDisplayValue(UdfField field, string rawValue) var rules = ParseRules(field.ValidationRules); if (rules?.Options != null) { - var keys = rawValue.Split(',').Select(v => v.Trim()).ToHashSet(); + // Only a multi-select stores a comma-joined list; a single-select key may itself + // contain a comma ("Transported, ALS") and must be matched whole. + var keys = dataType == UdfFieldDataType.MultiSelect + ? rawValue.Split(',').Select(v => v.Trim()).ToHashSet() + : new HashSet { rawValue }; var labels = rules.Options.Where(o => keys.Contains(o.Key)).Select(o => o.Label); return string.Join(", ", labels); } diff --git a/Core/Resgrid.Services/UserDefinedFieldsService.cs b/Core/Resgrid.Services/UserDefinedFieldsService.cs index 1265e1b38..15449d8df 100644 --- a/Core/Resgrid.Services/UserDefinedFieldsService.cs +++ b/Core/Resgrid.Services/UserDefinedFieldsService.cs @@ -18,13 +18,16 @@ public class UserDefinedFieldsService : IUserDefinedFieldsService private readonly IUdfFieldRepository _fieldRepository; private readonly IUdfFieldValueRepository _valueRepository; private readonly IUnitOfWork _unitOfWork; + private readonly IProtectedWorkflowService _protectedWorkflows; public UserDefinedFieldsService( IUdfDefinitionRepository definitionRepository, IUdfFieldRepository fieldRepository, IUdfFieldValueRepository valueRepository, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IProtectedWorkflowService protectedWorkflows = null) { + _protectedWorkflows = protectedWorkflows; _definitionRepository = definitionRepository; _fieldRepository = fieldRepository; _valueRepository = valueRepository; @@ -80,6 +83,10 @@ public async Task SaveDefinitionAsync(int departmentId, int entit var nameErrors = Resgrid.Model.Helpers.UdfValidationHelper.ValidateFieldNamesUnique(fields); if (nameErrors.Count > 0) throw new InvalidOperationException(string.Join(" | ", nameErrors)); + + var optionErrors = Resgrid.Model.Helpers.UdfValidationHelper.ValidateFieldOptions(fields); + if (optionErrors.Count > 0) + throw new InvalidOperationException(string.Join(" | ", optionErrors)); } // Open a shared connection/transaction so the read, deactivation, and all inserts @@ -132,6 +139,14 @@ public async Task SaveDefinitionAsync(int departmentId, int entit throw; } + // A call custom field's Protected Workflows sensitivity tag (or the field itself) may have changed: every release + // that allow-lists a custom field is re-fingerprinted and goes back for approval when it moved. + if (entityType == (int)UdfEntityType.Call && _protectedWorkflows != null) + { + try { await _protectedWorkflows.OnCallCustomFieldsChangedAsync(departmentId, userId, cancellationToken); } + catch (Exception ex) { Logging.LogError($"Protected workflow custom field hook failed for department {departmentId}: {ex.GetType().FullName}."); } + } + return definition; } @@ -210,6 +225,15 @@ public async Task>> SaveFieldValuesForEntityAsyn .Select(g => g.Last()) .ToList(); + // A combo box entry naming an option is stored as the option's key, the way a dropdown + // stores its selection; free text is stored as typed. + var fieldsById = fields + .Where(f => !string.IsNullOrEmpty(f.UdfFieldId)) + .ToDictionary(f => f.UdfFieldId, StringComparer.Ordinal); + + foreach (var value in normalizedValues) + value.Value = UdfValidationHelper.NormalizeFieldValue(fieldsById[value.UdfFieldId], value.Value); + // REDACTED-sentinel restoration (ADP plan 5.2). udffieldvalues.value is cataloged, and // the edit surfaces render it through SafeDisplay — so an editor without a grant sees // the placeholder and posts it straight back. This save is delete-then-reinsert, so @@ -318,7 +342,9 @@ public async Task DeleteFieldFromDefinitionAsync(string fieldId, IsVisibleOnMobile = f.IsVisibleOnMobile, IsVisibleOnReports = f.IsVisibleOnReports, IsEnabled = f.IsEnabled, - Visibility = f.Visibility + Visibility = f.Visibility, + RmsClassification = f.RmsClassification, + Sensitivity = f.Sensitivity }).ToList(); return await SaveDefinitionAsync(owningDefinition.DepartmentId, owningDefinition.EntityType, diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index e1118a030..b59371ee8 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -15,6 +15,13 @@ public static class WorkflowSampleDataGenerator public static object GenerateSampleData(WorkflowTriggerEventType eventType) { var obj = new ScriptObject(); + WorkflowTemplateFunctions.AddTo(obj); + obj["run"] = new ScriptObject + { + ["id"] = "00000000-0000-0000-0000-000000000000", + ["attempt"] = 1, + ["idempotency_key"] = "0123456789abcdef0123456789abcdef" + }; AddSampleDepartment(obj); AddSampleTimestamp(obj); AddSampleUser(obj); @@ -22,6 +29,29 @@ public static object GenerateSampleData(WorkflowTriggerEventType eventType) return obj; } + /// + /// Synthetic subject identifiers for a protected test send: every requested key (or the usual EHR keys when the + /// whole field is released) with an obviously fake value. Never real data. + /// + public static ScriptObject SampleSubjectIdentifiers(IEnumerable keys) + { + var ids = new ScriptObject(); + foreach (var key in keys ?? new[] { "ehr_client_id", "ehr_encounter_id" }) + { + ids[key] = key switch + { + "ehr_client_id" => "SAMPLE-CLIENT-000123", + "ehr_encounter_id" => "SAMPLE-ENCOUNTER-000456", + "dynamics_case_id" => "SAMPLE-CAS-0042", + _ => "SAMPLE-" + key.ToUpperInvariant() + }; + } + return ids; + } + + /// A synthetic value for a released call custom field in a protected test send. + public static string SampleCustomFieldValue(string fieldName) => $"SAMPLE {fieldName} (synthetic test data)"; + private static void AddSampleDepartment(ScriptObject obj) { var d = new ScriptObject(); @@ -194,6 +224,7 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve c["dispatch_count"] = 3; c["dispatch_on"] = DateTime.Now.AddMinutes(-8); c["form_data"] = "{}"; + c["part2_consent_on_file"] = false; c["is_deleted"] = false; c["deleted_reason"] = ""; obj["call"] = c; diff --git a/Core/Resgrid.Services/WorkflowService.ProtectedRelease.cs b/Core/Resgrid.Services/WorkflowService.ProtectedRelease.cs new file mode 100644 index 000000000..e60a4a5c7 --- /dev/null +++ b/Core/Resgrid.Services/WorkflowService.ProtectedRelease.cs @@ -0,0 +1,631 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Scriban; +using Scriban.Runtime; + +namespace Resgrid.Services +{ + /// + /// Protected Workflows (ADP push model) inside workflow execution. A step of a workflow with an Active release runs + /// ONLY through : + /// 1. the condition is evaluated against the ordinary (REDACTED) context — never against plaintext; + /// 2. the action config (URL, headers) renders against the same ordinary context — plaintext never reaches a URL; + /// 3. every precondition is re-read fresh (ADP state, toggle, release Active and unexpired, fingerprint, action, + /// credential, rendered https host); + /// 4. ONLY the allow-listed fields are decrypted, through the broker's workload lane with a fresh request id; + /// 5. the output renders with the released values under protected.* and is scrubbed for envelopes; + /// 6. the payload is normalized and validated for its declared content type (JSON, FHIR, XML, HL7 v2); + /// 7. the executor sends in protected mode (no redirects, TLS 1.2+, pinned host re-checked, status line only; the + /// body is read, capped, only for a success rule or a capture); + /// 8. captured response values are written into the call's subject identifiers through the encrypt lane; + /// 9. one value-free disclosure is chained per attempt (after an "attempted" record written before sending). + /// Plaintext exists only in memory during 4-6. Nothing here persists, queues or logs it: the run log gets a hash, + /// a byte count and field ids; errors get a fixed code and an exception type name. + /// + public partial class WorkflowService + { + private sealed class ProtectedStepOutcome + { + public WorkflowRunLog Log { get; init; } + public bool Failed { get; init; } + public string ErrorCode { get; init; } + + /// Whether the workflow retry policy may repeat this attempt (see ProtectedWorkflowRetryPolicy). + public bool Retryable { get; init; } + } + + private sealed class CredentialChange + { + public bool TypeChanged { get; init; } + public bool SecretChanged { get; init; } + public string PreviousTokenHost { get; init; } + public string CurrentTokenHost { get; init; } + public string PreviousAuthMethod { get; init; } + public string CurrentAuthMethod { get; init; } + + /// Set when this save replaced a private_key_jwt signing key (a rotation). + public string RotatedKeyId { get; set; } + } + + private async Task ExecuteProtectedStepAsync(Workflow workflow, WorkflowRun run, WorkflowStep step, + ScriptObject baseContext, string eventPayloadJson, int departmentId, string departmentCode, bool isFreePlan, + string idempotencyKey, CancellationToken cancellationToken) + { + var log = new WorkflowRunLog + { + WorkflowRunLogId = Guid.NewGuid().ToString(), + WorkflowRunId = run.WorkflowRunId, + WorkflowStepId = step.WorkflowStepId, + Status = (int)WorkflowRunStatus.Running, + StartedOn = DateTime.UtcNow + }; + + var disclosure = new ProtectedWorkflowDisclosure + { + DepartmentId = departmentId, + WorkflowId = workflow.WorkflowId, + WorkflowRunId = run.WorkflowRunId, + WorkflowStepId = step.WorkflowStepId, + EntityType = ProtectedWorkflowFieldCatalog.EntityTypeFor(workflow.TriggerEventType) + }; + + var sw = Stopwatch.StartNew(); + var recordDisclosure = false; + string errorCode = null; + ProtectedReleasedValues released = null; + string rendered = null; + + try + { + // 1. Condition: the ordinary context only (a fresh copy, so no step can leave values behind for another). + if (!string.IsNullOrWhiteSpace(step.ConditionExpression)) + { + var condition = Template.Parse(step.ConditionExpression); + string conditionResult = null; + var conditionError = condition.HasErrors; + if (!conditionError) + { + try { conditionResult = (await condition.RenderAsync(NewContext(baseContext, null)))?.Trim() ?? string.Empty; } + catch (Exception) { conditionError = true; } + } + + if (conditionError || string.IsNullOrWhiteSpace(conditionResult) || string.Equals(conditionResult, "false", StringComparison.OrdinalIgnoreCase)) + { + log.Status = (int)WorkflowRunStatus.Skipped; + log.ErrorMessage = conditionError ? "Step skipped: condition_error" : "Step skipped: condition evaluated to false."; + return new ProtectedStepOutcome { Log = log, Failed = false }; + } + } + + // 2. Action config (URL, headers, If-None-Exist): the ordinary context only. A render failure fails the step — + // falling back to the raw template would send to a URL nobody approved in that form. + var renderedActionConfig = step.ActionConfig; + if (!string.IsNullOrWhiteSpace(step.ActionConfig)) + { + var configTemplate = Template.Parse(step.ActionConfig); + if (configTemplate.HasErrors) + return Fail(ProtectedWorkflowDisclosureOutcomes.FailedRender, ProtectedWorkflowErrorCodes.RenderFailed); + try { renderedActionConfig = await configTemplate.RenderAsync(NewContext(baseContext, null)); } + catch (Exception) { return Fail(ProtectedWorkflowDisclosureOutcomes.FailedRender, ProtectedWorkflowErrorCodes.RenderFailed); } + } + + // 3. Preconditions, re-read fresh for this attempt. + var authorization = await _protectedRuntime.AuthorizeStepAsync(workflow, step, renderedActionConfig, cancellationToken); + disclosure.WorkflowProtectedReleaseId = authorization.Release?.WorkflowProtectedReleaseId; + disclosure.DestinationHost = authorization.Release?.DestinationHost; + if (!authorization.Allowed) + return Fail(authorization.Outcome, authorization.ErrorCode); + + var release = authorization.Release; + var credential = await _credentialRepository.GetByIdAsync(step.WorkflowCredentialId); + if (credential == null) + return Fail(ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.CredentialNotAllowed); + + // The step options as rendered: declared content type, success rule, capture, idempotency. + var options = ProtectedStepOptions.Read(renderedActionConfig, out var optionErrors, DataProtectionConfig.ProtectedWorkflowMaxCaptureKeys); + disclosure.ContentType = options.MediaType; + if (optionErrors.Count > 0) + return Fail(ProtectedWorkflowDisclosureOutcomes.FailedValidation, $"{ProtectedWorkflowErrorCodes.PayloadInvalid}: rule={optionErrors[0]}"); + + // 4. Decrypt ONLY the allow-listed fields of the triggering entity (a Part 2 field is refused first without consent). + released = await _protectedRuntime.ResolveReleasedValuesAsync(workflow, release, eventPayloadJson, cancellationToken); + disclosure.BrokerRequestId = released.BrokerRequestId; + disclosure.EntityId = released.EntityId; + disclosure.EntityType = released.EntityType ?? disclosure.EntityType; + if (!released.Success) + return Fail(released.Outcome ?? ProtectedWorkflowDisclosureOutcomes.FailedBroker, released.ErrorCode ?? ProtectedWorkflowErrorCodes.BrokerFailed); + disclosure.FieldIds = JsonConvert.SerializeObject(released.FieldIds); + + // 5. Render with protected.* layered over a copy of the ordinary context, then scrub for envelopes. + var output = Template.Parse(step.OutputTemplate ?? string.Empty); + if (output.HasErrors) + return Fail(ProtectedWorkflowDisclosureOutcomes.FailedRender, ProtectedWorkflowErrorCodes.RenderFailed); + try { rendered = await output.RenderAsync(NewContext(baseContext, (ScriptObject)released.Namespace)); } + catch (Exception) { return Fail(ProtectedWorkflowDisclosureOutcomes.FailedRender, ProtectedWorkflowErrorCodes.RenderFailed); } + + // Never truncate a protected payload: a partial record is worse than none. + if (rendered != null && rendered.Length > WorkflowConfig.MaxRenderedContentLength) + return Fail(ProtectedWorkflowDisclosureOutcomes.FailedRender, ProtectedWorkflowErrorCodes.PayloadTooLarge); + + rendered = ProtectedPayloadValidator.NormalizeBody(options.MediaType, rendered); + ProtectedOutboundGuard.Scrub(rendered, out var envelopes); + if (envelopes > 0) + return Fail(ProtectedWorkflowDisclosureOutcomes.BlockedGuard, ProtectedWorkflowErrorCodes.EnvelopeInPayload); + + // The payload must be valid for what it claims to be before it leaves. The detail is the rule and position only. + var check = ProtectedPayloadValidator.Validate(options.MediaType, rendered); + if (!check.Ok) + return Fail(ProtectedWorkflowDisclosureOutcomes.FailedValidation, check.Describe()); + + // 6. Chain the attempt BEFORE anything leaves: if the audit record cannot be written, nothing is sent. + var bodyBytes = Encoding.UTF8.GetBytes(rendered ?? string.Empty); + disclosure.PayloadSha256 = ProtectedWorkflowDisclosureChain.Sha256Hex(bodyBytes); + disclosure.PayloadBytes = bodyBytes.Length; + if (!await RecordAttemptAsync(disclosure)) + return Fail(null, ProtectedWorkflowErrorCodes.DisclosureUnavailable, record: false); + + // 7. Send in protected mode. + var decryptedCredJson = _encryptionService.DecryptForDepartment(credential.EncryptedData, departmentId, departmentCode); + var result = await _executorFactory.GetExecutor((WorkflowActionType)step.ActionType).ExecuteAsync(new WorkflowActionContext + { + RenderedContent = rendered, + DecryptedCredentialJson = decryptedCredJson, + ActionConfigJson = renderedActionConfig, + WorkflowId = workflow.WorkflowId, + WorkflowStepId = step.WorkflowStepId, + WorkflowRunId = run.WorkflowRunId, + DepartmentId = departmentId, + ActionType = step.ActionType, + IsFreePlanDepartment = isFreePlan, + CredentialType = credential.CredentialType, + ProtectedMode = true, + PinnedHost = release.DestinationHost, + PinnedTokenHost = release.TokenHost, + PinnedAuthMethod = release.AuthMethod, + IdempotencyKey = idempotencyKey + }, cancellationToken); + + recordDisclosure = true; + disclosure.HttpStatus = result.HttpStatus; + disclosure.PayloadSha256 = result.PayloadSha256 ?? disclosure.PayloadSha256; + disclosure.PayloadBytes = result.PayloadBytes ?? disclosure.PayloadBytes; + log.RenderedOutput = ProtectedWorkflowLogText.RenderedOutputSummary( + result.PayloadSha256 ?? ProtectedWorkflowDisclosureChain.Sha256Hex(Encoding.UTF8.GetBytes(rendered ?? string.Empty)), + result.PayloadBytes ?? Encoding.UTF8.GetByteCount(rendered ?? string.Empty), + released.FieldIds); + log.ActionResult = Cap(result.ResultMessage); + + if (result.Success) + { + disclosure.Outcome = ProtectedWorkflowDisclosureOutcomes.Sent; + + // 8. Captured response values go into the call's subject identifiers through the encrypt lane. Only the + // KEYS are ever written anywhere else: captured=[ehr_encounter_id]. + if (result.CapturedValues != null && result.CapturedValues.Count > 0) + { + var write = await _protectedRuntime.WriteCapturedValuesAsync(workflow, release, released.EntityId, result.CapturedValues, cancellationToken); + if (!write.Success) + { + // The record was delivered; sending it again would duplicate it. Stop and alert instead of retrying. + errorCode = write.ErrorCode ?? ProtectedWorkflowErrorCodes.CaptureFailed; + disclosure.Detail = errorCode; + log.Status = (int)WorkflowRunStatus.Failed; + log.ErrorMessage = errorCode; + return new ProtectedStepOutcome { Log = log, Failed = true, ErrorCode = errorCode, Retryable = false }; + } + + disclosure.CapturedKeys = JsonConvert.SerializeObject(write.WrittenKeys); + log.ActionResult = Cap($"{result.ResultMessage} captured=[{string.Join(",", write.WrittenKeys)}]"); + } + + log.Status = (int)WorkflowRunStatus.Completed; + return new ProtectedStepOutcome { Log = log, Failed = false }; + } + + disclosure.Outcome = result.ProtectedOutcome ?? ProtectedWorkflowDisclosureOutcomes.FailedHttp; + errorCode = disclosure.Outcome switch + { + ProtectedWorkflowDisclosureOutcomes.BlockedHost => ProtectedWorkflowErrorCodes.HostMismatch, + ProtectedWorkflowDisclosureOutcomes.BlockedRelease => result.ErrorDetail ?? ProtectedWorkflowErrorCodes.CredentialNotAllowed, + ProtectedWorkflowDisclosureOutcomes.FailedAck => ProtectedWorkflowErrorCodes.AckRejected, + ProtectedWorkflowDisclosureOutcomes.FailedResponseTooLarge => ProtectedWorkflowErrorCodes.ResponseTooLarge, + ProtectedWorkflowDisclosureOutcomes.FailedValidation => ProtectedWorkflowErrorCodes.PayloadInvalid, + _ => ProtectedWorkflowErrorCodes.HttpFailed + }; + disclosure.Detail = Cap(result.ErrorDetail, 500); + log.Status = (int)WorkflowRunStatus.Failed; + log.ErrorMessage = Cap(result.ErrorDetail) ?? errorCode; + return new ProtectedStepOutcome + { + Log = log, + Failed = true, + ErrorCode = errorCode, + Retryable = ProtectedWorkflowRetryPolicy.IsRetryable(disclosure.Outcome, result.HttpStatus, errorCode) + }; + } + catch (Exception ex) + { + // Fixed code + exception type. The message is never logged or stored: it could quote rendered content. + errorCode = ProtectedWorkflowErrorCodes.StepError; + log.Status = (int)WorkflowRunStatus.Failed; + log.ErrorMessage = ProtectedWorkflowLogText.Error(errorCode, ex); + if (recordDisclosure || released != null) + { + recordDisclosure = true; + disclosure.Outcome = disclosure.Outcome ?? (rendered != null ? ProtectedWorkflowDisclosureOutcomes.FailedHttp : ProtectedWorkflowDisclosureOutcomes.FailedRender); + disclosure.Detail = errorCode; + } + Logging.LogError($"Protected workflow step {step.WorkflowStepId} failed for run {run.WorkflowRunId}: {ex.GetType().FullName}."); + // An exception after the request left must not resend it; before that, another attempt is harmless. + return new ProtectedStepOutcome { Log = log, Failed = true, ErrorCode = errorCode, Retryable = disclosure.Outcome != ProtectedWorkflowDisclosureOutcomes.Sent }; + } + finally + { + sw.Stop(); + log.DurationMs = sw.ElapsedMilliseconds; + log.CompletedOn = DateTime.UtcNow; + + // Drop the only references to plaintext before anything slower happens. + rendered = null; + released = null; + + if (recordDisclosure) + await RecordProtectedDisclosureAsync(disclosure, log); + } + + // Every refused or failed attempt past the condition is chained too, with its blocked_*/failed_* outcome. + ProtectedStepOutcome Fail(string outcome, string code, bool record = true) + { + errorCode = code; + recordDisclosure = record; + disclosure.Outcome = outcome; + disclosure.Detail = code; + log.Status = (int)WorkflowRunStatus.Failed; + log.ErrorMessage = code; + return new ProtectedStepOutcome { Log = log, Failed = true, ErrorCode = code, Retryable = ProtectedWorkflowRetryPolicy.IsRetryable(outcome, null, code) }; + } + } + + /// The pre-send record. False (and nothing is sent) when the chain cannot be written. + private async Task RecordAttemptAsync(ProtectedWorkflowDisclosure disclosure) + { + if (_protectedWorkflows == null) + return false; + + var attempt = new ProtectedWorkflowDisclosure + { + DepartmentId = disclosure.DepartmentId, + WorkflowId = disclosure.WorkflowId, + WorkflowRunId = disclosure.WorkflowRunId, + WorkflowStepId = disclosure.WorkflowStepId, + WorkflowProtectedReleaseId = disclosure.WorkflowProtectedReleaseId, + EntityType = disclosure.EntityType, + EntityId = disclosure.EntityId, + FieldIds = disclosure.FieldIds, + DestinationHost = disclosure.DestinationHost, + PayloadSha256 = disclosure.PayloadSha256, + PayloadBytes = disclosure.PayloadBytes, + BrokerRequestId = disclosure.BrokerRequestId, + IsTest = disclosure.IsTest, + ContentType = disclosure.ContentType, + Outcome = ProtectedWorkflowDisclosureOutcomes.Attempted + }; + + try + { + await _protectedWorkflows.RecordDisclosureAsync(attempt, CancellationToken.None); + return true; + } + catch (Exception ex) + { + Logging.LogError($"Protected workflow attempt could not be recorded for run {disclosure.WorkflowRunId}, step {disclosure.WorkflowStepId}; not sending: {ex.GetType().FullName}."); + return false; + } + } + + /// + /// Writes the disclosure. A failure here never fails the step (the request already left; failing would retry and + /// send again) — it is logged value-free and flagged on the run log instead. + /// + private async Task RecordProtectedDisclosureAsync(ProtectedWorkflowDisclosure disclosure, WorkflowRunLog log) + { + if (_protectedWorkflows == null) + return; + + try + { + await _protectedWorkflows.RecordDisclosureAsync(disclosure, CancellationToken.None); + } + catch (Exception ex) + { + Logging.LogError($"Protected workflow disclosure could not be recorded for run {disclosure.WorkflowRunId}, step {disclosure.WorkflowStepId}: {ex.GetType().FullName}."); + log.ActionResult = Cap((log.ActionResult ?? string.Empty) + " [disclosure_record_failed]"); + } + } + + /// A fresh, isolated render context: a deep copy of the ordinary context, optional released values, and a scratch frame for assignments. + private static TemplateContext NewContext(ScriptObject baseContext, ScriptObject protectedNamespace) + { + var context = new TemplateContext + { + LoopLimit = WorkflowConfig.ScribanLoopLimit, + StrictVariables = false, + // A field that is not released (or protected.* in a condition or URL) reads as empty, never as an error. + EnableRelaxedTargetAccess = true + }; + context.PushGlobal((ScriptObject)(baseContext ?? new ScriptObject()).Clone(true)); + if (protectedNamespace != null) + context.PushGlobal(protectedNamespace); + context.PushGlobal(new ScriptObject()); + return context; + } + + private static string Cap(string value, int max = 4000) => + value == null ? null : value.Length > max ? value.Substring(0, max) : value; + + // ── Test send ───────────────────────────────────────────────────────────────────────────────── + + public async Task SendProtectedTestAsync(int departmentId, string departmentCode, string workflowId, + CancellationToken cancellationToken = default) + { + if (_protectedRuntime == null || _protectedWorkflows == null) + return new ProtectedWorkflowTestResult { ErrorCode = ProtectedWorkflowErrorCodes.InvalidState }; + + var workflow = await _workflowRepository.GetByIdAsync(workflowId); + if (workflow == null || workflow.DepartmentId != departmentId) + return new ProtectedWorkflowTestResult { ErrorCode = ProtectedWorkflowErrorCodes.NotFound }; + + var settings = await _protectedWorkflows.GetDepartmentSettingsAsync(departmentId, bypassCache: true); + if (!settings.AdpActive) + return new ProtectedWorkflowTestResult { ErrorCode = ProtectedWorkflowErrorCodes.AdpNotEnabled }; + if (!settings.Enabled) + return new ProtectedWorkflowTestResult { ErrorCode = ProtectedWorkflowErrorCodes.DepartmentDisabled }; + + var release = await _protectedWorkflows.GetCurrentReleaseAsync(workflowId); + if (release == null || release.ReleaseState == ProtectedReleaseState.Revoked) + return new ProtectedWorkflowTestResult { ErrorCode = ProtectedWorkflowErrorCodes.InvalidState }; + + var steps = await GetStepsByWorkflowIdAsync(workflowId, cancellationToken); + var credentials = (await _credentialRepository.GetAllByDepartmentIdAsync(departmentId))?.ToList() ?? new List(); + var validation = ProtectedWorkflowValidator.Validate(workflow.TriggerEventType, steps, + credentials.Where(c => !string.IsNullOrWhiteSpace(c.WorkflowCredentialId)) + .GroupBy(c => c.WorkflowCredentialId.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().CredentialType, StringComparer.OrdinalIgnoreCase), + DataProtectionConfig.ProtectedWorkflowAllowHttpBasicCredentials, DataProtectionConfig.ProtectedWorkflowMaxCaptureKeys); + var pins = validation.CredentialType == (int)WorkflowCredentialType.OAuth2ClientCredentials + ? await _protectedWorkflows.GetCredentialPinsAsync(departmentId, validation.WorkflowCredentialId) ?? new ProtectedCredentialPins() + : null; + var tokenHost = pins?.TokenHost; + if (pins != null && tokenHost == null) + validation.Add(ProtectedWorkflowValidator.TokenUrlInvalid); + if (pins?.AuthMethod == WorkflowJwtKeys.PrivateKeyJwt && !pins.HasSigningKey) + validation.Add(ProtectedWorkflowValidator.SigningKeyMissing); + if (!validation.IsValid) + return new ProtectedWorkflowTestResult { ErrorCode = ProtectedWorkflowErrorCodes.ValidationFailed, ValidationErrors = validation.Errors }; + + // Once a release has pinned its hosts, a test may only go to those hosts (a draft has none pinned yet). + if (!string.IsNullOrEmpty(release.DestinationHost) && + (!string.Equals(validation.DestinationHost, release.DestinationHost, StringComparison.Ordinal) || + !string.Equals(tokenHost, release.TokenHost, StringComparison.Ordinal) || + !string.Equals(pins?.AuthMethod, release.AuthMethod, StringComparison.Ordinal))) + return new ProtectedWorkflowTestResult { ErrorCode = ProtectedWorkflowErrorCodes.HostMismatch }; + + // Synthetic values only: the sample generator for call.*, and sample strings for the allow-listed protected.*. + var sampleContext = WorkflowSampleDataGenerator.GenerateSampleData((WorkflowTriggerEventType)workflow.TriggerEventType) as ScriptObject ?? new ScriptObject(); + var sample = _protectedRuntime.BuildSampleValues(workflow.TriggerEventType, release.GetAllowedFieldIds()); + var stepResults = new List(); + var testRunId = "test-" + Guid.NewGuid().ToString("N"); + + foreach (var step in ProtectedWorkflowFingerprint.OrderedEnabledSteps(steps)) + { + // A test has its own idempotency key, so an EHR that deduplicates never mistakes it for a real delivery. + var idempotencyKey = WorkflowIdempotency.Key(workflowId, null, testRunId, step.WorkflowStepId); + sampleContext["run"] = new ScriptObject { ["id"] = testRunId, ["attempt"] = 1, ["idempotency_key"] = idempotencyKey }; + + var disclosure = new ProtectedWorkflowDisclosure + { + DepartmentId = departmentId, + WorkflowId = workflowId, + WorkflowStepId = step.WorkflowStepId, + WorkflowProtectedReleaseId = release.WorkflowProtectedReleaseId, + EntityType = sample.EntityType, + EntityId = sample.EntityId, + FieldIds = JsonConvert.SerializeObject(sample.FieldIds), + DestinationHost = validation.DestinationHost, + IsTest = true + }; + + string code = null; + try + { + var renderedActionConfig = await Template.Parse(step.ActionConfig ?? string.Empty).RenderAsync(NewContext(sampleContext, null)); + var options = ProtectedStepOptions.Read(renderedActionConfig, out var optionErrors, DataProtectionConfig.ProtectedWorkflowMaxCaptureKeys); + disclosure.ContentType = options.MediaType; + if (!ProtectedWorkflowValidator.TryGetRenderedHttpsHost(ProtectedWorkflowValidator.ReadUrl(renderedActionConfig), out var host, out _) || + !string.Equals(host, validation.DestinationHost, StringComparison.Ordinal)) + { + disclosure.Outcome = ProtectedWorkflowDisclosureOutcomes.BlockedHost; + code = ProtectedWorkflowErrorCodes.HostMismatch; + } + else if (optionErrors.Count > 0) + { + disclosure.Outcome = ProtectedWorkflowDisclosureOutcomes.FailedValidation; + code = $"{ProtectedWorkflowErrorCodes.PayloadInvalid}: rule={optionErrors[0]}"; + } + else + { + var rendered = await Template.Parse(step.OutputTemplate ?? string.Empty).RenderAsync(NewContext(sampleContext, (ScriptObject)sample.Namespace)); + rendered = ProtectedPayloadValidator.NormalizeBody(options.MediaType, rendered); + ProtectedOutboundGuard.Scrub(rendered, out var envelopes); + var testBytes = Encoding.UTF8.GetBytes(rendered ?? string.Empty); + disclosure.PayloadSha256 = ProtectedWorkflowDisclosureChain.Sha256Hex(testBytes); + disclosure.PayloadBytes = testBytes.Length; + var check = envelopes > 0 ? ProtectedPayloadCheck.Valid : ProtectedPayloadValidator.Validate(options.MediaType, rendered); + if (envelopes > 0) + { + disclosure.Outcome = ProtectedWorkflowDisclosureOutcomes.BlockedGuard; + code = ProtectedWorkflowErrorCodes.EnvelopeInPayload; + } + else if (!check.Ok) + { + disclosure.Outcome = ProtectedWorkflowDisclosureOutcomes.FailedValidation; + code = check.Describe(); + } + else if (!await RecordAttemptAsync(disclosure)) + { + disclosure.Outcome = null; + code = ProtectedWorkflowErrorCodes.DisclosureUnavailable; + } + else + { + var credential = credentials.First(c => string.Equals(c.WorkflowCredentialId, validation.WorkflowCredentialId, StringComparison.OrdinalIgnoreCase)); + var result = await _executorFactory.GetExecutor((WorkflowActionType)step.ActionType).ExecuteAsync(new WorkflowActionContext + { + RenderedContent = rendered, + DecryptedCredentialJson = _encryptionService.DecryptForDepartment(credential.EncryptedData, departmentId, departmentCode), + ActionConfigJson = renderedActionConfig, + WorkflowId = workflowId, + WorkflowStepId = step.WorkflowStepId, + DepartmentId = departmentId, + ActionType = step.ActionType, + CredentialType = credential.CredentialType, + ProtectedMode = true, + PinnedHost = validation.DestinationHost, + PinnedTokenHost = tokenHost, + PinnedAuthMethod = pins?.AuthMethod, + IdempotencyKey = idempotencyKey + }, cancellationToken); + + // A test never writes captured values anywhere: there is no real call behind synthetic data. + disclosure.HttpStatus = result.HttpStatus; + disclosure.PayloadSha256 = result.PayloadSha256; + disclosure.PayloadBytes = result.PayloadBytes; + disclosure.Outcome = result.Success ? ProtectedWorkflowDisclosureOutcomes.Sent : result.ProtectedOutcome ?? ProtectedWorkflowDisclosureOutcomes.FailedHttp; + code = result.Success ? null : disclosure.Outcome switch + { + ProtectedWorkflowDisclosureOutcomes.FailedAck => ProtectedWorkflowErrorCodes.AckRejected, + ProtectedWorkflowDisclosureOutcomes.FailedResponseTooLarge => ProtectedWorkflowErrorCodes.ResponseTooLarge, + ProtectedWorkflowDisclosureOutcomes.BlockedHost => ProtectedWorkflowErrorCodes.HostMismatch, + _ => ProtectedWorkflowErrorCodes.HttpFailed + }; + } + } + } + catch (Exception ex) + { + disclosure.Outcome = ProtectedWorkflowDisclosureOutcomes.FailedRender; + code = ProtectedWorkflowLogText.Error(ProtectedWorkflowErrorCodes.StepError, ex); + } + + disclosure.Detail = code == null ? "test" : $"test;{code}"; + if (disclosure.Outcome != null) + await RecordProtectedDisclosureAsync(disclosure, new WorkflowRunLog()); + stepResults.Add(new ProtectedWorkflowTestStepResult + { + WorkflowStepId = step.WorkflowStepId, + Outcome = disclosure.Outcome, + HttpStatus = disclosure.HttpStatus, + ErrorCode = code + }); + } + + var allSent = stepResults.Count > 0 && stepResults.All(s => s.Outcome == ProtectedWorkflowDisclosureOutcomes.Sent); + var testResult = new ProtectedWorkflowTestResult { Success = allSent, ErrorCode = allSent ? null : ProtectedWorkflowErrorCodes.HttpFailed }; + testResult.Steps.AddRange(stepResults); + return testResult; + } + + // ── Lifecycle hooks (best effort: the runtime's fingerprint check is the fail-closed backstop) ─────── + + private async Task OnProtectedConfigurationChangedAsync(string workflowId, string actorUserId, CancellationToken cancellationToken) + { + if (_protectedWorkflows == null || string.IsNullOrWhiteSpace(workflowId)) + return; + try { await _protectedWorkflows.OnWorkflowConfigurationChangedAsync(workflowId, actorUserId, cancellationToken); } + catch (Exception ex) { Logging.LogError($"Protected workflow change hook failed for workflow {workflowId}: {ex.GetType().FullName}."); } + } + + private async Task OnProtectedWorkflowDeletedAsync(Workflow workflow, CancellationToken cancellationToken) + { + if (_protectedWorkflows == null || workflow == null) + return; + try { await _protectedWorkflows.OnWorkflowDeletedAsync(workflow, null, cancellationToken); } + catch (Exception ex) { Logging.LogError($"Protected workflow delete hook failed for workflow {workflow.WorkflowId}: {ex.GetType().FullName}."); } + } + + private async Task CaptureCredentialChangeAsync(WorkflowCredential incoming, string departmentCode) + { + if (_protectedWorkflows == null || incoming == null || string.IsNullOrEmpty(incoming.WorkflowCredentialId)) + return null; + + try + { + var stored = await _credentialRepository.GetByIdAsync(incoming.WorkflowCredentialId); + if (stored == null) + return null; + + string previousJson = null; + try { previousJson = _encryptionService.DecryptForDepartment(stored.EncryptedData, stored.DepartmentId, departmentCode); } + catch (Exception) { /* unreadable before: treat as a secret change */ } + + var previousOAuth = stored.CredentialType == (int)WorkflowCredentialType.OAuth2ClientCredentials; + var currentOAuth = incoming.CredentialType == (int)WorkflowCredentialType.OAuth2ClientCredentials; + return new CredentialChange + { + TypeChanged = stored.CredentialType != incoming.CredentialType, + SecretChanged = !SameJson(previousJson, incoming.EncryptedData), + PreviousTokenHost = previousOAuth ? ProtectedWorkflowService.ReadTokenHost(previousJson) : null, + CurrentTokenHost = currentOAuth ? ProtectedWorkflowService.ReadTokenHost(incoming.EncryptedData) : null, + PreviousAuthMethod = previousOAuth ? ProtectedWorkflowService.ReadPins(previousJson).AuthMethod : null, + CurrentAuthMethod = currentOAuth ? ProtectedWorkflowService.ReadPins(incoming.EncryptedData).AuthMethod : null + }; + } + catch (Exception ex) + { + Logging.LogError($"Protected workflow credential capture failed for credential {incoming.WorkflowCredentialId}: {ex.GetType().FullName}."); + return null; + } + } + + private async Task OnProtectedCredentialSavedAsync(WorkflowCredential credential, CredentialChange change, CancellationToken cancellationToken) + { + if (_protectedWorkflows == null || change == null) + return; + try + { + await _protectedWorkflows.OnCredentialSavedAsync(credential, change.TypeChanged, change.SecretChanged || change.RotatedKeyId != null, + change.PreviousTokenHost, change.CurrentTokenHost, credential.UpdatedByUserId, cancellationToken, + change.PreviousAuthMethod, change.CurrentAuthMethod, change.RotatedKeyId); + } + catch (Exception ex) { Logging.LogError($"Protected workflow credential hook failed for credential {credential.WorkflowCredentialId}: {ex.GetType().FullName}."); } + } + + /// Semantic JSON equality (property order and whitespace do not matter); ordinal text equality otherwise. + private static bool SameJson(string left, string right) + { + if (string.Equals(left, right, StringComparison.Ordinal)) + return true; + try { return JToken.DeepEquals(JToken.Parse(left ?? "null"), JToken.Parse(right ?? "null")); } + catch (JsonException) { return false; } + } + + private async Task OnProtectedCredentialDeletedAsync(WorkflowCredential credential, CancellationToken cancellationToken) + { + if (_protectedWorkflows == null || credential == null) + return; + try { await _protectedWorkflows.OnCredentialDeletedAsync(credential, null, cancellationToken); } + catch (Exception ex) { Logging.LogError($"Protected workflow credential delete hook failed for credential {credential.WorkflowCredentialId}: {ex.GetType().FullName}."); } + } + } +} diff --git a/Core/Resgrid.Services/WorkflowService.cs b/Core/Resgrid.Services/WorkflowService.cs index d809f7509..c6a157315 100644 --- a/Core/Resgrid.Services/WorkflowService.cs +++ b/Core/Resgrid.Services/WorkflowService.cs @@ -18,7 +18,7 @@ namespace Resgrid.Services { - public class WorkflowService : IWorkflowService + public partial class WorkflowService : IWorkflowService { private readonly IWorkflowRepository _workflowRepository; private readonly IWorkflowStepRepository _stepRepository; @@ -34,6 +34,8 @@ public class WorkflowService : IWorkflowService private readonly Lazy _protectedProjection; private readonly Lazy _history; private IReadinessHistoryProtectionService History => _history?.Value ?? throw new InvalidOperationException("Readiness history protection is unavailable."); + private readonly IProtectedWorkflowRuntime _protectedRuntime; + private readonly IProtectedWorkflowService _protectedWorkflows; public WorkflowService( IWorkflowRepository workflowRepository, @@ -46,8 +48,11 @@ public WorkflowService( IWorkflowActionExecutorFactory executorFactory, IWorkflowTemplateContextBuilder contextBuilder, ISubscriptionsService subscriptionsService, - IRecordsExportService recordsExportService, Lazy protectedProjection = null, Lazy history = null) + IRecordsExportService recordsExportService, Lazy protectedProjection = null, Lazy history = null, + IProtectedWorkflowRuntime protectedRuntime = null, IProtectedWorkflowService protectedWorkflows = null) { + _protectedRuntime = protectedRuntime; + _protectedWorkflows = protectedWorkflows; _recordsExportService = recordsExportService; _protectedProjection = protectedProjection; _history = history; @@ -90,6 +95,7 @@ public async Task SaveWorkflowAsync(Workflow workflow, CancellationTok { workflow.UpdatedOn = DateTime.UtcNow; await _workflowRepository.UpdateAsync(workflow, cancellationToken); + await OnProtectedConfigurationChangedAsync(workflow.WorkflowId, null, cancellationToken); return workflow; } } @@ -104,6 +110,7 @@ public async Task DeleteWorkflowAsync(string workflowId, CancellationToken // FK_WorkflowRuns_Workflows constraint violation that occurs when a background worker // inserts a new WorkflowRun between the sequential per-table deletes. await _workflowRepository.DeleteWorkflowWithAllDependenciesAsync(workflowId); + await OnProtectedWorkflowDeletedAsync(workflow, cancellationToken); return true; } @@ -114,18 +121,6 @@ public async Task> GetActiveWorkflowsByDepartmentAndEventTypeAsyn return results?.ToList() ?? new List(); } - public async Task WorkflowExistsForEventTypeAsync(int departmentId, int triggerEventType, CancellationToken cancellationToken = default) - { - var existing = await _workflowRepository.GetByDepartmentAndEventTypeAsync(departmentId, triggerEventType); - return existing != null; - } - - public async Task> GetUsedEventTypesForDepartmentAsync(int departmentId, CancellationToken cancellationToken = default) - { - var workflows = await _workflowRepository.GetAllByDepartmentIdAsync(departmentId); - return workflows?.Select(w => w.TriggerEventType).ToHashSet() ?? (IReadOnlyCollection)Array.Empty(); - } - public async Task CanAddWorkflowAsync(int departmentId, bool isFreePlan, CancellationToken cancellationToken = default) { var max = isFreePlan ? WorkflowConfig.FreeMaxWorkflowsPerDepartment : WorkflowConfig.MaxWorkflowsPerDepartment; @@ -177,7 +172,9 @@ public async Task SaveWorkflowStepAsync(WorkflowStep step, Cancell { step.WorkflowStepId = Guid.NewGuid().ToString(); step.CreatedOn = DateTime.UtcNow; - return await _stepRepository.InsertAsync(step, cancellationToken); + var inserted = await _stepRepository.InsertAsync(step, cancellationToken); + await OnProtectedConfigurationChangedAsync(step.WorkflowId, step.CreatedByUserId, cancellationToken); + return inserted; } // Fetch the existing record to preserve immutable audit fields (CreatedOn, CreatedByUserId). @@ -189,6 +186,10 @@ public async Task SaveWorkflowStepAsync(WorkflowStep step, Cancell step.CreatedByUserId = existing.CreatedByUserId; step.UpdatedOn = DateTime.UtcNow; await _stepRepository.UpdateAsync(step, cancellationToken); + await OnProtectedConfigurationChangedAsync(step.WorkflowId, step.UpdatedByUserId ?? step.CreatedByUserId, cancellationToken); + // A step moved to another workflow changes the workflow it left, too. + if (!string.Equals(existing.WorkflowId, step.WorkflowId, StringComparison.OrdinalIgnoreCase)) + await OnProtectedConfigurationChangedAsync(existing.WorkflowId, step.UpdatedByUserId ?? step.CreatedByUserId, cancellationToken); return step; } @@ -197,6 +198,7 @@ public async Task DeleteWorkflowStepAsync(string stepId, CancellationToken var step = await _stepRepository.GetByIdAsync(stepId); if (step == null) return false; await _stepRepository.DeleteAsync(step, cancellationToken); + await OnProtectedConfigurationChangedAsync(step.WorkflowId, null, cancellationToken); return true; } @@ -217,8 +219,40 @@ public async Task> GetCredentialsByDepartmentIdAsync(in return results?.ToList() ?? new List(); } - public async Task SaveCredentialAsync(WorkflowCredential credential, string departmentCode, CancellationToken cancellationToken = default) + public Task SaveCredentialAsync(WorkflowCredential credential, string departmentCode, CancellationToken cancellationToken = default) => + SaveCredentialInternalAsync(credential, departmentCode, rotateSigningKey: false, cancellationToken); + + public async Task RotateCredentialSigningKeyAsync(string credentialId, int departmentId, string departmentCode, string userId, + CancellationToken cancellationToken = default) + { + var credential = await _credentialRepository.GetByIdAsync(credentialId); + if (credential == null || credential.DepartmentId != departmentId || + credential.CredentialType != (int)WorkflowCredentialType.OAuth2ClientCredentials) + return null; + + credential.EncryptedData = _encryptionService.DecryptForDepartment(credential.EncryptedData, departmentId, departmentCode); + if (WorkflowJwtKeys.NormalizeAuthMethod(ReadJsonString(credential.EncryptedData, "authMethod")) != WorkflowJwtKeys.PrivateKeyJwt) + return null; + + credential.UpdatedByUserId = userId; + return await SaveCredentialInternalAsync(credential, departmentCode, rotateSigningKey: true, cancellationToken); + } + + private async Task SaveCredentialInternalAsync(WorkflowCredential credential, string departmentCode, bool rotateSigningKey, + CancellationToken cancellationToken) { + // OAuth2 private_key_jwt: the signing keys are generated and kept here, inside the encrypted data. The editor never + // sends or sees them; only the public halves go to PublicJwks. + var keys = await PrepareSigningKeysAsync(credential, departmentCode, rotateSigningKey); + credential.EncryptedData = keys.Json; + + // Protected Workflows pin a credential by id: capture what the stored row was BEFORE it is overwritten, so a + // type, OAuth2 token-host or client-authentication change suspends pinned releases and a secret-only rotation + // is recorded. + var change = await CaptureCredentialChangeAsync(credential, departmentCode); + if (change != null) + change.RotatedKeyId = keys.RotatedKeyId; + credential.EncryptedData = _encryptionService.EncryptForDepartment( credential.EncryptedData, credential.DepartmentId, departmentCode); @@ -232,15 +266,90 @@ public async Task SaveCredentialAsync(WorkflowCredential cre { credential.UpdatedOn = DateTime.UtcNow; await _credentialRepository.UpdateAsync(credential, cancellationToken); + await OnProtectedCredentialSavedAsync(credential, change, cancellationToken); return credential; } } + /// + /// Normalizes an OAuth2 credential's key material. client_secret: no keys, no JWKS. private_key_jwt: keys carried over + /// from the stored credential (never from the caller), a first key generated when there is none, a new key when + /// rotating or when the algorithm changes (the previous one retired but published for the overlap window), and + /// expired keys dropped. Returns the JSON to encrypt and the kid of a key that REPLACED another one. + /// + private async Task<(string Json, string RotatedKeyId)> PrepareSigningKeysAsync(WorkflowCredential credential, string departmentCode, bool rotate) + { + if (credential.CredentialType != (int)WorkflowCredentialType.OAuth2ClientCredentials) + { + credential.PublicJwks = null; + return (credential.EncryptedData, null); + } + + JObject incoming; + try { incoming = JObject.Parse(credential.EncryptedData ?? "{}"); } + catch (JsonException) { credential.PublicJwks = null; return (credential.EncryptedData, null); } + + var method = WorkflowJwtKeys.NormalizeAuthMethod((string)incoming.GetValue("authMethod", StringComparison.OrdinalIgnoreCase)); + incoming.Remove("signingKeys"); + if (method != WorkflowJwtKeys.PrivateKeyJwt) + { + credential.PublicJwks = null; + return (incoming.ToString(Formatting.None), null); + } + + var keys = new List(); + if (!string.IsNullOrEmpty(credential.WorkflowCredentialId)) + { + var stored = await _credentialRepository.GetByIdAsync(credential.WorkflowCredentialId); + if (stored != null && stored.DepartmentId == credential.DepartmentId && stored.CredentialType == credential.CredentialType) + { + try + { + var previous = JObject.Parse(_encryptionService.DecryptForDepartment(stored.EncryptedData, stored.DepartmentId, departmentCode)); + keys = previous.GetValue("signingKeys", StringComparison.OrdinalIgnoreCase)?.ToObject>() ?? keys; + } + catch (Exception ex) when (ex is JsonException || ex is FormatException || ex is System.Security.Cryptography.CryptographicException) + { + Logging.LogError($"Workflow credential {credential.WorkflowCredentialId}: stored signing keys unreadable ({ex.GetType().FullName}); generating a new key."); + } + } + } + + var now = DateTime.UtcNow; + var alg = WorkflowJwtKeys.NormalizeAlgorithm((string)incoming.GetValue("signingAlg", StringComparison.OrdinalIgnoreCase)); + var current = WorkflowJwtKeys.Current(keys); + string rotatedKeyId = null; + if (current == null || rotate || !string.Equals(current.Alg, alg, StringComparison.Ordinal)) + { + foreach (var key in keys.Where(k => !k.RetiredOn.HasValue)) + key.RetiredOn = now; + var (signing, _) = WorkflowJwtKeys.Generate(alg, now); + keys.Add(signing); + if (current != null) + rotatedKeyId = signing.Kid; + } + + keys = keys.Where(k => WorkflowJwtKeys.IsPublished(k.RetiredOn, now, DataProtectionConfig.WorkflowJwksOverlapDays)).ToList(); + incoming.Remove("clientSecret"); + incoming["authMethod"] = WorkflowJwtKeys.PrivateKeyJwt; + incoming["signingAlg"] = alg; + incoming["signingKeys"] = JArray.FromObject(keys); + credential.PublicJwks = WorkflowJwtKeys.WritePublicKeys(keys.Select(WorkflowJwtKeys.PublicFor)); + return (incoming.ToString(Formatting.None), rotatedKeyId); + } + + private static string ReadJsonString(string json, string name) + { + try { return (string)JObject.Parse(json ?? "{}").GetValue(name, StringComparison.OrdinalIgnoreCase); } + catch (Exception) { return null; } + } + public async Task DeleteCredentialAsync(string credentialId, CancellationToken cancellationToken = default) { var cred = await _credentialRepository.GetByIdAsync(credentialId); if (cred == null) return false; await _credentialRepository.DeleteAsync(cred, cancellationToken); + await OnProtectedCredentialDeletedAsync(cred, cancellationToken); return true; } @@ -369,6 +478,36 @@ public async Task ExecuteWorkflowAsync( run = await _runRepository.InsertAsync(run, cancellationToken); } + // ── Protected Workflows run gate ───────────────────────────────────── + // A workflow whose release is not Active is skipped, never run unprotected (its destination expects + // plaintext; REDACTED would overwrite the real record). An Active release sends every step through the + // protected path: fresh preconditions, an allow-listed decrypt, the pinned host, value-free logs. + ProtectedRunGate protectedGate; + try + { + protectedGate = _protectedRuntime == null ? ProtectedRunGate.NotProtected : await _protectedRuntime.GetRunGateAsync(workflow, cancellationToken); + } + catch (Exception gateEx) when (!(gateEx is OperationCanceledException && cancellationToken.IsCancellationRequested)) + { + // Unknown protection state: fail closed. Nothing runs; the run fails (and retries) without sending. + Logging.LogError($"Protected workflow gate unavailable for run {run.WorkflowRunId}: {gateEx.GetType().FullName}."); + run.Status = (int)WorkflowRunStatus.Failed; + run.ErrorMessage = "protected_gate_unavailable"; + run.CompletedOn = DateTime.UtcNow; + await UpdateRunAsync(run, cancellationToken); + return run; + } + + if (protectedGate.SkipReason != null) + { + run.Status = (int)WorkflowRunStatus.Skipped; + run.SkipReason = protectedGate.SkipReason; + run.CompletedOn = DateTime.UtcNow; + await UpdateRunAsync(run, cancellationToken); + return run; + } + // ── End protected run gate ─────────────────────────────────────────── + // Build template context once for all steps var triggerEventType = (WorkflowTriggerEventType)workflow.TriggerEventType; object scriptObject = null; @@ -378,9 +517,11 @@ public async Task ExecuteWorkflowAsync( } catch (Exception ex) { - if (checklist) Logging.LogError($"Checklist workflow failed for run {run.WorkflowRunId}: {ex.GetType().FullName}."); else Logging.LogException(ex); + if (checklist || protectedGate.IsProtected) Logging.LogError($"Workflow context failed for run {run.WorkflowRunId}: {ex.GetType().FullName}."); else Logging.LogException(ex); run.Status = (int)WorkflowRunStatus.Failed; - run.ErrorMessage = $"Failed to build template context: {ex.Message}"; + run.ErrorMessage = protectedGate.IsProtected + ? ProtectedWorkflowLogText.Error("context_failed", ex) + : $"Failed to build template context: {ex.Message}"; run.CompletedOn = DateTime.UtcNow; await UpdateRunAsync(run, cancellationToken); return run; @@ -398,10 +539,36 @@ public async Task ExecuteWorkflowAsync( var steps = await GetStepsByWorkflowIdAsync(workflowId, cancellationToken); var anyFailure = false; + var anyRetryable = false; var utcToday = DateTime.UtcNow.Date; + string lastProtectedError = null; foreach (var step in steps.Where(s => s.IsEnabled)) { + // run.* for this step: run.idempotency_key is the same on every retry of this delivery, so a destination that + // honors it (an Idempotency-Key header, FHIR conditional create, HL7 MSH-10) never records it twice. + var idempotencyKey = WorkflowIdempotency.Key(workflowId, run.EventId, run.WorkflowRunId, step.WorkflowStepId); + ((Scriban.Runtime.ScriptObject)scriptObject)["run"] = new Scriban.Runtime.ScriptObject + { + ["id"] = run.WorkflowRunId, + ["attempt"] = attemptNumber, + ["idempotency_key"] = idempotencyKey + }; + + if (protectedGate.IsProtected) + { + var protectedStep = await ExecuteProtectedStepAsync(workflow, run, step, (Scriban.Runtime.ScriptObject)scriptObject, + eventPayloadJson, departmentId, departmentCode, isFreePlan, idempotencyKey, cancellationToken); + await InsertLogAsync(departmentId, checklist, protectedStep.Log, cancellationToken); + if (protectedStep.Failed) + { + anyFailure = true; + anyRetryable |= protectedStep.Retryable; + lastProtectedError = protectedStep.ErrorCode ?? lastProtectedError; + } + continue; + } + var logEntry = new WorkflowRunLog { WorkflowRunLogId = Guid.NewGuid().ToString(), @@ -420,7 +587,9 @@ public async Task ExecuteWorkflowAsync( var conditionContext = new Scriban.TemplateContext { LoopLimit = WorkflowConfig.ScribanLoopLimit, - StrictVariables = false + StrictVariables = false, + // protected.* never exists outside an approved release: it must read as empty, not throw. + EnableRelaxedTargetAccess = ProtectedWorkflowValidator.ReferencesProtectedNamespace(step.ConditionExpression) }; conditionContext.PushGlobal((Scriban.Runtime.ScriptObject)scriptObject); @@ -499,7 +668,10 @@ public async Task ExecuteWorkflowAsync( var scribanContext = new Scriban.TemplateContext { LoopLimit = WorkflowConfig.ScribanLoopLimit, - StrictVariables = false + StrictVariables = false, + // A protected.* reference in a workflow without an Active release renders as an empty string. + EnableRelaxedTargetAccess = ProtectedWorkflowValidator.ReferencesProtectedNamespace(step.OutputTemplate) || + ProtectedWorkflowValidator.ReferencesProtectedNamespace(step.ActionConfig) }; scribanContext.PushGlobal((Scriban.Runtime.ScriptObject)scriptObject); // ── End sandboxed Scriban context ──────────────────────────────── @@ -561,12 +733,16 @@ public async Task ExecuteWorkflowAsync( // Decrypt credential if one is attached string decryptedCredJson = null; + int? credentialType = null; if (!string.IsNullOrEmpty(step.WorkflowCredentialId)) { var cred = await _credentialRepository.GetByIdAsync(step.WorkflowCredentialId); if (cred != null) + { + credentialType = cred.CredentialType; decryptedCredJson = _encryptionService.DecryptForDepartment( cred.EncryptedData, departmentId, departmentCode); + } } // ── Records report export attachment (RMS plan section 5.6) ───── @@ -628,7 +804,9 @@ public async Task ExecuteWorkflowAsync( DepartmentId = departmentId, ActionType = step.ActionType, IsFreePlanDepartment = isFreePlan, - Attachment = attachment + Attachment = attachment, + CredentialType = credentialType, + IdempotencyKey = idempotencyKey }; var executor = _executorFactory.GetExecutor((WorkflowActionType)step.ActionType); @@ -683,12 +861,19 @@ public async Task ExecuteWorkflowAsync( ? workflow.MaxRetryCount : WorkflowConfig.DefaultMaxRetryCount; - if (attemptNumber < maxRetries) + // A protected run retries only for failures another attempt could fix (transport, 5xx, 429); a rejected + // acknowledgement, a 4xx or a refused send stops at once and alerts. + if (attemptNumber < maxRetries && (!protectedGate.IsProtected || anyRetryable)) run.Status = (int)WorkflowRunStatus.Retrying; else { run.Status = (int)WorkflowRunStatus.Failed; - run.ErrorMessage = "Maximum retry attempts exceeded."; + run.ErrorMessage = protectedGate.IsProtected && !anyRetryable && attemptNumber < maxRetries + ? $"Not retried: {lastProtectedError ?? ProtectedWorkflowErrorCodes.StepError}" + : "Maximum retry attempts exceeded."; + if (protectedGate.IsProtected && _protectedWorkflows != null) + await _protectedWorkflows.NotifyFinalFailureAsync(departmentId, workflow, run.WorkflowRunId, + lastProtectedError ?? ProtectedWorkflowErrorCodes.StepError, cancellationToken); } } else diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index db3cb0690..48c1066de 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -56,6 +56,7 @@ public async Task BuildContextAsync( CancellationToken cancellationToken) { var scriptObject = new ScriptObject(); + WorkflowTemplateFunctions.AddTo(scriptObject); var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); var phoneNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(departmentId); @@ -915,6 +916,9 @@ private async Task MapCallVariablesAsync(ScriptObject obj, Call call, int depart c["dispatch_count"] = call.DispatchCount; c["dispatch_on"] = call.DispatchOn; c["form_data"] = ProtectedDataEnvelope.SafeDisplay(call.CallFormData) ?? string.Empty; + // Structural, never protected: a condition may branch on it. The subject identifiers are deliberately absent + // from call.*; they are only ever available as protected.call.subject_ids under an Active release. + c["part2_consent_on_file"] = call.Part2ConsentOnFile; c["is_deleted"] = call.IsDeleted; c["deleted_reason"] = ProtectedDataEnvelope.SafeDisplay(call.DeletedReason) ?? string.Empty; @@ -1564,7 +1568,7 @@ private static string MapRecordsEventVariables(ScriptObject obj, RecordsWorkflow return recordToken?["author_user_id"]?.Type == JTokenType.String ? (string)recordToken["author_user_id"] : null; } - private static ScriptObject ToScriptObject(JObject source) + internal static ScriptObject ToScriptObject(JObject source) { var result = new ScriptObject(); if (source == null) @@ -1576,7 +1580,7 @@ private static ScriptObject ToScriptObject(JObject source) return result; } - private static object ToScriptValue(JToken token) + internal static object ToScriptValue(JToken token) { if (token == null) return null; diff --git a/Core/Resgrid.Services/WorkflowTemplateFunctions.cs b/Core/Resgrid.Services/WorkflowTemplateFunctions.cs new file mode 100644 index 000000000..b126a9e6e --- /dev/null +++ b/Core/Resgrid.Services/WorkflowTemplateFunctions.cs @@ -0,0 +1,174 @@ +using System; +using System.Globalization; +using System.Text; +using Newtonsoft.Json; +using Scriban.Runtime; + +namespace Resgrid.Services +{ + /// + /// Escaping and date helpers available to every workflow template (pipe a value into them: + /// {{ call.notes | json_escape }}). They make free text safe to drop into a structured payload: a quote, + /// an angle bracket or an HL7 delimiter in a note can never break out of the value it was put in. + /// + public static class WorkflowTemplateFunctions + { + public const string JsonEscapeName = "json_escape"; + public const string XmlEscapeName = "xml_escape"; + public const string Hl7EscapeName = "hl7_escape"; + public const string FhirDateTimeName = "fhir_datetime"; + public const string Hl7TimestampName = "hl7_ts"; + + /// The helpers that make a value safe inside a payload (the date helpers format, they do not escape). + public static readonly string[] EscapeHelperNames = { JsonEscapeName, XmlEscapeName, Hl7EscapeName }; + + /// Adds the helpers to a template context's global object. + public static void AddTo(ScriptObject scriptObject) + { + if (scriptObject == null) + return; + + scriptObject.Import(JsonEscapeName, new Func(JsonEscape)); + scriptObject.Import(XmlEscapeName, new Func(XmlEscape)); + scriptObject.Import(Hl7EscapeName, new Func(Hl7Escape)); + scriptObject.Import(FhirDateTimeName, new Func(FhirDateTime)); + scriptObject.Import(Hl7TimestampName, new Func(Hl7Timestamp)); + } + + /// The value escaped for use INSIDE a JSON string (no surrounding quotes). Null is an empty string. + public static string JsonEscape(object value) + { + var text = AsText(value); + if (text.Length == 0) + return string.Empty; + + // Escaping HTML characters too keeps "" and friends inert if a payload is ever embedded in a page. + var quoted = JsonConvert.ToString(text, '"', StringEscapeHandling.EscapeHtml); + return quoted.Substring(1, quoted.Length - 2); + } + + /// + /// The value escaped for XML text or attribute content. Characters XML 1.0 cannot carry at all (control characters + /// other than tab, CR and LF, and unpaired surrogates) are dropped rather than written as an invalid document. + /// + public static string XmlEscape(object value) + { + var text = AsText(value); + if (text.Length == 0) + return string.Empty; + + var sb = new StringBuilder(text.Length + 16); + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + switch (c) + { + case '&': sb.Append("&"); continue; + case '<': sb.Append("<"); continue; + case '>': sb.Append(">"); continue; + case '"': sb.Append("""); continue; + case '\'': sb.Append("'"); continue; + case '\t': sb.Append(" "); continue; + case '\n': sb.Append(" "); continue; + case '\r': sb.Append(" "); continue; + } + + if (char.IsHighSurrogate(c)) + { + if (i + 1 < text.Length && char.IsLowSurrogate(text[i + 1])) + { + sb.Append(c).Append(text[i + 1]); + i++; + } + continue; + } + + if (char.IsLowSurrogate(c) || c < 0x20 || c == '\uFFFE' || c == '\uFFFF') + continue; + + sb.Append(c); + } + + return sb.ToString(); + } + + /// + /// HL7 v2 escaping with the standard encoding characters (|^~\&): the escape character first, then the + /// field, component, repetition and subcomponent separators. CR and LF become \X0D\ and \X0A\, so a + /// line break in a note can never start a new segment. Other control characters are dropped. + /// + public static string Hl7Escape(object value) + { + var text = AsText(value); + if (text.Length == 0) + return string.Empty; + + var sb = new StringBuilder(text.Length + 16); + foreach (var c in text) + { + switch (c) + { + case '\\': sb.Append("\\E\\"); break; + case '|': sb.Append("\\F\\"); break; + case '^': sb.Append("\\S\\"); break; + case '&': sb.Append("\\T\\"); break; + case '~': sb.Append("\\R\\"); break; + case '\r': sb.Append("\\X0D\\"); break; + case '\n': sb.Append("\\X0A\\"); break; + default: + if (c >= 0x20 || c == '\t') + sb.Append(c); + break; + } + } + + return sb.ToString(); + } + + /// A date as a FHIR dateTime in UTC (2026-09-24T14:05:00Z). Empty when there is no date. + public static string FhirDateTime(object value) + { + var utc = AsUtc(value); + return utc.HasValue ? utc.Value.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture) : string.Empty; + } + + /// A date as an HL7 TS in UTC (20260924140500+0000). Empty when there is no date. + public static string Hl7Timestamp(object value) + { + var utc = AsUtc(value); + return utc.HasValue ? utc.Value.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture) + "+0000" : string.Empty; + } + + private static string AsText(object value) => value switch + { + null => string.Empty, + string s => s, + bool b => b ? "true" : "false", + DateTime d => FhirDateTime(d), + DateTimeOffset o => FhirDateTime(o), + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + + /// + /// A UTC instant from a template value. Unspecified DateTimes are UTC (the platform stores UTC); strings are parsed + /// invariantly, and one without an offset is taken as UTC. + /// + private static DateTime? AsUtc(object value) + { + switch (value) + { + case DateTime d: + return d.Kind == DateTimeKind.Local ? d.ToUniversalTime() : DateTime.SpecifyKind(d, DateTimeKind.Utc); + case DateTimeOffset o: + return o.UtcDateTime; + case string s when !string.IsNullOrWhiteSpace(s): + if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AllowWhiteSpaces, out var parsed)) + return parsed.UtcDateTime; + return null; + default: + return null; + } + } + } +} diff --git a/Core/Resgrid.Services/WorkflowTemplateGallery.cs b/Core/Resgrid.Services/WorkflowTemplateGallery.cs new file mode 100644 index 000000000..37c76dd82 --- /dev/null +++ b/Core/Resgrid.Services/WorkflowTemplateGallery.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Resgrid.Model; + +namespace Resgrid.Services +{ + /// One step of a gallery template: everything but the credential, which the department picks. + public sealed class WorkflowGalleryStep + { + public WorkflowActionType ActionType { get; init; } + public string ActionConfig { get; init; } + public string OutputTemplate { get; init; } + public string ConditionExpression { get; init; } + } + + /// A ready-made workflow: a trigger and its steps, with placeholder destinations and no real data. + public sealed class WorkflowGalleryTemplate + { + public string Key { get; init; } + public WorkflowTriggerEventType Trigger { get; init; } + + /// Only offered when the department has Protected Workflows enabled (the EHR samples). + public bool RequiresProtectedWorkflows { get; init; } + + /// The release field ids the template reads, for the Draft release created with it. + public IReadOnlyList ReleaseFieldIds { get; init; } = Array.Empty(); + + public IReadOnlyList Steps { get; init; } = Array.Empty(); + + /// Localization keys (Workflows area): Gallery_{Key}_Name and Gallery_{Key}_Description. + public string NameKey => $"Gallery_{Key}_Name"; + public string DescriptionKey => $"Gallery_{Key}_Description"; + } + + /// + /// The workflow template gallery. Templates escape every value they place in a structured payload (json_escape, + /// xml_escape, hl7_escape) and use run.idempotency_key so a retried delivery is recognized. The EHR samples (FHIR R4 + /// transaction Bundle, HL7 v2.5.1 MDM^T02) are only offered to departments with Protected Workflows enabled; they read + /// protected values, so they only ever send under an approved release. + /// + public static class WorkflowTemplateGallery + { + public const string WebhookCallAdded = "webhook_call_added"; + public const string EmailCallClosed = "email_call_closed"; + public const string FhirEncounterBundle = "fhir_encounter_bundle"; + public const string Hl7MdmT02 = "hl7_mdm_t02"; + + /// Placeholder destinations: an administrator replaces them before anything can be approved or enabled. + public const string FhirPlaceholderUrl = "https://ehr.example.org/fhir/r4"; + public const string Hl7PlaceholderUrl = "https://interface-engine.example.org/hl7/mdm"; + + public static readonly IReadOnlyList All = new List + { + new WorkflowGalleryTemplate + { + Key = WebhookCallAdded, + Trigger = WorkflowTriggerEventType.CallAdded, + Steps = new[] + { + new WorkflowGalleryStep + { + ActionType = WorkflowActionType.CallApiPost, + ActionConfig = JsonConvert.SerializeObject(new { url = "https://hooks.example.org/resgrid/calls", contentType = "application/json", timeoutSeconds = 30, idempotencyHeader = "Idempotency-Key" }), + OutputTemplate = WebhookTemplate + } + } + }, + new WorkflowGalleryTemplate + { + Key = EmailCallClosed, + Trigger = WorkflowTriggerEventType.CallClosed, + Steps = new[] + { + new WorkflowGalleryStep + { + ActionType = WorkflowActionType.SendEmail, + ActionConfig = JsonConvert.SerializeObject(new { to = "dispatch-supervisors@example.org", subject = "Call {{ call.number }} closed" }), + OutputTemplate = EmailTemplate + } + } + }, + new WorkflowGalleryTemplate + { + Key = FhirEncounterBundle, + Trigger = WorkflowTriggerEventType.CallClosed, + RequiresProtectedWorkflows = true, + ReleaseFieldIds = new[] { ProtectedWorkflowFieldCatalog.SubjectIdentifierFieldId("ehr_client_id") }, + Steps = new[] + { + new WorkflowGalleryStep + { + ActionType = WorkflowActionType.CallApiPost, + ActionConfig = JsonConvert.SerializeObject(new + { + url = FhirPlaceholderUrl, + contentType = "application/fhir+json", + timeoutSeconds = 30, + successRule = new { type = ProtectedSuccessRule.FhirOperationOutcome }, + responseCapture = new[] { new { source = ProtectedCaptureEntry.FhirLocationId, expression = "Encounter", key = "ehr_encounter_id" } }, + idempotencyHeader = "Idempotency-Key" + }), + OutputTemplate = FhirTemplate + } + } + }, + new WorkflowGalleryTemplate + { + Key = Hl7MdmT02, + Trigger = WorkflowTriggerEventType.CallClosed, + RequiresProtectedWorkflows = true, + ReleaseFieldIds = new[] { ProtectedWorkflowFieldCatalog.SubjectIdentifierFieldId("ehr_client_id") }, + Steps = new[] + { + new WorkflowGalleryStep + { + ActionType = WorkflowActionType.CallApiPost, + ActionConfig = JsonConvert.SerializeObject(new + { + url = Hl7PlaceholderUrl, + contentType = ProtectedPayloadValidator.Hl7MediaType, + timeoutSeconds = 30, + successRule = new { type = ProtectedSuccessRule.Hl7Ack } + }), + OutputTemplate = Hl7Template + } + } + } + }; + + public static IReadOnlyList Available(bool protectedWorkflowsEnabled) => + All.Where(t => protectedWorkflowsEnabled || !t.RequiresProtectedWorkflows).ToList(); + + public static WorkflowGalleryTemplate Find(string key) => + All.FirstOrDefault(t => string.Equals(t.Key, key, StringComparison.Ordinal)); + + // ── Templates ───────────────────────────────────────────────────────────────────────────────── + + private const string WebhookTemplate = +@"{ + ""event"": ""call.added"", + ""delivery_id"": ""{{ run.idempotency_key }}"", + ""department"": ""{{ department.name | json_escape }}"", + ""call"": { + ""id"": {{ call.id }}, + ""number"": ""{{ call.number | json_escape }}"", + ""name"": ""{{ call.name | json_escape }}"", + ""priority"": ""{{ call.priority_text | json_escape }}"", + ""address"": ""{{ call.address | json_escape }}"", + ""logged_on"": ""{{ call.logged_on | fhir_datetime }}"" + } +}"; + + private const string EmailTemplate = +@"

Call {{ call.number | xml_escape }} ({{ call.name | xml_escape }}) was closed.

+

Logged {{ call.logged_on }}, closed {{ call.closed_on }}.

"; + + /// + /// A FHIR R4 transaction: the Encounter (class FLD, finished, dispatch to close) conditionally created on the call's + /// identifier, plus one Observation per released call custom field. The Bundle identifier is run.idempotency_key. + /// + private const string FhirTemplate = +@"{{ k = run.idempotency_key }}{{ encounter = ""urn:uuid:"" + (k | string.slice 0 8) + ""-"" + (k | string.slice 8 4) + ""-"" + (k | string.slice 12 4) + ""-"" + (k | string.slice 16 4) + ""-"" + (k | string.slice 20 12) }}{ + ""resourceType"": ""Bundle"", + ""type"": ""transaction"", + ""identifier"": { ""system"": ""https://resgrid.com/workflow-delivery"", ""value"": ""{{ k }}"" }, + ""entry"": [ + { + ""fullUrl"": ""{{ encounter }}"", + ""resource"": { + ""resourceType"": ""Encounter"", + ""identifier"": [ { ""system"": ""https://resgrid.com/call"", ""value"": ""{{ call.id }}"" } ], + ""status"": ""finished"", + ""class"": { ""system"": ""http://terminology.hl7.org/CodeSystem/v3-ActCode"", ""code"": ""FLD"", ""display"": ""field"" }, + ""type"": [ { ""text"": ""Crisis field response"" } ], + ""subject"": { ""reference"": ""Patient/{{ protected.call.subject_ids.ehr_client_id | json_escape }}"" }, + ""period"": { ""start"": ""{{ (call.dispatch_on ?? call.logged_on) | fhir_datetime }}"", ""end"": ""{{ call.closed_on | fhir_datetime }}"" } + }, + ""request"": { ""method"": ""POST"", ""url"": ""Encounter"", ""ifNoneExist"": ""identifier=https://resgrid.com/call|{{ call.id }}"" } + }{{ for field in protected.call.udf }}, + { + ""resource"": { + ""resourceType"": ""Observation"", + ""status"": ""final"", + ""code"": { ""text"": ""{{ field.key | json_escape }}"" }, + ""subject"": { ""reference"": ""Patient/{{ protected.call.subject_ids.ehr_client_id | json_escape }}"" }, + ""encounter"": { ""reference"": ""{{ encounter }}"" }, + ""effectiveDateTime"": ""{{ call.closed_on | fhir_datetime }}"", + ""valueString"": ""{{ field.value | json_escape }}"" + }, + ""request"": { ""method"": ""POST"", ""url"": ""Observation"" } + }{{ end }} + ] +}"; + + /// + /// HL7 v2.5.1 MDM^T02 for an interface engine: MSH-10 is the idempotency key, PID-3 the EHR client id, PV1 the + /// encounter class and times, TXA the "Crisis Field Response" document, one OBX per released call custom field. + /// Every value goes through hl7_escape. Line breaks between segments become CR when the payload is prepared. + /// + /// A computed property, not a field: is initialized first and reads it. + private static string Hl7Template => string.Join("\n", new[] + { + Segment("MSH", "^~\\&", "RESGRID", "{{ department.code | hl7_escape }}", "EHR", "EHR", "{{ timestamp.utc_now | hl7_ts }}", "", "MDM^T02^MDM_T02", + "{{ run.idempotency_key }}", "P", "2.5.1"), + Segment("EVN", "T02", "{{ timestamp.utc_now | hl7_ts }}"), + Segment("PID", "1", "", "{{ protected.call.subject_ids.ehr_client_id | hl7_escape }}^^^EHR^MR"), + Pv1(), + Txa(), + "{{ n = 0 }}{{ for field in protected.call.udf }}{{ n = n + 1 }}" + + Segment("OBX", "{{ n }}", "TX", "{{ field.key | hl7_escape }}", "", "{{ field.value | hl7_escape }}", "", "", "", "", "", "F"), + "{{ end }}" + }); + + private static string Segment(string id, params string[] fields) => id + "|" + string.Join("|", fields); + + /// PV1: class E (emergency), the call number as the visit number (PV1-19), dispatch (PV1-44) and close (PV1-45). + private static string Pv1() + { + var fields = Enumerable.Repeat(string.Empty, 45).ToArray(); + fields[0] = "1"; + fields[1] = "E"; + fields[18] = "{{ call.number | hl7_escape }}"; + fields[43] = "{{ (call.dispatch_on ?? call.logged_on) | hl7_ts }}"; + fields[44] = "{{ call.closed_on | hl7_ts }}"; + return Segment("PV1", fields); + } + + /// TXA: document type (TXA-2), content presentation TX (TXA-3), activity time (TXA-4), unique document number (TXA-12), completion AU (TXA-17). + private static string Txa() + { + var fields = Enumerable.Repeat(string.Empty, 17).ToArray(); + fields[0] = "1"; + fields[1] = "CFR^Crisis Field Response"; + fields[2] = "TX"; + fields[3] = "{{ call.closed_on | hl7_ts }}"; + fields[11] = "{{ run.idempotency_key }}"; + fields[16] = "AU"; + return Segment("TXA", fields); + } + } +} diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs index 8c7049bd3..5754e18c3 100644 --- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs @@ -42,7 +42,9 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro _eventAggregator.AddListener(dListCheckHandler); _eventAggregator.AddListener(shiftTradeRequestedHandler); _eventAggregator.AddListener(shiftTradeRejectedEventHandler); + _eventAggregator.AddListener(shiftTradeProposedEventHandler); _eventAggregator.AddListener(shiftTradeFilledEventHandler); + _eventAggregator.AddListener(shiftRosterChangedEventHandler); _eventAggregator.AddListener(shiftCreatedEventHandler); _eventAggregator.AddListener(shiftUpdatedEventHandler); _eventAggregator.AddListener(shiftDaysAddedEventHandler); @@ -536,6 +538,23 @@ await _outboundQueueProvider.EnqueueNotification(new NotificationItem await _outboundQueueProvider.EnqueueShiftNotification(item); }; + public Action shiftRosterChangedEventHandler = async delegate (ShiftRosterChangedEvent message) + { + if (_outboundQueueProvider == null) + _outboundQueueProvider = new OutboundQueueProvider(); + + var item = new ShiftQueueItem(); + item.DepartmentId = message.DepartmentId; + item.DepartmentNumber = message.DepartmentNumber; + item.Type = (int)message.ChangeType; + item.ShiftId = message.ShiftId; + item.ShiftSignupId = message.ShiftSignupId; + item.ShiftSignupTradeId = message.ShiftSignupTradeId; + item.SourceUserId = message.UserId; + + await _outboundQueueProvider.EnqueueShiftNotification(item); + }; + public Action shiftCreatedEventHandler = async delegate (ShiftCreatedEvent message) { if (_rabbitTopicProvider == null) diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0232_AddProtectedWorkflows.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0232_AddProtectedWorkflows.cs new file mode 100644 index 000000000..aa51b145b --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0232_AddProtectedWorkflows.cs @@ -0,0 +1,155 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// ADP Protected Workflows (push model): the department opt-in on the egress policy, per-workflow protected + /// releases (field allow-list, pinned host and credential, attestation, approval, expiry), and the append-only, + /// per-department hash-chained disclosure log. Everything ships inert: every department's toggle is off and no + /// release exists. WorkflowProtectedReleases.WorkflowId is deliberately not a foreign key — a deleted workflow's + /// release is revoked and kept, and disclosures are retained with the department's audit data. + /// Runs outside a migration transaction so ONLINE index builds do not hold schema locks; every statement is + /// existence-guarded for a safe retry. + /// + [Migration(232, TransactionBehavior.None)] + public class M0232_AddProtectedWorkflows : Migration + { + public override void Up() + { + Execute.Sql(@" +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsEnabled') IS NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] ADD [ProtectedWorkflowsEnabled] bit NOT NULL + CONSTRAINT [DF_DepartmentProtectedDataEgressPolicies_ProtectedWorkflowsEnabled] DEFAULT (0); +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsAckVersion') IS NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] ADD [ProtectedWorkflowsAckVersion] nvarchar(64) NULL; +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsAckByUserId') IS NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] ADD [ProtectedWorkflowsAckByUserId] nvarchar(128) NULL; +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsAckOn') IS NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] ADD [ProtectedWorkflowsAckOn] datetime2 NULL; +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsRequireSecondApprover') IS NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] ADD [ProtectedWorkflowsRequireSecondApprover] bit NOT NULL + CONSTRAINT [DF_DepartmentProtectedDataEgressPolicies_ProtectedWorkflowsRequireSecondApprover] DEFAULT (0); +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsRelaxRequestedByUserId') IS NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] ADD [ProtectedWorkflowsRelaxRequestedByUserId] nvarchar(128) NULL; +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsRelaxRequestedOn') IS NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] ADD [ProtectedWorkflowsRelaxRequestedOn] datetime2 NULL;"); + + if (!Schema.Table("WorkflowProtectedReleases").Exists()) + Create.Table("WorkflowProtectedReleases") + .WithColumn("WorkflowProtectedReleaseId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("WorkflowId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SuspendedReason").AsString(64).Nullable() + .WithColumn("AllowedFieldIds").AsString(int.MaxValue).Nullable() + .WithColumn("DestinationScheme").AsString(8).Nullable() + .WithColumn("DestinationHost").AsString(255).Nullable() + .WithColumn("TokenHost").AsString(255).Nullable() + .WithColumn("WorkflowCredentialId").AsString(128).Nullable() + .WithColumn("AuthMethod").AsString(32).Nullable() + .WithColumn("AllowsRestricted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("RestrictedAckVersion").AsString(64).Nullable() + .WithColumn("RestrictedAckByUserId").AsString(128).Nullable() + .WithColumn("AllowsPart2").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Part2AckVersion").AsString(64).Nullable() + .WithColumn("Part2AckByUserId").AsString(128).Nullable() + .WithColumn("ConfigFingerprint").AsString(64).Nullable() + .WithColumn("RecipientType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RecipientName").AsString(200).Nullable() + .WithColumn("Purpose").AsString(500).Nullable() + .WithColumn("AckVersion").AsString(64).Nullable() + .WithColumn("RequestedByUserId").AsString(128).Nullable() + .WithColumn("RequestedOn").AsDateTime2().Nullable() + .WithColumn("ApprovedByUserId").AsString(128).Nullable() + .WithColumn("ApprovedOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("ExpiryNoticeSentDays").AsInt32().Nullable() + .WithColumn("RevokedByUserId").AsString(128).Nullable() + .WithColumn("RevokedOn").AsDateTime2().Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("UpdatedOn").AsDateTime2().Nullable() + .WithColumn("Version").AsInt32().NotNullable().WithDefaultValue(1); + + // One current (non-revoked) release per workflow; revoked rows are history. + Execute.Sql(SqlServerOnlineIndex.Create("UX_WorkflowProtectedReleases_Workflow_Current", + "WorkflowProtectedReleases", new[] { "[WorkflowId] ASC" }, unique: true, filter: "[State] <> 5")); + Execute.Sql(SqlServerOnlineIndex.Create("IX_WorkflowProtectedReleases_Workflow_Created", + "WorkflowProtectedReleases", new[] { "[WorkflowId] ASC", "[CreatedOn] DESC" })); + Execute.Sql(SqlServerOnlineIndex.Create("IX_WorkflowProtectedReleases_DepartmentId", + "WorkflowProtectedReleases", new[] { "[DepartmentId] ASC" })); + Execute.Sql(SqlServerOnlineIndex.Create("IX_WorkflowProtectedReleases_State", + "WorkflowProtectedReleases", new[] { "[State] ASC" })); + Execute.Sql(SqlServerOnlineIndex.Create("IX_WorkflowProtectedReleases_WorkflowCredentialId", + "WorkflowProtectedReleases", new[] { "[WorkflowCredentialId] ASC" })); + + if (!Schema.Table("ProtectedWorkflowDisclosures").Exists()) + Create.Table("ProtectedWorkflowDisclosures") + .WithColumn("ProtectedWorkflowDisclosureId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ChainSequence").AsInt64().NotNullable() + .WithColumn("RecordType").AsString(32).NotNullable() + .WithColumn("EventType").AsString(64).Nullable() + .WithColumn("ActorUserId").AsString(128).Nullable() + .WithColumn("WorkflowId").AsString(128).Nullable() + .WithColumn("WorkflowRunId").AsString(128).Nullable() + .WithColumn("WorkflowStepId").AsString(128).Nullable() + .WithColumn("WorkflowProtectedReleaseId").AsString(128).Nullable() + .WithColumn("EntityType").AsString(32).Nullable() + .WithColumn("EntityId").AsString(64).Nullable() + .WithColumn("FieldIds").AsString(int.MaxValue).Nullable() + .WithColumn("DestinationHost").AsString(255).Nullable() + .WithColumn("PayloadSha256").AsString(64).Nullable() + .WithColumn("PayloadBytes").AsInt32().Nullable() + .WithColumn("HttpStatus").AsInt32().Nullable() + .WithColumn("Outcome").AsString(32).Nullable() + .WithColumn("IsTest").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("BrokerRequestId").AsString(64).Nullable() + .WithColumn("Detail").AsString(500).Nullable() + .WithColumn("ContentType").AsString(64).Nullable() + .WithColumn("CapturedKeys").AsString(1000).Nullable() + .WithColumn("OccurredOn").AsDateTime2().NotNullable() + .WithColumn("PrevHash").AsString(64).NotNullable() + .WithColumn("Hash").AsString(64).NotNullable(); + + // The chain position is unique per department: a concurrent append loses the race and re-links. + Execute.Sql(SqlServerOnlineIndex.Create("UX_ProtectedWorkflowDisclosures_Department_Sequence", + "ProtectedWorkflowDisclosures", new[] { "[DepartmentId] ASC", "[ChainSequence] ASC" }, unique: true)); + Execute.Sql(SqlServerOnlineIndex.Create("IX_ProtectedWorkflowDisclosures_Department_Workflow", + "ProtectedWorkflowDisclosures", new[] { "[DepartmentId] ASC", "[WorkflowId] ASC", "[OccurredOn] DESC" })); + Execute.Sql(SqlServerOnlineIndex.Create("IX_ProtectedWorkflowDisclosures_Department_Entity", + "ProtectedWorkflowDisclosures", new[] { "[DepartmentId] ASC", "[EntityId] ASC" })); + } + + public override void Down() + { + // Down drops the disclosure chain, which is audit data; never run it against a department that has used + // Protected Workflows unless that audit trail has been exported and retained elsewhere. + if (Schema.Table("ProtectedWorkflowDisclosures").Exists()) + Delete.Table("ProtectedWorkflowDisclosures"); + if (Schema.Table("WorkflowProtectedReleases").Exists()) + Delete.Table("WorkflowProtectedReleases"); + + Execute.Sql(@" +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsRelaxRequestedOn') IS NOT NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] DROP COLUMN [ProtectedWorkflowsRelaxRequestedOn]; +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsRelaxRequestedByUserId') IS NOT NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] DROP COLUMN [ProtectedWorkflowsRelaxRequestedByUserId]; +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsRequireSecondApprover') IS NOT NULL +BEGIN + ALTER TABLE [DepartmentProtectedDataEgressPolicies] DROP CONSTRAINT [DF_DepartmentProtectedDataEgressPolicies_ProtectedWorkflowsRequireSecondApprover]; + ALTER TABLE [DepartmentProtectedDataEgressPolicies] DROP COLUMN [ProtectedWorkflowsRequireSecondApprover]; +END +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsAckOn') IS NOT NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] DROP COLUMN [ProtectedWorkflowsAckOn]; +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsAckByUserId') IS NOT NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] DROP COLUMN [ProtectedWorkflowsAckByUserId]; +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsAckVersion') IS NOT NULL + ALTER TABLE [DepartmentProtectedDataEgressPolicies] DROP COLUMN [ProtectedWorkflowsAckVersion]; +IF COL_LENGTH('DepartmentProtectedDataEgressPolicies', 'ProtectedWorkflowsEnabled') IS NOT NULL +BEGIN + ALTER TABLE [DepartmentProtectedDataEgressPolicies] DROP CONSTRAINT [DF_DepartmentProtectedDataEgressPolicies_ProtectedWorkflowsEnabled]; + ALTER TABLE [DepartmentProtectedDataEgressPolicies] DROP COLUMN [ProtectedWorkflowsEnabled]; +END"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0233_AddProtectedWorkflowsEhr.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0233_AddProtectedWorkflowsEhr.cs new file mode 100644 index 000000000..1b2542364 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0233_AddProtectedWorkflowsEhr.cs @@ -0,0 +1,47 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Protected Workflows for EHR integration. Calls.SubjectIdentifiers holds external subject and record identifiers + /// as a JSON object (protected: ADP catalog 29, calls.subjectidentifiers); Calls.Part2ConsentOnFile is the structural + /// 42 CFR Part 2 consent flag. UdfFields.Sensitivity tags a custom field none (0), restricted (1) or Part 2 (2) for + /// release. WorkflowCredentials.PublicJwks carries the PUBLIC keys of a private_key_jwt credential (its JWKS). + /// Every column is nullable or has a constant default, so the adds are metadata-only. + /// + [Migration(233)] + public class M0233_AddProtectedWorkflowsEhr : Migration + { + public override void Up() + { + Execute.Sql(@" +IF COL_LENGTH('Calls', 'SubjectIdentifiers') IS NULL + ALTER TABLE [Calls] ADD [SubjectIdentifiers] nvarchar(max) NULL; +IF COL_LENGTH('Calls', 'Part2ConsentOnFile') IS NULL + ALTER TABLE [Calls] ADD [Part2ConsentOnFile] bit NOT NULL CONSTRAINT [DF_Calls_Part2ConsentOnFile] DEFAULT (0); +IF COL_LENGTH('UdfFields', 'Sensitivity') IS NULL + ALTER TABLE [UdfFields] ADD [Sensitivity] int NOT NULL CONSTRAINT [DF_UdfFields_Sensitivity] DEFAULT (0); +IF COL_LENGTH('WorkflowCredentials', 'PublicJwks') IS NULL + ALTER TABLE [WorkflowCredentials] ADD [PublicJwks] nvarchar(max) NULL;"); + } + + public override void Down() + { + Execute.Sql(@" +IF COL_LENGTH('WorkflowCredentials', 'PublicJwks') IS NOT NULL + ALTER TABLE [WorkflowCredentials] DROP COLUMN [PublicJwks]; +IF COL_LENGTH('UdfFields', 'Sensitivity') IS NOT NULL +BEGIN + ALTER TABLE [UdfFields] DROP CONSTRAINT [DF_UdfFields_Sensitivity]; + ALTER TABLE [UdfFields] DROP COLUMN [Sensitivity]; +END +IF COL_LENGTH('Calls', 'Part2ConsentOnFile') IS NOT NULL +BEGIN + ALTER TABLE [Calls] DROP CONSTRAINT [DF_Calls_Part2ConsentOnFile]; + ALTER TABLE [Calls] DROP COLUMN [Part2ConsentOnFile]; +END +IF COL_LENGTH('Calls', 'SubjectIdentifiers') IS NOT NULL + ALTER TABLE [Calls] DROP COLUMN [SubjectIdentifiers];"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0234_AddShiftApprovals.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0234_AddShiftApprovals.cs new file mode 100644 index 000000000..2ffbbd22e --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0234_AddShiftApprovals.cs @@ -0,0 +1,43 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Supervisor approval and per-day roster edits for shifts. ShiftSignups gains ApprovalPending (a signup on a + /// RequireApproval shift waits for a supervisor), AssignedByUserId (a supervisor placed the person on that day) and + /// review audit columns. ShiftSignupTrades gains ApprovalPending (the requester picked an offer and a supervisor must + /// approve the swap) and the same review audit columns. Existing rows default to not pending, which matches how they + /// behaved before this migration. + /// + [Migration(234)] + public class M0234_AddShiftApprovals : Migration + { + public override void Up() + { + Execute.Sql("IF COL_LENGTH('ShiftSignups', 'ApprovalPending') IS NULL ALTER TABLE [ShiftSignups] ADD [ApprovalPending] bit NOT NULL CONSTRAINT [DF_ShiftSignups_ApprovalPending] DEFAULT(0);"); + Execute.Sql("IF COL_LENGTH('ShiftSignups', 'AssignedByUserId') IS NULL ALTER TABLE [ShiftSignups] ADD [AssignedByUserId] nvarchar(128) NULL;"); + Execute.Sql("IF COL_LENGTH('ShiftSignups', 'ReviewedByUserId') IS NULL ALTER TABLE [ShiftSignups] ADD [ReviewedByUserId] nvarchar(128) NULL;"); + Execute.Sql("IF COL_LENGTH('ShiftSignups', 'ReviewedOn') IS NULL ALTER TABLE [ShiftSignups] ADD [ReviewedOn] datetime NULL;"); + Execute.Sql("IF COL_LENGTH('ShiftSignups', 'ReviewNote') IS NULL ALTER TABLE [ShiftSignups] ADD [ReviewNote] nvarchar(max) NULL;"); + + Execute.Sql("IF COL_LENGTH('ShiftSignupTrades', 'ApprovalPending') IS NULL ALTER TABLE [ShiftSignupTrades] ADD [ApprovalPending] bit NOT NULL CONSTRAINT [DF_ShiftSignupTrades_ApprovalPending] DEFAULT(0);"); + Execute.Sql("IF COL_LENGTH('ShiftSignupTrades', 'ReviewedByUserId') IS NULL ALTER TABLE [ShiftSignupTrades] ADD [ReviewedByUserId] nvarchar(128) NULL;"); + Execute.Sql("IF COL_LENGTH('ShiftSignupTrades', 'ReviewedOn') IS NULL ALTER TABLE [ShiftSignupTrades] ADD [ReviewedOn] datetime NULL;"); + Execute.Sql("IF COL_LENGTH('ShiftSignupTrades', 'ReviewNote') IS NULL ALTER TABLE [ShiftSignupTrades] ADD [ReviewNote] nvarchar(max) NULL;"); + } + + public override void Down() + { + Execute.Sql("IF COL_LENGTH('ShiftSignupTrades', 'ReviewNote') IS NOT NULL ALTER TABLE [ShiftSignupTrades] DROP COLUMN [ReviewNote];"); + Execute.Sql("IF COL_LENGTH('ShiftSignupTrades', 'ReviewedOn') IS NOT NULL ALTER TABLE [ShiftSignupTrades] DROP COLUMN [ReviewedOn];"); + Execute.Sql("IF COL_LENGTH('ShiftSignupTrades', 'ReviewedByUserId') IS NOT NULL ALTER TABLE [ShiftSignupTrades] DROP COLUMN [ReviewedByUserId];"); + Execute.Sql("IF COL_LENGTH('ShiftSignupTrades', 'ApprovalPending') IS NOT NULL BEGIN ALTER TABLE [ShiftSignupTrades] DROP CONSTRAINT [DF_ShiftSignupTrades_ApprovalPending]; ALTER TABLE [ShiftSignupTrades] DROP COLUMN [ApprovalPending]; END"); + + Execute.Sql("IF COL_LENGTH('ShiftSignups', 'ReviewNote') IS NOT NULL ALTER TABLE [ShiftSignups] DROP COLUMN [ReviewNote];"); + Execute.Sql("IF COL_LENGTH('ShiftSignups', 'ReviewedOn') IS NOT NULL ALTER TABLE [ShiftSignups] DROP COLUMN [ReviewedOn];"); + Execute.Sql("IF COL_LENGTH('ShiftSignups', 'ReviewedByUserId') IS NOT NULL ALTER TABLE [ShiftSignups] DROP COLUMN [ReviewedByUserId];"); + Execute.Sql("IF COL_LENGTH('ShiftSignups', 'AssignedByUserId') IS NOT NULL ALTER TABLE [ShiftSignups] DROP COLUMN [AssignedByUserId];"); + Execute.Sql("IF COL_LENGTH('ShiftSignups', 'ApprovalPending') IS NOT NULL BEGIN ALTER TABLE [ShiftSignups] DROP CONSTRAINT [DF_ShiftSignups_ApprovalPending]; ALTER TABLE [ShiftSignups] DROP COLUMN [ApprovalPending]; END"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0232_AddProtectedWorkflowsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0232_AddProtectedWorkflowsPg.cs new file mode 100644 index 000000000..7fdaf8ea2 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0232_AddProtectedWorkflowsPg.cs @@ -0,0 +1,121 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// ADP Protected Workflows (see the SQL Server M0232): the department opt-in on the egress policy, per-workflow + /// protected releases and the append-only, per-department hash-chained disclosure log. Ships inert. workflowid on + /// workflowprotectedreleases is deliberately not a foreign key (a deleted workflow's release is revoked and kept). + /// Both tables are new and empty, so plain existence-guarded index builds are used. + /// + [Migration(232, TransactionBehavior.None)] + public class M0232_AddProtectedWorkflowsPg : Migration + { + public override void Up() + { + Execute.Sql(@" +ALTER TABLE departmentprotecteddataegresspolicies ADD COLUMN IF NOT EXISTS protectedworkflowsenabled boolean NOT NULL DEFAULT false; +ALTER TABLE departmentprotecteddataegresspolicies ADD COLUMN IF NOT EXISTS protectedworkflowsackversion citext NULL; +ALTER TABLE departmentprotecteddataegresspolicies ADD COLUMN IF NOT EXISTS protectedworkflowsackbyuserid citext NULL; +ALTER TABLE departmentprotecteddataegresspolicies ADD COLUMN IF NOT EXISTS protectedworkflowsackon timestamp NULL; +ALTER TABLE departmentprotecteddataegresspolicies ADD COLUMN IF NOT EXISTS protectedworkflowsrequiresecondapprover boolean NOT NULL DEFAULT false; +ALTER TABLE departmentprotecteddataegresspolicies ADD COLUMN IF NOT EXISTS protectedworkflowsrelaxrequestedbyuserid citext NULL; +ALTER TABLE departmentprotecteddataegresspolicies ADD COLUMN IF NOT EXISTS protectedworkflowsrelaxrequestedon timestamp NULL;"); + + if (!Schema.Table("workflowprotectedreleases").Exists()) + Create.Table("workflowprotectedreleases") + .WithColumn("workflowprotectedreleaseid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("workflowid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("suspendedreason").AsCustom("citext").Nullable() + .WithColumn("allowedfieldids").AsCustom("citext").Nullable() + .WithColumn("destinationscheme").AsCustom("citext").Nullable() + .WithColumn("destinationhost").AsCustom("citext").Nullable() + .WithColumn("tokenhost").AsCustom("citext").Nullable() + .WithColumn("workflowcredentialid").AsCustom("citext").Nullable() + .WithColumn("authmethod").AsCustom("citext").Nullable() + .WithColumn("allowsrestricted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("restrictedackversion").AsCustom("citext").Nullable() + .WithColumn("restrictedackbyuserid").AsCustom("citext").Nullable() + .WithColumn("allowspart2").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("part2ackversion").AsCustom("citext").Nullable() + .WithColumn("part2ackbyuserid").AsCustom("citext").Nullable() + .WithColumn("configfingerprint").AsCustom("citext").Nullable() + .WithColumn("recipienttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("recipientname").AsCustom("citext").Nullable() + .WithColumn("purpose").AsCustom("citext").Nullable() + .WithColumn("ackversion").AsCustom("citext").Nullable() + .WithColumn("requestedbyuserid").AsCustom("citext").Nullable() + .WithColumn("requestedon").AsDateTime2().Nullable() + .WithColumn("approvedbyuserid").AsCustom("citext").Nullable() + .WithColumn("approvedon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("expirynoticesentdays").AsInt32().Nullable() + .WithColumn("revokedbyuserid").AsCustom("citext").Nullable() + .WithColumn("revokedon").AsDateTime2().Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("updatedon").AsDateTime2().Nullable() + .WithColumn("version").AsInt32().NotNullable().WithDefaultValue(1); + + // One current (non-revoked) release per workflow; revoked rows are history. + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_workflowprotectedreleases_workflow_current ON workflowprotectedreleases (workflowid) WHERE state <> 5;"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workflowprotectedreleases_workflow_created ON workflowprotectedreleases (workflowid, createdon DESC);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workflowprotectedreleases_departmentid ON workflowprotectedreleases (departmentid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workflowprotectedreleases_state ON workflowprotectedreleases (state);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workflowprotectedreleases_workflowcredentialid ON workflowprotectedreleases (workflowcredentialid);"); + + // Hashed columns use text/varchar rather than citext so the stored bytes are exactly the hashed bytes. + if (!Schema.Table("protectedworkflowdisclosures").Exists()) + Create.Table("protectedworkflowdisclosures") + .WithColumn("protectedworkflowdisclosureid").AsString(128).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("chainsequence").AsInt64().NotNullable() + .WithColumn("recordtype").AsString(32).NotNullable() + .WithColumn("eventtype").AsString(64).Nullable() + .WithColumn("actoruserid").AsString(128).Nullable() + .WithColumn("workflowid").AsString(128).Nullable() + .WithColumn("workflowrunid").AsString(128).Nullable() + .WithColumn("workflowstepid").AsString(128).Nullable() + .WithColumn("workflowprotectedreleaseid").AsString(128).Nullable() + .WithColumn("entitytype").AsString(32).Nullable() + .WithColumn("entityid").AsString(64).Nullable() + .WithColumn("fieldids").AsString(int.MaxValue).Nullable() + .WithColumn("destinationhost").AsString(255).Nullable() + .WithColumn("payloadsha256").AsString(64).Nullable() + .WithColumn("payloadbytes").AsInt32().Nullable() + .WithColumn("httpstatus").AsInt32().Nullable() + .WithColumn("outcome").AsString(32).Nullable() + .WithColumn("istest").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("brokerrequestid").AsString(64).Nullable() + .WithColumn("detail").AsString(500).Nullable() + .WithColumn("contenttype").AsString(64).Nullable() + .WithColumn("capturedkeys").AsString(1000).Nullable() + .WithColumn("occurredon").AsDateTime2().NotNullable() + .WithColumn("prevhash").AsString(64).NotNullable() + .WithColumn("hash").AsString(64).NotNullable(); + + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_protectedworkflowdisclosures_department_sequence ON protectedworkflowdisclosures (departmentid, chainsequence);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_protectedworkflowdisclosures_department_workflow ON protectedworkflowdisclosures (departmentid, workflowid, occurredon DESC);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_protectedworkflowdisclosures_department_entity ON protectedworkflowdisclosures (departmentid, entityid);"); + } + + public override void Down() + { + // Drops the disclosure chain (audit data): export and retain it first. + if (Schema.Table("protectedworkflowdisclosures").Exists()) + Delete.Table("protectedworkflowdisclosures"); + if (Schema.Table("workflowprotectedreleases").Exists()) + Delete.Table("workflowprotectedreleases"); + + Execute.Sql(@" +ALTER TABLE departmentprotecteddataegresspolicies DROP COLUMN IF EXISTS protectedworkflowsrelaxrequestedon; +ALTER TABLE departmentprotecteddataegresspolicies DROP COLUMN IF EXISTS protectedworkflowsrelaxrequestedbyuserid; +ALTER TABLE departmentprotecteddataegresspolicies DROP COLUMN IF EXISTS protectedworkflowsrequiresecondapprover; +ALTER TABLE departmentprotecteddataegresspolicies DROP COLUMN IF EXISTS protectedworkflowsackon; +ALTER TABLE departmentprotecteddataegresspolicies DROP COLUMN IF EXISTS protectedworkflowsackbyuserid; +ALTER TABLE departmentprotecteddataegresspolicies DROP COLUMN IF EXISTS protectedworkflowsackversion; +ALTER TABLE departmentprotecteddataegresspolicies DROP COLUMN IF EXISTS protectedworkflowsenabled;"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0233_AddProtectedWorkflowsEhrPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0233_AddProtectedWorkflowsEhrPg.cs new file mode 100644 index 000000000..b696658bf --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0233_AddProtectedWorkflowsEhrPg.cs @@ -0,0 +1,30 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Protected Workflows for EHR integration (see the SQL Server M0233): calls.subjectidentifiers (protected, catalog 29), + /// calls.part2consentonfile, udffields.sensitivity and workflowcredentials.publicjwks. Constant defaults, so no rewrite. + /// + [Migration(233)] + public class M0233_AddProtectedWorkflowsEhrPg : Migration + { + public override void Up() + { + Execute.Sql(@" +ALTER TABLE calls ADD COLUMN IF NOT EXISTS subjectidentifiers citext NULL; +ALTER TABLE calls ADD COLUMN IF NOT EXISTS part2consentonfile boolean NOT NULL DEFAULT false; +ALTER TABLE udffields ADD COLUMN IF NOT EXISTS sensitivity integer NOT NULL DEFAULT 0; +ALTER TABLE workflowcredentials ADD COLUMN IF NOT EXISTS publicjwks citext NULL;"); + } + + public override void Down() + { + Execute.Sql(@" +ALTER TABLE workflowcredentials DROP COLUMN IF EXISTS publicjwks; +ALTER TABLE udffields DROP COLUMN IF EXISTS sensitivity; +ALTER TABLE calls DROP COLUMN IF EXISTS part2consentonfile; +ALTER TABLE calls DROP COLUMN IF EXISTS subjectidentifiers;"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0234_AddShiftApprovalsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0234_AddShiftApprovalsPg.cs new file mode 100644 index 000000000..c29e02bc6 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0234_AddShiftApprovalsPg.cs @@ -0,0 +1,39 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Supervisor approval and per-day roster edits for shifts (see the SQL Server M0234). + /// + [Migration(234)] + public class M0234_AddShiftApprovalsPg : Migration + { + public override void Up() + { + Execute.Sql("ALTER TABLE shiftsignups ADD COLUMN IF NOT EXISTS approvalpending boolean NOT NULL DEFAULT false;"); + Execute.Sql("ALTER TABLE shiftsignups ADD COLUMN IF NOT EXISTS assignedbyuserid citext NULL;"); + Execute.Sql("ALTER TABLE shiftsignups ADD COLUMN IF NOT EXISTS reviewedbyuserid citext NULL;"); + Execute.Sql("ALTER TABLE shiftsignups ADD COLUMN IF NOT EXISTS reviewedon timestamp without time zone NULL;"); + Execute.Sql("ALTER TABLE shiftsignups ADD COLUMN IF NOT EXISTS reviewnote citext NULL;"); + + Execute.Sql("ALTER TABLE shiftsignuptrades ADD COLUMN IF NOT EXISTS approvalpending boolean NOT NULL DEFAULT false;"); + Execute.Sql("ALTER TABLE shiftsignuptrades ADD COLUMN IF NOT EXISTS reviewedbyuserid citext NULL;"); + Execute.Sql("ALTER TABLE shiftsignuptrades ADD COLUMN IF NOT EXISTS reviewedon timestamp without time zone NULL;"); + Execute.Sql("ALTER TABLE shiftsignuptrades ADD COLUMN IF NOT EXISTS reviewnote citext NULL;"); + } + + public override void Down() + { + Execute.Sql("ALTER TABLE shiftsignuptrades DROP COLUMN IF EXISTS reviewnote;"); + Execute.Sql("ALTER TABLE shiftsignuptrades DROP COLUMN IF EXISTS reviewedon;"); + Execute.Sql("ALTER TABLE shiftsignuptrades DROP COLUMN IF EXISTS reviewedbyuserid;"); + Execute.Sql("ALTER TABLE shiftsignuptrades DROP COLUMN IF EXISTS approvalpending;"); + + Execute.Sql("ALTER TABLE shiftsignups DROP COLUMN IF EXISTS reviewnote;"); + Execute.Sql("ALTER TABLE shiftsignups DROP COLUMN IF EXISTS reviewedon;"); + Execute.Sql("ALTER TABLE shiftsignups DROP COLUMN IF EXISTS reviewedbyuserid;"); + Execute.Sql("ALTER TABLE shiftsignups DROP COLUMN IF EXISTS assignedbyuserid;"); + Execute.Sql("ALTER TABLE shiftsignups DROP COLUMN IF EXISTS approvalpending;"); + } + } +} diff --git a/Providers/Resgrid.Providers.Workflow/Executors/HttpApiExecutor.cs b/Providers/Resgrid.Providers.Workflow/Executors/HttpApiExecutor.cs index d40148cff..2d9480465 100644 --- a/Providers/Resgrid.Providers.Workflow/Executors/HttpApiExecutor.cs +++ b/Providers/Resgrid.Providers.Workflow/Executors/HttpApiExecutor.cs @@ -1,10 +1,20 @@ -using System; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Config; using Resgrid.Model; using Resgrid.Model.Providers; @@ -12,10 +22,34 @@ namespace Resgrid.Providers.Workflow.Executors { public class HttpApiExecutor : IWorkflowActionExecutor { + /// OAuth2 tokens by a hash of (token URL, client id, secret, scope, audience); reused until 60 seconds before expiry. + private static readonly ConcurrentDictionary TokenCache = new ConcurrentDictionary(StringComparer.Ordinal); + private static readonly TimeSpan TokenRefreshMargin = TimeSpan.FromSeconds(60); + + /// Headers a protected step's action config may never set: they would redirect or re-authenticate the request. + private static readonly HashSet ProtectedForbiddenHeaders = ProtectedStepOptions.ForbiddenHeaders; + + private readonly Func _handlerFactory; + private readonly Func> _urlValidator; + + public HttpApiExecutor() : this(null, null) + { + } + + /// Test seam: a handler factory (the flag is true for protected mode) and a URL validator in place of the SSRF guard. + internal HttpApiExecutor(Func handlerFactory, Func> urlValidator) + { + _handlerFactory = handlerFactory ?? CreateHandler; + _urlValidator = urlValidator ?? (url => SsrfGuard.ValidateUrlAsync(url, requireHttps: true)); + } + public WorkflowActionType ActionType => WorkflowActionType.CallApiPost; public async Task ExecuteAsync(WorkflowActionContext context, CancellationToken cancellationToken) { + if (context.ProtectedMode) + return await ExecuteProtectedAsync(context, cancellationToken); + try { var config = string.IsNullOrWhiteSpace(context.ActionConfigJson) @@ -29,18 +63,17 @@ public async Task ExecuteAsync(WorkflowActionContext conte return WorkflowActionResult.Failed("HTTP request failed.", $"The configured URL '{config.Url}' is not a valid absolute URI."); // ── SSRF protection ────────────────────────────────────────────────── - var (ssrfAllowed, ssrfReason) = await SsrfGuard.ValidateUrlAsync(config.Url, requireHttps: true); + var (ssrfAllowed, ssrfReason) = await _urlValidator(config.Url); if (!ssrfAllowed) return WorkflowActionResult.Failed("HTTP request blocked.", ssrfReason); // ── End SSRF protection ────────────────────────────────────────────── - var cred = string.IsNullOrWhiteSpace(context.DecryptedCredentialJson) - ? null - : JsonConvert.DeserializeObject(context.DecryptedCredentialJson); + var cred = ReadCredential(context); + var authType = ResolveAuthType(cred, context.CredentialType); if (cred != null) { - switch (cred.AuthType?.ToLowerInvariant()) + switch (authType) { case "bearer" when string.IsNullOrWhiteSpace(cred.Token): return WorkflowActionResult.Failed("HTTP request failed.", "Auth type is 'bearer' but no token is set in the credential. Please update the credential with a valid token."); @@ -48,17 +81,44 @@ public async Task ExecuteAsync(WorkflowActionContext conte return WorkflowActionResult.Failed("HTTP request failed.", "Auth type is 'basic' but no username is set in the credential."); case "apikey" when string.IsNullOrWhiteSpace(cred.ApiKey): return WorkflowActionResult.Failed("HTTP request failed.", "Auth type is 'apikey' but no API key is set in the credential."); + case "oauth2" when string.IsNullOrWhiteSpace(cred.TokenUrl) || string.IsNullOrWhiteSpace(cred.ClientId) || + (cred.IsPrivateKeyJwt ? WorkflowJwtKeys.Current(cred.SigningKeys) == null : string.IsNullOrWhiteSpace(cred.ClientSecret)): + return WorkflowActionResult.Failed("HTTP request failed.", "Auth type is 'oauth2' but the credential is missing its token URL, client id, or client secret / signing key."); } } - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(config.TimeoutSeconds > 0 ? config.TimeoutSeconds : 30) }; + // OAuth2 uses the no-redirect handler: a 307/308 from a token endpoint would otherwise re-send the client secret + // to a host the SSRF guard never checked. + using var client = new HttpClient(_handlerFactory(authType == "oauth2")) { Timeout = TimeSpan.FromSeconds(config.TimeoutSeconds > 0 ? config.TimeoutSeconds : 30) }; - ApplyAuth(client, cred); + if (authType == "oauth2") + { + if (!Uri.TryCreate(cred.TokenUrl, UriKind.Absolute, out var tokenUri) || tokenUri.Scheme != Uri.UriSchemeHttps) + return WorkflowActionResult.Failed("HTTP request failed.", "The OAuth2 token URL must be an absolute https URL."); + var (tokenAllowed, tokenReason) = await _urlValidator(cred.TokenUrl); + if (!tokenAllowed) + return WorkflowActionResult.Failed("HTTP request blocked.", tokenReason); + + var token = await GetOAuthTokenAsync(client, cred, cancellationToken); + if (token.AccessToken == null) + return WorkflowActionResult.Failed("OAuth2 token request failed.", token.Status.HasValue ? $"The token endpoint returned HTTP {token.Status}." : "The token endpoint could not be reached."); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + } + else + { + ApplyAuth(client.DefaultRequestHeaders, cred, authType); + } if (config.Headers != null) foreach (var h in config.Headers) client.DefaultRequestHeaders.TryAddWithoutValidation(h.Key, h.Value); + if (!string.IsNullOrWhiteSpace(config.IdempotencyHeader) && !string.IsNullOrWhiteSpace(context.IdempotencyKey) && + !ProtectedStepOptions.ForbiddenHeaders.Contains(config.IdempotencyHeader)) + client.DefaultRequestHeaders.TryAddWithoutValidation(config.IdempotencyHeader, context.IdempotencyKey); + if (!string.IsNullOrWhiteSpace(config.IfNoneExist) && config.IfNoneExist.IndexOfAny(new[] { '\r', '\n' }) < 0) + client.DefaultRequestHeaders.TryAddWithoutValidation("If-None-Exist", config.IfNoneExist); + var method = (WorkflowActionType)context.ActionType switch { WorkflowActionType.CallApiGet => HttpMethod.Get, @@ -94,23 +154,445 @@ public async Task ExecuteAsync(WorkflowActionContext conte } } - private static void ApplyAuth(HttpClient client, HttpCredential cred) + /// + /// A Protected Workflow send. Refuses anything but POST/PUT to the pinned https host (re-checked on the URL as + /// rendered), never follows a redirect (a 3xx is blocked_host), requires TLS 1.2 or later, applies the protected + /// timeout, and reports only the status code and its standard reason phrase. The response body is never read: + /// endpoints can echo the payload back. Nothing here logs, and error details are fixed codes plus exception types. + /// + private async Task ExecuteProtectedAsync(WorkflowActionContext context, CancellationToken cancellationToken) + { + var body = Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); + var sha256 = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant(); + + WorkflowActionResult Refuse(string outcome, string code, int? status = null) => new WorkflowActionResult + { + Success = false, + ResultMessage = status.HasValue ? StatusLine(status.Value) : "Protected request refused.", + ErrorDetail = code, + HttpStatus = status, + ProtectedOutcome = outcome + }; + + var actionType = (WorkflowActionType)context.ActionType; + if (actionType != WorkflowActionType.CallApiPost && actionType != WorkflowActionType.CallApiPut) + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.ActionNotAllowed); + + HttpActionConfig config; + try + { + config = string.IsNullOrWhiteSpace(context.ActionConfigJson) + ? new HttpActionConfig() + : JsonConvert.DeserializeObject(context.ActionConfigJson) ?? new HttpActionConfig(); + } + catch (JsonException) + { + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.HostMismatch); + } + + // The host is checked again here, on the URL exactly as it will be requested. + if (!ProtectedWorkflowValidator.TryGetRenderedHttpsHost(config.Url, out var host, out var urlError)) + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedHost, + urlError == ProtectedWorkflowValidator.SchemeNotHttps ? ProtectedWorkflowErrorCodes.SchemeNotHttps : ProtectedWorkflowErrorCodes.HostMismatch); + if (string.IsNullOrWhiteSpace(context.PinnedHost) || + !string.Equals(host, ProtectedWorkflowFingerprint.NormalizeHost(context.PinnedHost), StringComparison.Ordinal)) + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.HostMismatch); + + var (allowed, _) = await _urlValidator(config.Url); + if (!allowed) + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.HostMismatch); + + var cred = ReadCredential(context); + var authType = ResolveAuthType(cred, context.CredentialType); + if (cred == null || authType == null || + (authType == "basic" && !DataProtectionConfig.ProtectedWorkflowAllowHttpBasicCredentials)) + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.CredentialNotAllowed); + + // OAuth2 authenticates exactly the way the release pinned (client secret or a signed private_key_jwt assertion). + if (authType == "oauth2" && !string.Equals(WorkflowJwtKeys.NormalizeAuthMethod(cred.AuthMethod), context.PinnedAuthMethod, StringComparison.Ordinal)) + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.AuthMethodMismatch); + + // Content type, success rule, capture and idempotency (validated before approval; re-read on the rendered config). + var options = ProtectedStepOptions.Read(context.ActionConfigJson, out var optionErrors, DataProtectionConfig.ProtectedWorkflowMaxCaptureKeys); + if (optionErrors.Count > 0 || !ProtectedStepOptions.IsAllowedContentType(options.ContentType)) + return Refuse(ProtectedWorkflowDisclosureOutcomes.FailedValidation, $"{ProtectedWorkflowErrorCodes.PayloadInvalid}: rule={optionErrors.FirstOrDefault() ?? ProtectedStepOptions.ContentTypeNotAllowed}"); + + try + { + using var client = new HttpClient(_handlerFactory(true)) + { + Timeout = TimeSpan.FromSeconds(Math.Max(1, DataProtectionConfig.ProtectedWorkflowHttpTimeoutSeconds)) + }; + + string bearer = null; + if (authType == "oauth2") + { + // The token endpoint is pinned too: the client secret goes nowhere but the approved host. + if (!ProtectedWorkflowValidator.TryGetRenderedHttpsHost(cred.TokenUrl, out var tokenHost, out _) || + string.IsNullOrWhiteSpace(context.PinnedTokenHost) || + !string.Equals(tokenHost, ProtectedWorkflowFingerprint.NormalizeHost(context.PinnedTokenHost), StringComparison.Ordinal)) + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.TokenHostMismatch); + + var (tokenAllowed, _) = await _urlValidator(cred.TokenUrl); + if (!tokenAllowed) + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.TokenHostMismatch); + + if (cred.IsPrivateKeyJwt && WorkflowJwtKeys.Current(cred.SigningKeys) == null) + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.SigningKeyUnavailable); + + var token = await GetOAuthTokenAsync(client, cred, cancellationToken); + if (token.Redirected) + return Refuse(ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.Redirected, token.Status); + if (token.AccessToken == null) + return Refuse(ProtectedWorkflowDisclosureOutcomes.FailedHttp, ProtectedWorkflowErrorCodes.OAuthTokenFailed, token.Status); + bearer = token.AccessToken; + } + + using var request = new HttpRequestMessage(actionType == WorkflowActionType.CallApiPut ? HttpMethod.Put : HttpMethod.Post, config.Url); + request.Content = new ByteArrayContent(body); + request.Content.Headers.ContentType = ParseContentType(options.ContentType); + + if (config.Headers != null) + foreach (var h in config.Headers) + if (!ProtectedForbiddenHeaders.Contains(h.Key ?? string.Empty) && + !string.Equals(h.Key, options.IdempotencyHeader, StringComparison.OrdinalIgnoreCase)) + request.Headers.TryAddWithoutValidation(h.Key, h.Value); + + // The delivery's idempotency key and a FHIR conditional create: a retried delivery is recognized, not duplicated. + if (options.IdempotencyHeader != null && !string.IsNullOrWhiteSpace(context.IdempotencyKey)) + request.Headers.TryAddWithoutValidation(options.IdempotencyHeader, context.IdempotencyKey); + if (options.IfNoneExist != null) + request.Headers.TryAddWithoutValidation("If-None-Exist", options.IfNoneExist); + + if (bearer != null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + else + ApplyAuth(request.Headers, cred, authType); + + // Headers first. The body is read only when a success rule or a capture needs it, capped, and never logged + // (an endpoint can echo the payload back). + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + var status = (int)response.StatusCode; + + if (status >= 300 && status < 400) + return new WorkflowActionResult + { + Success = false, + ResultMessage = StatusLine(status), + ErrorDetail = ProtectedWorkflowErrorCodes.Redirected, + HttpStatus = status, + PayloadSha256 = sha256, + PayloadBytes = body.Length, + ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.BlockedHost + }; + + string responseBody = null; + if (options.NeedsResponseBody) + { + var read = await ReadCappedAsync(response, Math.Max(1, DataProtectionConfig.ProtectedWorkflowMaxResponseBytes), cancellationToken); + if (read.TooLarge) + return new WorkflowActionResult + { + Success = false, + ResultMessage = StatusLine(status), + ErrorDetail = ProtectedWorkflowErrorCodes.ResponseTooLarge, + HttpStatus = status, + PayloadSha256 = sha256, + PayloadBytes = body.Length, + ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.FailedResponseTooLarge + }; + responseBody = read.Body; + } + + var evaluation = ProtectedResponseRules.Evaluate(options, status, responseBody, name => HeaderValue(response, name)); + responseBody = null; + + if (evaluation.Accepted) + return new WorkflowActionResult + { + Success = true, + ResultMessage = evaluation.Missing.Count > 0 + ? $"{StatusLine(status)} capture_missing=[{string.Join(",", evaluation.Missing)}]" + : StatusLine(status), + HttpStatus = status, + PayloadSha256 = sha256, + PayloadBytes = body.Length, + CapturedValues = evaluation.Captured.Count > 0 ? evaluation.Captured : null + }; + + return new WorkflowActionResult + { + Success = false, + ResultMessage = StatusLine(status), + ErrorDetail = evaluation.RuleRejected + ? $"{ProtectedWorkflowErrorCodes.AckRejected}: {evaluation.Detail}" + : $"{ProtectedWorkflowErrorCodes.HttpFailed}: HTTP {status}", + HttpStatus = status, + PayloadSha256 = sha256, + PayloadBytes = body.Length, + ProtectedOutcome = evaluation.RuleRejected ? ProtectedWorkflowDisclosureOutcomes.FailedAck : ProtectedWorkflowDisclosureOutcomes.FailedHttp + }; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new WorkflowActionResult + { + Success = false, + ResultMessage = "Request timed out.", + ErrorDetail = ProtectedWorkflowErrorCodes.HttpTimeout, + PayloadSha256 = sha256, + PayloadBytes = body.Length, + ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.FailedHttp + }; + } + catch (Exception ex) when (ex is HttpRequestException || ex is AuthenticationException || ex is System.IO.IOException) + { + // No exception message: nothing guarantees it cannot quote the request. The type plus the transport's own + // value-free error enum is enough to tell DNS from TLS from a reset connection. + return new WorkflowActionResult + { + Success = false, + ResultMessage = "HTTP request failed.", + ErrorDetail = TransportError(ex), + PayloadSha256 = sha256, + PayloadBytes = body.Length, + ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.FailedHttp + }; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + return new WorkflowActionResult + { + Success = false, + ResultMessage = "HTTP request failed.", + ErrorDetail = ProtectedWorkflowLogText.Error(ProtectedWorkflowErrorCodes.HttpFailed, ex), + PayloadSha256 = sha256, + PayloadBytes = body.Length, + ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.FailedHttp + }; + } + } + + /// The response body as text, or TooLarge once it passes (the declared length is checked first). + private static async Task<(string Body, bool TooLarge)> ReadCappedAsync(HttpResponseMessage response, int maxBytes, CancellationToken cancellationToken) + { + if (response.Content == null) + return (string.Empty, false); + if (response.Content.Headers.ContentLength.HasValue && response.Content.Headers.ContentLength.Value > maxBytes) + return (null, true); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var buffer = new byte[Math.Min(maxBytes + 1, 81920)]; + using var collected = new System.IO.MemoryStream(); + int read; + while ((read = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) + { + collected.Write(buffer, 0, read); + if (collected.Length > maxBytes) + return (null, true); + } + + var charset = response.Content.Headers.ContentType?.CharSet; + Encoding encoding; + try { encoding = string.IsNullOrWhiteSpace(charset) ? Encoding.UTF8 : Encoding.GetEncoding(charset.Trim('"')); } + catch (ArgumentException) { encoding = Encoding.UTF8; } + return (encoding.GetString(collected.GetBuffer(), 0, (int)collected.Length), false); + } + + private static string HeaderValue(HttpResponseMessage response, string name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + if (response.Headers.TryGetValues(name, out var values)) + return values.FirstOrDefault(); + if (response.Content != null && response.Content.Headers.TryGetValues(name, out var contentValues)) + return contentValues.FirstOrDefault(); + return null; + } + + private static string TransportError(Exception ex) + { + var detail = ProtectedWorkflowLogText.Error(ProtectedWorkflowErrorCodes.HttpFailed, ex); + if (ex is HttpRequestException requestException) + detail += $" ({requestException.HttpRequestError})"; + if (ex.InnerException is System.Net.Sockets.SocketException socketException) + detail += $" ({socketException.SocketErrorCode})"; + return detail; + } + + /// "HTTP 204 No Content" from the status code alone — never the server-supplied reason text. + public static string StatusLine(int status) + { + var name = Enum.IsDefined(typeof(HttpStatusCode), status) ? ((HttpStatusCode)status).ToString() : null; + return name == null ? $"HTTP {status}" : $"HTTP {status} {Regex.Replace(name, "(?<=[a-z])(?=[A-Z])", " ")}"; + } + + private static MediaTypeHeaderValue ParseContentType(string contentType) + { + if (string.IsNullOrWhiteSpace(contentType) || !MediaTypeHeaderValue.TryParse(contentType, out var parsed)) + parsed = new MediaTypeHeaderValue("application/json"); + if (string.IsNullOrWhiteSpace(parsed.CharSet)) + parsed.CharSet = "utf-8"; + return parsed; + } + + internal static HttpMessageHandler CreateHandler(bool protectedMode) + { + if (!protectedMode) + return new HttpClientHandler(); + + return new SocketsHttpHandler + { + AllowAutoRedirect = false, + UseCookies = false, + ConnectTimeout = TimeSpan.FromSeconds(Math.Max(1, DataProtectionConfig.ProtectedWorkflowHttpTimeoutSeconds)), + SslOptions = new SslClientAuthenticationOptions + { + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + } + + private static HttpCredential ReadCredential(WorkflowActionContext context) + { + if (string.IsNullOrWhiteSpace(context.DecryptedCredentialJson)) + return null; + try + { + return JsonConvert.DeserializeObject(context.DecryptedCredentialJson); + } + catch (JsonException) + { + return null; + } + } + + /// + /// The auth scheme: the credential's explicit authType when present, otherwise the credential TYPE — the web + /// credential editor stores only the type's fields ({ token }, { username, password }, { headerName, apiKey }). + /// + public static string ResolveAuthType(HttpCredential cred, int? credentialType) + { + if (cred == null) + return null; + + var explicitType = cred.AuthType?.Trim().ToLowerInvariant(); + if (!string.IsNullOrEmpty(explicitType)) + return explicitType == "oauth2clientcredentials" || explicitType == "client_credentials" ? "oauth2" : explicitType; + + return credentialType switch + { + (int)WorkflowCredentialType.HttpBearer => "bearer", + (int)WorkflowCredentialType.HttpBasic => "basic", + (int)WorkflowCredentialType.HttpApiKey => "apikey", + (int)WorkflowCredentialType.OAuth2ClientCredentials => "oauth2", + _ => null + }; + } + + private static void ApplyAuth(HttpRequestHeaders headers, HttpCredential cred, string authType) { if (cred == null) return; - switch (cred.AuthType?.ToLowerInvariant()) + switch (authType) { case "bearer": - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cred.Token); + headers.Authorization = new AuthenticationHeaderValue("Bearer", cred.Token); break; case "basic": var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{cred.Username}:{cred.Password}")); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", encoded); + headers.Authorization = new AuthenticationHeaderValue("Basic", encoded); break; case "apikey": - client.DefaultRequestHeaders.TryAddWithoutValidation(cred.ApiKeyHeader ?? "X-Api-Key", cred.ApiKey); + headers.TryAddWithoutValidation(cred.ApiKeyHeader ?? cred.HeaderName ?? "X-Api-Key", cred.ApiKey); break; } } + + private sealed class CachedToken + { + public string AccessToken { get; init; } + public DateTime RefreshAfterUtc { get; init; } + } + + private readonly struct TokenResult + { + public TokenResult(string accessToken, int? status, bool redirected) + { + AccessToken = accessToken; + Status = status; + Redirected = redirected; + } + + public string AccessToken { get; } + public int? Status { get; } + public bool Redirected { get; } + } + + /// OAuth2 client credentials grant, cached until 60 seconds before the token expires. The token and secret are never logged. + private static async Task GetOAuthTokenAsync(HttpClient client, HttpCredential cred, CancellationToken cancellationToken) + { + var cacheKey = TokenCacheKey(cred); + if (TokenCache.TryGetValue(cacheKey, out var cached) && cached.RefreshAfterUtc > DateTime.UtcNow) + return new TokenResult(cached.AccessToken, null, false); + + var form = new List> { new KeyValuePair("grant_type", "client_credentials") }; + if (cred.IsPrivateKeyJwt) + { + // SMART Backend Services: a short-lived assertion signed with the credential's current key (kid in the header). + form.Add(new KeyValuePair("client_assertion_type", WorkflowJwtKeys.AssertionType)); + form.Add(new KeyValuePair("client_assertion", + WorkflowJwtKeys.CreateAssertion(WorkflowJwtKeys.Current(cred.SigningKeys), cred.ClientId, cred.TokenUrl, DateTime.UtcNow))); + } + else + { + form.Add(new KeyValuePair("client_id", cred.ClientId)); + form.Add(new KeyValuePair("client_secret", cred.ClientSecret)); + } + if (!string.IsNullOrWhiteSpace(cred.Scope)) + form.Add(new KeyValuePair("scope", cred.Scope)); + if (!string.IsNullOrWhiteSpace(cred.Audience)) + form.Add(new KeyValuePair("audience", cred.Audience)); + + using var request = new HttpRequestMessage(HttpMethod.Post, cred.TokenUrl) { Content = new FormUrlEncodedContent(form) }; + using var response = await client.SendAsync(request, cancellationToken); + var status = (int)response.StatusCode; + if (status >= 300 && status < 400) + return new TokenResult(null, status, true); + if (!response.IsSuccessStatusCode) + return new TokenResult(null, status, false); + + string accessToken; + int expiresIn; + try + { + var json = JObject.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + accessToken = json.Value("access_token"); + expiresIn = json["expires_in"] == null ? 0 : (int)Math.Min(int.MaxValue, json["expires_in"].Value()); + } + catch (Exception) + { + return new TokenResult(null, status, false); + } + + if (string.IsNullOrWhiteSpace(accessToken)) + return new TokenResult(null, status, false); + + var lifetime = TimeSpan.FromSeconds(expiresIn); + if (lifetime > TokenRefreshMargin) + TokenCache[cacheKey] = new CachedToken { AccessToken = accessToken, RefreshAfterUtc = DateTime.UtcNow + lifetime - TokenRefreshMargin }; + + return new TokenResult(accessToken, status, false); + } + + private static string TokenCacheKey(HttpCredential cred) + { + var secret = cred.IsPrivateKeyJwt ? "jwt:" + WorkflowJwtKeys.Current(cred.SigningKeys)?.Kid : cred.ClientSecret; + var material = string.Join("\n", cred.TokenUrl, cred.ClientId, secret, cred.Scope, cred.Audience); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); + } + + /// Test seam: forgets every cached OAuth2 token. + internal static void ClearTokenCache() => TokenCache.Clear(); } public class HttpActionConfig @@ -119,6 +601,12 @@ public class HttpActionConfig public string ContentType { get; set; } public int TimeoutSeconds { get; set; } = 30; public System.Collections.Generic.Dictionary Headers { get; set; } + + /// Header that carries run.idempotency_key (for example Idempotency-Key). + public string IdempotencyHeader { get; set; } + + /// FHIR conditional create: sent as If-None-Exist. + public string IfNoneExist { get; set; } } public class HttpCredential @@ -128,7 +616,26 @@ public class HttpCredential public string Username { get; set; } public string Password { get; set; } public string ApiKeyHeader { get; set; } + + /// The web credential editor's name for . + public string HeaderName { get; set; } + public string ApiKey { get; set; } + + // OAuth2 client credentials + public string TokenUrl { get; set; } + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string Scope { get; set; } + public string Audience { get; set; } + + /// client_secret (default) or private_key_jwt. + public string AuthMethod { get; set; } + + /// private_key_jwt signing keys (inside the encrypted credential only). + public List SigningKeys { get; set; } + + [JsonIgnore] + public bool IsPrivateKeyJwt => WorkflowJwtKeys.NormalizeAuthMethod(AuthMethod) == WorkflowJwtKeys.PrivateKeyJwt; } } - diff --git a/Providers/Resgrid.Providers.Workflow/Executors/ProtectedResponseRules.cs b/Providers/Resgrid.Providers.Workflow/Executors/ProtectedResponseRules.cs new file mode 100644 index 000000000..389b5b55a --- /dev/null +++ b/Providers/Resgrid.Providers.Workflow/Executors/ProtectedResponseRules.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.XPath; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; + +namespace Resgrid.Providers.Workflow.Executors +{ + /// + /// Evaluates a protected step's success rule against the destination's response, and reads the values its + /// ResponseCapture asks for. The body is held only for the duration of this call. Everything reported back is value + /// free — a rule name, an acknowledgement code (AA/AE/AR/CA/CE/CR), a key name — except the captured values themselves, + /// which go to the caller in memory to be encrypted into the call. + /// + public static class ProtectedResponseRules + { + public sealed class Evaluation + { + /// The destination accepted the payload (a 2xx that satisfies the rule). + public bool Accepted { get; init; } + + /// The rule said no (as opposed to a plain HTTP failure): outcome failed_ack. + public bool RuleRejected { get; init; } + + /// Value-free, for the run log and the disclosure: "rule=hl7_ack ack=AE". + public string Detail { get; init; } + + public Dictionary Captured { get; } = new Dictionary(StringComparer.Ordinal); + + /// Capture keys the response did not carry (names only). + public List Missing { get; } = new List(); + } + + private static readonly Regex FhirLocation = new Regex(@"(?:^|/)(?[A-Z][A-Za-z]{0,63})/(?[A-Za-z0-9\-\.]{1,64})(?:/_history/[^/?#]*)?(?:[?#].*)?$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// Looks a response header up by name (null when absent). + public static Evaluation Evaluate(ProtectedStepOptions options, int status, string body, Func header) + { + options ??= new ProtectedStepOptions(); + var is2xx = status >= 200 && status < 300; + var ruleType = options.SuccessRuleType; + var rule = options.SuccessRule; + + if (!is2xx) + { + // A FHIR server that explains its failure in an OperationOutcome is a rejected acknowledgement, not a bare + // HTTP failure; the retry policy still decides from the status (5xx and 429 retry, other 4xx do not). + if (ruleType == ProtectedSuccessRule.FhirOperationOutcome && HasOperationOutcomeError(body)) + return new Evaluation { RuleRejected = true, Detail = $"rule={ruleType} outcome=error status={status}" }; + return new Evaluation { Detail = null }; + } + + string rejection = ruleType switch + { + ProtectedSuccessRule.Http2xx => null, + ProtectedSuccessRule.JsonPath => JsonPathMatches(body, rule?.Path, rule?.Expected) ? null : $"rule={ruleType}", + ProtectedSuccessRule.XPath => XPathMatches(body, rule?.Path, rule?.Expected) ? null : $"rule={ruleType}", + ProtectedSuccessRule.Hl7Ack => Hl7AckRejection(body), + ProtectedSuccessRule.FhirOperationOutcome => HasOperationOutcomeError(body) ? $"rule={ruleType} outcome=error" : null, + _ => $"rule={ruleType}" + }; + + if (rejection != null) + return new Evaluation { RuleRejected = true, Detail = rejection }; + + var evaluation = new Evaluation { Accepted = true }; + foreach (var entry in options.ResponseCapture) + { + var value = Capture(entry, body, header); + value = value?.Trim(); + if (string.IsNullOrEmpty(value) || value.Length > CallSubjectIdentifiers.MaxValueLength || value.Any(char.IsControl)) + evaluation.Missing.Add(entry.Key); + else + evaluation.Captured[entry.Key] = value; + } + + return evaluation; + } + + // ── Rules ───────────────────────────────────────────────────────────────────────────────────── + + private static bool JsonPathMatches(string body, string path, string expected) + { + var token = SelectJson(body, path); + if (token == null || token.Type == JTokenType.Null) + return false; + var text = token is JValue value ? Convert.ToString(value.Value, System.Globalization.CultureInfo.InvariantCulture) : token.ToString(Newtonsoft.Json.Formatting.None); + return expected == null ? !string.IsNullOrEmpty(text) : string.Equals(text, expected, StringComparison.Ordinal); + } + + private static bool XPathMatches(string body, string path, string expected) + { + var text = SelectXml(body, path); + return text != null && (expected == null ? text.Length > 0 : string.Equals(text, expected, StringComparison.Ordinal)); + } + + /// Null when MSA-1 is AA or CA; otherwise "rule=hl7_ack ack=AE" (or ack=missing). + private static string Hl7AckRejection(string body) + { + var code = Hl7Field(body, "MSA", 1, 0); + if (string.Equals(code, "AA", StringComparison.Ordinal) || string.Equals(code, "CA", StringComparison.Ordinal)) + return null; + + var safe = code != null && Regex.IsMatch(code, "^[A-Z]{2}$") ? code : "missing"; + return $"rule={ProtectedSuccessRule.Hl7Ack} ack={safe}"; + } + + /// An OperationOutcome (on its own, or in a transaction/batch response entry) with an error or fatal issue. + private static bool HasOperationOutcomeError(string body) + { + var root = ParseJson(body) as JObject; + if (root == null) + return false; + + IEnumerable Outcomes() + { + if ((string)root["resourceType"] == "OperationOutcome") + yield return root; + if ((string)root["resourceType"] == "Bundle" && root["entry"] is JArray entries) + { + foreach (var entry in entries.OfType()) + { + if (entry["response"]?["outcome"] is JObject outcome && (string)outcome["resourceType"] == "OperationOutcome") + yield return outcome; + if (entry["resource"] is JObject resource && (string)resource["resourceType"] == "OperationOutcome") + yield return resource; + } + } + } + + return Outcomes().Any(o => (o["issue"] as JArray)?.OfType() + .Any(i => (string)i["severity"] == "error" || (string)i["severity"] == "fatal") == true); + } + + // ── Capture ─────────────────────────────────────────────────────────────────────────────────── + + private static string Capture(ProtectedCaptureEntry entry, string body, Func header) + { + switch (entry.Source) + { + case ProtectedCaptureEntry.JsonPath: + var token = SelectJson(body, entry.Expression); + return token is JValue value && value.Value != null + ? Convert.ToString(value.Value, System.Globalization.CultureInfo.InvariantCulture) + : null; + case ProtectedCaptureEntry.XPath: + return SelectXml(body, entry.Expression); + case ProtectedCaptureEntry.Hl7Field: + return Hl7FieldReference(body, entry.Expression); + case ProtectedCaptureEntry.Header: + return header?.Invoke(entry.Expression); + case ProtectedCaptureEntry.FhirLocationId: + return FhirResourceId(body, header, entry.Expression); + default: + return null; + } + } + + /// The id of the created resource: the Location header, or a Bundle entry's response.location, or the returned resource's id. + private static string FhirResourceId(string body, Func header, string resourceType) + { + foreach (var location in new[] { header?.Invoke("Location"), header?.Invoke("Content-Location") }) + { + var id = LocationId(location, resourceType); + if (id != null) + return id; + } + + var root = ParseJson(body) as JObject; + if (root == null) + return null; + + if ((string)root["resourceType"] == "Bundle" && root["entry"] is JArray entries) + { + foreach (var entry in entries.OfType()) + { + var id = LocationId((string)entry["response"]?["location"], resourceType); + if (id != null) + return id; + } + return null; + } + + var type = (string)root["resourceType"]; + return resourceType == null || string.Equals(type, resourceType, StringComparison.Ordinal) ? (string)root["id"] : null; + } + + private static string LocationId(string location, string resourceType) + { + if (string.IsNullOrWhiteSpace(location)) + return null; + + // Drop a trailing /_history/n so the pattern sees .../Type/id. + var trimmed = Regex.Replace(location.Trim(), @"/_history/[^/?#]*", string.Empty); + var match = FhirLocation.Match(trimmed); + if (!match.Success) + return null; + return resourceType == null || string.Equals(match.Groups["type"].Value, resourceType, StringComparison.Ordinal) + ? match.Groups["id"].Value + : null; + } + + // ── Parsers ─────────────────────────────────────────────────────────────────────────────────── + + private static JToken ParseJson(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return null; + try + { + using var reader = new JsonTextReader(new StringReader(body)) { DateParseHandling = DateParseHandling.None, MaxDepth = 64 }; + return JToken.ReadFrom(reader); + } + catch (JsonException) + { + return null; + } + } + + private static JToken SelectJson(string body, string path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + try + { + return ParseJson(body)?.SelectToken(path, errorWhenNoMatch: false); + } + catch (JsonException) + { + return null; // a path that matches several tokens, or a malformed path + } + } + + /// The string value of an XPath expression (the first node of a node-set). DTDs are refused. + private static string SelectXml(string body, string path) + { + if (string.IsNullOrWhiteSpace(body) || string.IsNullOrWhiteSpace(path)) + return null; + try + { + var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersFromEntities = 0 }; + using var reader = XmlReader.Create(new StringReader(body), settings); + var navigator = new XPathDocument(reader).CreateNavigator(); + var result = navigator.Evaluate(path); + return result switch + { + XPathNodeIterator nodes => nodes.MoveNext() ? nodes.Current?.Value : null, + string text => text, + bool flag => flag ? "true" : "false", + double number => number.ToString(System.Globalization.CultureInfo.InvariantCulture), + _ => null + }; + } + catch (Exception ex) when (ex is XmlException || ex is XPathException) + { + return null; + } + } + + /// "MSA-2", "PID-3.1" or "PID-3.1.2": field, then optional component and subcomponent. + private static string Hl7FieldReference(string body, string reference) + { + var match = Regex.Match(reference ?? string.Empty, @"^(?[A-Z0-9]{3})-(?\d{1,3})(\.(?\d{1,3}))?(\.(?\d{1,3}))?$"); + if (!match.Success) + return null; + + var value = Hl7Field(body, match.Groups["seg"].Value, int.Parse(match.Groups["field"].Value), 0); + if (value == null) + return null; + if (match.Groups["comp"].Success) + { + var components = value.Split('^'); + var index = int.Parse(match.Groups["comp"].Value) - 1; + value = index >= 0 && index < components.Length ? components[index] : null; + } + if (value != null && match.Groups["sub"].Success) + { + var subcomponents = value.Split('&'); + var index = int.Parse(match.Groups["sub"].Value) - 1; + value = index >= 0 && index < subcomponents.Length ? subcomponents[index] : null; + } + return value; + } + + /// Field of the first segment (MSH counts its field separator as MSH-1). + private static string Hl7Field(string body, string segmentId, int field, int repetition) + { + if (string.IsNullOrEmpty(body) || field < 1) + return null; + + foreach (var segment in body.Replace("\r\n", "\r").Replace('\n', '\r').Split('\r')) + { + if (!segment.StartsWith(segmentId + "|", StringComparison.Ordinal)) + continue; + + var parts = segment.Split('|'); + var index = segmentId == "MSH" ? field - 1 : field; + if (segmentId == "MSH" && field == 1) + return "|"; + if (index <= 0 || index >= parts.Length) + return null; + var repetitions = parts[index].Split('~'); + return repetition < repetitions.Length ? repetitions[repetition] : null; + } + + return null; + } + } +} diff --git a/Providers/Resgrid.Providers.Workflow/Resgrid.Providers.Workflow.csproj b/Providers/Resgrid.Providers.Workflow/Resgrid.Providers.Workflow.csproj index aa6e9f4f7..76e9fc354 100644 --- a/Providers/Resgrid.Providers.Workflow/Resgrid.Providers.Workflow.csproj +++ b/Providers/Resgrid.Providers.Workflow/Resgrid.Providers.Workflow.csproj @@ -8,6 +8,10 @@ + + + + diff --git a/Repositories/Resgrid.Repositories.DataRepository/CallsRepository.SubjectIdentifiers.cs b/Repositories/Resgrid.Repositories.DataRepository/CallsRepository.SubjectIdentifiers.cs new file mode 100644 index 000000000..ebca6ac33 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/CallsRepository.SubjectIdentifiers.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; + +namespace Resgrid.Repositories.DataRepository +{ + public partial class CallsRepository + { + public async Task TryUpdateSubjectIdentifiersAsync(int callId, int departmentId, string expectedValue, string newValue, + CancellationToken cancellationToken = default) + { + var postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + var sql = postgres + ? $"UPDATE {_sqlConfiguration.SchemaName}.calls SET subjectidentifiers = @NewValue " + + "WHERE callid = @CallId AND departmentid = @DepartmentId AND subjectidentifiers IS NOT DISTINCT FROM @ExpectedValue::citext" + : $"UPDATE {_sqlConfiguration.SchemaName}.[Calls] SET [SubjectIdentifiers] = @NewValue " + + "WHERE [CallId] = @CallId AND [DepartmentId] = @DepartmentId AND " + + "(([SubjectIdentifiers] IS NULL AND @ExpectedValue IS NULL) OR [SubjectIdentifiers] = @ExpectedValue COLLATE Latin1_General_BIN2)"; + + var parameters = new { CallId = callId, DepartmentId = departmentId, ExpectedValue = expectedValue, NewValue = newValue }; + + if (_unitOfWork?.Connection != null) + return await _unitOfWork.CreateOrGetConnection().ExecuteAsync(new CommandDefinition(sql, parameters, _unitOfWork.Transaction, + cancellationToken: cancellationToken)) == 1; + + await using var connection = _connectionProvider.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition(sql, parameters, cancellationToken: cancellationToken)) == 1; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index 26159aea3..456664a68 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -296,6 +296,9 @@ protected SqlConfiguration() { } public string SelectShiftByShiftIdJSONQuery { get; set; } public string SelectShiftsByDidJSONQuery { get; set; } public string SelectShiftSignupsByGroupIdAndDateQuery { get; set; } + public string SelectShiftSignupTradesByDepartmentIdQuery { get; set; } + public string SelectShiftSignupsByDepartmentIdAndDateRangeQuery { get; set; } + public string SelectShiftSignupTradeUserShiftsBySignupIdQuery { get; set; } #endregion Shifts #region Calls diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index f108f71f7..dd0766230 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -359,6 +359,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Protected Workflows (ADP push model): per-workflow releases and the per-department disclosure hash chain. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // UDF Repositories builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 5b9ad6132..4bca23ec4 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -289,6 +289,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); // Workflow runs: the RMS-4 release telemetry counts them per department. builder.RegisterType().As().InstancePerLifetimeScope(); + // Protected Workflows: WorkflowService's protected runtime resolves these. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // Contacts Phase A (M0183/M0184): the pre-plan, hazard and site-attachment repositories ContactsService now takes. builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/ProtectedWorkflowDisclosureRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ProtectedWorkflowDisclosureRepository.cs new file mode 100644 index 000000000..4f4c269c2 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ProtectedWorkflowDisclosureRepository.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// Append-only store for the per-department Protected Workflow hash chain. The only write is AppendAsync, which + /// links the record to the current tail and inserts it; the unique (DepartmentId, ChainSequence) index turns a + /// concurrent append into a failed insert, which re-links against the new tail and retries. There is no update + /// or delete path in application code. + /// + public class ProtectedWorkflowDisclosureRepository : RepositoryBase, IProtectedWorkflowDisclosureRepository + { + private const int MaxAppendAttempts = 8; + + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public ProtectedWorkflowDisclosureRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _unitOfWork = unitOfWork; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = _isPostgres + ? $"{sqlConfiguration.SchemaName}.protectedworkflowdisclosures" + : $"{sqlConfiguration.SchemaName}.[ProtectedWorkflowDisclosures]"; + } + + public async Task AppendAsync(ProtectedWorkflowDisclosure record, CancellationToken cancellationToken = default) + { + if (record == null) + throw new ArgumentNullException(nameof(record)); + + if (string.IsNullOrWhiteSpace(record.ProtectedWorkflowDisclosureId)) + record.ProtectedWorkflowDisclosureId = Guid.NewGuid().ToString(); + + // Sanitize BEFORE hashing: InsertAsync sanitizes too, and the stored bytes must be the hashed bytes. + Utf8WriteGuard.Sanitize(record); + + for (var attempt = 1; ; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var tail = await GetLatestForDepartmentAsync(record.DepartmentId); + ProtectedWorkflowDisclosureChain.Link(record, tail); + // Hashed at millisecond precision already; sent as an unzoned timestamp so no session time zone can shift + // the stored value (a UTC-kinded DateTime is converted by PostgreSQL on its way into a timestamp column). + record.OccurredOn = DateTime.SpecifyKind(record.OccurredOn, DateTimeKind.Unspecified); + + try + { + await InsertAsync(record, cancellationToken); + return record; + } + catch (DbException) when (attempt < MaxAppendAttempts) + { + // Only a lost race for the sequence number is retried; anything else is a real failure. + var current = await GetLatestForDepartmentAsync(record.DepartmentId); + if (current == null || current.ChainSequence < record.ChainSequence) + throw; + } + } + } + + public Task GetLatestForDepartmentAsync(int departmentId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId ORDER BY chainsequence DESC LIMIT 1" + : $"SELECT TOP 1 * FROM {_table} WHERE [DepartmentId] = @DepartmentId ORDER BY [ChainSequence] DESC"; + return WithConnectionAsync(c => c.QueryFirstOrDefaultAsync(sql, new { DepartmentId = departmentId }, _unitOfWork?.Transaction)); + } + + public Task> GetForDepartmentAsync(int departmentId, ProtectedWorkflowDisclosureFilter filter) + { + filter ??= new ProtectedWorkflowDisclosureFilter(); + var max = Math.Clamp(filter.MaxRows, 1, 50000); + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + + string Column(string name) => _isPostgres ? name.ToLowerInvariant() : $"[{name}]"; + + var where = new StringBuilder($"{Column("DepartmentId")} = @DepartmentId"); + if (!string.IsNullOrWhiteSpace(filter.WorkflowId)) + { + where.Append($" AND {Column("WorkflowId")} = @WorkflowId"); + parameters.Add("WorkflowId", filter.WorkflowId.Trim()); + } + if (!string.IsNullOrWhiteSpace(filter.EntityId)) + { + where.Append($" AND {Column("EntityId")} = @EntityId"); + parameters.Add("EntityId", filter.EntityId.Trim()); + } + if (!string.IsNullOrWhiteSpace(filter.RecordType)) + { + where.Append($" AND {Column("RecordType")} = @RecordType"); + parameters.Add("RecordType", filter.RecordType.Trim()); + } + if (filter.FromUtc.HasValue) + { + where.Append($" AND {Column("OccurredOn")} >= @FromUtc"); + parameters.Add("FromUtc", filter.FromUtc.Value); + } + if (filter.ToUtc.HasValue) + { + where.Append($" AND {Column("OccurredOn")} <= @ToUtc"); + parameters.Add("ToUtc", filter.ToUtc.Value); + } + + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE {where} ORDER BY chainsequence DESC LIMIT {max}" + : $"SELECT TOP {max} * FROM {_table} WHERE {where} ORDER BY [ChainSequence] DESC"; + return WithConnectionAsync(c => c.QueryAsync(sql, parameters, _unitOfWork?.Transaction)); + } + + public Task> GetChainForDepartmentAsync(int departmentId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId ORDER BY chainsequence ASC" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId ORDER BY [ChainSequence] ASC"; + return WithConnectionAsync(c => c.QueryAsync(sql, new { DepartmentId = departmentId }, _unitOfWork?.Transaction)); + } + + private async Task WithConnectionAsync(Func> operation) + { + if (_unitOfWork?.Connection != null) + return await operation(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await operation(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectOpenShiftSignupTradesByUserIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectOpenShiftSignupTradesByUserIdQuery.cs index f944ea648..e7fcde60b 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectOpenShiftSignupTradesByUserIdQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectOpenShiftSignupTradesByUserIdQuery.cs @@ -17,10 +17,12 @@ public string GetQuery() { var query = _sqlConfiguration.SelectOpenShiftSignupTradesByUserIdQuery .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, - _sqlConfiguration.UnitStatesTable, + string.Empty, _sqlConfiguration.ParameterNotation, new string[] { "%USERID%" }, - new string[] { "UserId" }); + new string[] { "UserId" }, + new string[] { "%SHIFTSIGNUPTRADESTABLE%", "%SHIFTSIGNUPTRADEUSERSTABLE%", "%SHIFTSIGNUPSTABLE%" }, + new string[] { _sqlConfiguration.ShiftSignupTradesTable, _sqlConfiguration.ShiftSignupTradeUsersTable, _sqlConfiguration.ShiftSignupsTable }); return query; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupTradeUserShiftsBySignupIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupTradeUserShiftsBySignupIdQuery.cs new file mode 100644 index 000000000..821f3c278 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupTradeUserShiftsBySignupIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Shifts +{ + public class SelectShiftSignupTradeUserShiftsBySignupIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectShiftSignupTradeUserShiftsBySignupIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectShiftSignupTradeUserShiftsBySignupIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.ShiftSignupTradeUserShiftsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%SHIFTSIGNUPID%" }, + new string[] { "ShiftSignupId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupTradesByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupTradesByDepartmentIdQuery.cs new file mode 100644 index 000000000..89b6a451d --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupTradesByDepartmentIdQuery.cs @@ -0,0 +1,35 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Shifts +{ + public class SelectShiftSignupTradesByDepartmentIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectShiftSignupTradesByDepartmentIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectShiftSignupTradesByDepartmentIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%", "%STARTDATE%" }, + new string[] { "DepartmentId", "StartDate" }, + new string[] { "%SHIFTSIGNUPTRADESTABLE%", "%SHIFTSIGNUPSTABLE%", "%SHIFTSTABLE%" }, + new string[] { _sqlConfiguration.ShiftSignupTradesTable, _sqlConfiguration.ShiftSignupsTable, _sqlConfiguration.ShiftsTable }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupsByDepartmentIdAndDateRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupsByDepartmentIdAndDateRangeQuery.cs new file mode 100644 index 000000000..7bd55e808 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Shifts/SelectShiftSignupsByDepartmentIdAndDateRangeQuery.cs @@ -0,0 +1,35 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Shifts +{ + public class SelectShiftSignupsByDepartmentIdAndDateRangeQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectShiftSignupsByDepartmentIdAndDateRangeQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectShiftSignupsByDepartmentIdAndDateRangeQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%", "%STARTDATE%", "%ENDDATE%" }, + new string[] { "DepartmentId", "StartDate", "EndDate" }, + new string[] { "%SHIFTSIGNUPSTABLE%", "%SHIFTSTABLE%" }, + new string[] { _sqlConfiguration.ShiftSignupsTable, _sqlConfiguration.ShiftsTable }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/SelectWorkflowByDeptAndEventTypeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/SelectWorkflowByDeptAndEventTypeQuery.cs deleted file mode 100644 index b39526e3e..000000000 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Workflows/SelectWorkflowByDeptAndEventTypeQuery.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Resgrid.Config; -using Resgrid.Model; -using Resgrid.Model.Repositories.Queries.Contracts; -using Resgrid.Repositories.DataRepository.Configs; - -namespace Resgrid.Repositories.DataRepository.Queries.Workflows -{ - public class SelectWorkflowByDeptAndEventTypeQuery : ISelectQuery - { - private readonly SqlConfiguration _sqlConfiguration; - - public SelectWorkflowByDeptAndEventTypeQuery(SqlConfiguration sqlConfiguration) - { - _sqlConfiguration = sqlConfiguration; - } - - public string GetQuery() - { - if (DataConfig.DatabaseType == DatabaseTypes.Postgres) - return $"SELECT * FROM {_sqlConfiguration.SchemaName}.workflows WHERE departmentid = {_sqlConfiguration.ParameterNotation}DepartmentId AND triggereventtype = {_sqlConfiguration.ParameterNotation}TriggerEventType LIMIT 1"; - - return $"SELECT TOP 1 * FROM {_sqlConfiguration.SchemaName}.[Workflows] WHERE [DepartmentId] = {_sqlConfiguration.ParameterNotation}DepartmentId AND [TriggerEventType] = {_sqlConfiguration.ParameterNotation}TriggerEventType"; - } - - public string GetQuery() where TEntity : class, IEntity => GetQuery(); - } -} - diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 5c467b882..9f16e5cd5 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -831,6 +831,7 @@ from shiftdays sd 'ShiftPersonId', sp.shiftpersonid, 'ShiftId', sp.shiftid, 'UserId', sp.userid, + 'GroupId', sp.groupid, 'shift', (SELECT row_to_json(s3) from shifts s3 where s3.shiftid = sp.shiftid) ) ) @@ -859,13 +860,18 @@ FROM shiftadmins sa 'SignupTimestamp', ss.signuptimestamp, 'ShiftDay', ss.shiftday, 'Denied', ss.denied, + 'ApprovalPending', ss.approvalpending, + 'AssignedByUserId', ss.assignedbyuserid, + 'ReviewedByUserId', ss.reviewedbyuserid, + 'ReviewedOn', ss.reviewedon, + 'ReviewNote', ss.reviewnote, 'shift', (SELECT row_to_json(s5) from shifts s5 where s5.shiftid = ss.shiftid), 'departmentgroup', (SELECT row_to_json(dg2) from departmentgroups dg2 WHERE dg2.departmentgroupid = ss.departmentgroupid) ) ) from shiftsignups ss where ss.shiftid = sh.shiftid - ) + ) signups from shifts sh ) j"; @@ -910,6 +916,7 @@ from shiftdays sd 'ShiftPersonId', sp.shiftpersonid, 'ShiftId', sp.shiftid, 'UserId', sp.userid, + 'GroupId', sp.groupid, 'shift', (SELECT row_to_json(s3) from shifts s3 where s3.shiftid = sp.shiftid) ) ) @@ -938,13 +945,18 @@ FROM shiftadmins sa 'SignupTimestamp', ss.signuptimestamp, 'ShiftDay', ss.shiftday, 'Denied', ss.denied, + 'ApprovalPending', ss.approvalpending, + 'AssignedByUserId', ss.assignedbyuserid, + 'ReviewedByUserId', ss.reviewedbyuserid, + 'ReviewedOn', ss.reviewedon, + 'ReviewNote', ss.reviewnote, 'shift', (SELECT row_to_json(s5) from shifts s5 where s5.shiftid = ss.shiftid), 'departmentgroup', (SELECT row_to_json(dg2) from departmentgroups dg2 WHERE dg2.departmentgroupid = ss.departmentgroupid) ) ) from shiftsignups ss where ss.shiftid = sh.shiftid - ) + ) signups from shifts sh where exists (select 1 from shiftdays sd where sd.shiftid = sh.shiftid and sd.day >= %startdate% and sd.day < %enddate%) @@ -955,11 +967,13 @@ where exists (select 1 from shiftdays sd where sd.shiftid = sh.shiftid and sd.da FROM %SCHEMA%.%SHIFTSIGNUPTRADESTABLE% LEFT JOIN %SCHEMA%.%SHIFTSIGNUPTRADEUSERSTABLE% ON %SCHEMA%.%SHIFTSIGNUPTRADEUSERSTABLE%.ShiftSignupTradeId = %SCHEMA%.%SHIFTSIGNUPTRADESTABLE%.ShiftSignupTradeId WHERE UserId = %USERID%"; + // Trades the user has been asked to take, whose source shift day is not long over (see SQL Server). SelectOpenShiftSignupTradesByUserIdQuery = @" - SELECT * - FROM ShiftSignupTrades sst - INNER JOIN ShiftSignupTradeUsers sstu ON sstu.ShiftSignupTradeId = sst.ShiftSignupTradeId - WHERE sst.UserId = %USERID% AND sst.UserId != %USERID% AND sst.TargetShiftSignupId IS NULL"; + SELECT sst.* + FROM %SCHEMA%.%SHIFTSIGNUPTRADESTABLE% sst + INNER JOIN %SCHEMA%.%SHIFTSIGNUPTRADEUSERSTABLE% sstu ON sstu.ShiftSignupTradeId = sst.ShiftSignupTradeId + INNER JOIN %SCHEMA%.%SHIFTSIGNUPSTABLE% ss ON ss.ShiftSignupId = sst.SourceShiftSignupId + WHERE sstu.UserId = %USERID% AND ss.ShiftDay >= (now() at time zone 'utc') - interval '1 day'"; SelectShiftAndDaysByDIdQuery = @" SELECT s.*, sd.* FROM %SCHEMA%.%SHIFTSTABLE% s @@ -1031,6 +1045,7 @@ from shiftdays sd 'ShiftPersonId', sp.shiftpersonid, 'ShiftId', sp.shiftid, 'UserId', sp.userid, + 'GroupId', sp.groupid, 'shift', (SELECT row_to_json(s3) from shifts s3 where s3.shiftid = sp.shiftid) ) ) @@ -1059,13 +1074,18 @@ FROM shiftadmins sa 'SignupTimestamp', ss.signuptimestamp, 'ShiftDay', ss.shiftday, 'Denied', ss.denied, + 'ApprovalPending', ss.approvalpending, + 'AssignedByUserId', ss.assignedbyuserid, + 'ReviewedByUserId', ss.reviewedbyuserid, + 'ReviewedOn', ss.reviewedon, + 'ReviewNote', ss.reviewnote, 'shift', (SELECT row_to_json(s5) from shifts s5 where s5.shiftid = ss.shiftid), 'departmentgroup', (SELECT row_to_json(dg2) from departmentgroups dg2 WHERE dg2.departmentgroupid = ss.departmentgroupid) ) ) from shiftsignups ss where ss.shiftid = sh.shiftid - ) + ) signups from shifts sh where sh.shiftid = %SHIFTID% @@ -1108,6 +1128,7 @@ from shiftdays sd 'ShiftPersonId', sp.shiftpersonid, 'ShiftId', sp.shiftid, 'UserId', sp.userid, + 'GroupId', sp.groupid, 'shift', (SELECT row_to_json(s3) from shifts s3 where s3.shiftid = sp.shiftid) ) ) @@ -1136,19 +1157,39 @@ FROM shiftadmins sa 'SignupTimestamp', ss.signuptimestamp, 'ShiftDay', ss.shiftday, 'Denied', ss.denied, + 'ApprovalPending', ss.approvalpending, + 'AssignedByUserId', ss.assignedbyuserid, + 'ReviewedByUserId', ss.reviewedbyuserid, + 'ReviewedOn', ss.reviewedon, + 'ReviewNote', ss.reviewnote, 'shift', (SELECT row_to_json(s5) from shifts s5 where s5.shiftid = ss.shiftid), 'departmentgroup', (SELECT row_to_json(dg2) from departmentgroups dg2 WHERE dg2.departmentgroupid = ss.departmentgroupid) ) ) from shiftsignups ss where ss.shiftid = sh.shiftid - ) + ) signups from shifts sh where sh.departmentid = %DID% ) j"; SelectShiftSignupsByGroupIdAndDateQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentGroupId = %GROUPID% AND CAST(ShiftDay AS DATE) = CAST(%SHIFTDAYDATE% AS DATE)"; + // Department trades with source and target signups joined (see SQL Server). + SelectShiftSignupTradesByDepartmentIdQuery = @" + SELECT sst.*, ss.*, ts.* + FROM %SCHEMA%.%SHIFTSIGNUPTRADESTABLE% sst + INNER JOIN %SCHEMA%.%SHIFTSIGNUPSTABLE% ss ON ss.ShiftSignupId = sst.SourceShiftSignupId + INNER JOIN %SCHEMA%.%SHIFTSTABLE% s ON s.ShiftId = ss.ShiftId + LEFT JOIN %SCHEMA%.%SHIFTSIGNUPSTABLE% ts ON ts.ShiftSignupId = sst.TargetShiftSignupId + WHERE s.DepartmentId = %DID% AND (ss.ShiftDay >= %STARTDATE% OR ts.ShiftDay >= %STARTDATE%)"; + SelectShiftSignupsByDepartmentIdAndDateRangeQuery = @" + SELECT ss.* + FROM %SCHEMA%.%SHIFTSIGNUPSTABLE% ss + INNER JOIN %SCHEMA%.%SHIFTSTABLE% s ON s.ShiftId = ss.ShiftId + WHERE s.DepartmentId = %DID% AND ss.ShiftDay >= %STARTDATE% AND ss.ShiftDay < %ENDDATE%"; + SelectShiftSignupTradeUserShiftsBySignupIdQuery = + "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE ShiftSignupId = %SHIFTSIGNUPID%"; #endregion Shifts diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index f09e6aaaf..fc386e6e2 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -930,11 +930,14 @@ WHERE EXISTS (SELECT 1 FROM [dbo].[ShiftDays] sd WHERE sd.[ShiftId] = sh.[ShiftI FROM %SCHEMA%.%SHIFTSIGNUPTRADESTABLE% LEFT JOIN %SCHEMA%.%SHIFTSIGNUPTRADEUSERSTABLE% ON %SCHEMA%.%SHIFTSIGNUPTRADEUSERSTABLE%.[ShiftSignupTradeId] = %SCHEMA%.%SHIFTSIGNUPTRADESTABLE%.[ShiftSignupTradeId] WHERE [UserId] = %USERID%"; + // Trades the user has been asked to take, whose source shift day is not long over. The user's own + // ShiftSignupTradeUsers row is what makes them a participant; sst.UserId is only set once a taker is picked. SelectOpenShiftSignupTradesByUserIdQuery = @" - SELECT * - FROM ShiftSignupTrades sst - INNER JOIN ShiftSignupTradeUsers sstu ON sstu.ShiftSignupTradeId = sst.ShiftSignupTradeId - WHERE sst.UserId = %USERID% AND sst.UserId != %USERID% AND sst.TargetShiftSignupId IS NULL"; + SELECT sst.* + FROM %SCHEMA%.%SHIFTSIGNUPTRADESTABLE% sst + INNER JOIN %SCHEMA%.%SHIFTSIGNUPTRADEUSERSTABLE% sstu ON sstu.[ShiftSignupTradeId] = sst.[ShiftSignupTradeId] + INNER JOIN %SCHEMA%.%SHIFTSIGNUPSTABLE% ss ON ss.[ShiftSignupId] = sst.[SourceShiftSignupId] + WHERE sstu.[UserId] = %USERID% AND ss.[ShiftDay] >= DATEADD(day, -1, GETUTCDATE())"; SelectShiftAndDaysByDIdQuery = @" SELECT s.*, sd.* FROM %SCHEMA%.%SHIFTSTABLE% s @@ -1102,6 +1105,22 @@ FROM [dbo].[Shifts] sh FOR JSON PATH) AS 'JsonResult'"; SelectShiftSignupsByGroupIdAndDateQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentGroupId] = %GROUPID% AND CAST([ShiftDay] AS DATE) = CAST(%SHIFTDAYDATE% AS DATE)"; + // Every trade in the department whose source or swap-back day is on or after StartDate, with both signups + // joined so rosters can be built in memory. Multi-mapped as trade, source signup, target signup. + SelectShiftSignupTradesByDepartmentIdQuery = @" + SELECT sst.*, ss.*, ts.* + FROM %SCHEMA%.%SHIFTSIGNUPTRADESTABLE% sst + INNER JOIN %SCHEMA%.%SHIFTSIGNUPSTABLE% ss ON ss.[ShiftSignupId] = sst.[SourceShiftSignupId] + INNER JOIN %SCHEMA%.%SHIFTSTABLE% s ON s.[ShiftId] = ss.[ShiftId] + LEFT JOIN %SCHEMA%.%SHIFTSIGNUPSTABLE% ts ON ts.[ShiftSignupId] = sst.[TargetShiftSignupId] + WHERE s.[DepartmentId] = %DID% AND (ss.[ShiftDay] >= %STARTDATE% OR ts.[ShiftDay] >= %STARTDATE%)"; + SelectShiftSignupsByDepartmentIdAndDateRangeQuery = @" + SELECT ss.* + FROM %SCHEMA%.%SHIFTSIGNUPSTABLE% ss + INNER JOIN %SCHEMA%.%SHIFTSTABLE% s ON s.[ShiftId] = ss.[ShiftId] + WHERE s.[DepartmentId] = %DID% AND ss.[ShiftDay] >= %STARTDATE% AND ss.[ShiftDay] < %ENDDATE%"; + SelectShiftSignupTradeUserShiftsBySignupIdQuery = + "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [ShiftSignupId] = %SHIFTSIGNUPID%"; #endregion Shifts diff --git a/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupRepository.cs index 885980a8f..2b3941bab 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupRepository.cs @@ -194,5 +194,48 @@ public async Task> GetAllShiftSignupsByUserIdAsync(stri throw; } } + + public async Task> GetShiftSignupsByDepartmentIdAndDateRangeAsync(int departmentId, DateTime startDate, DateTime endDate) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", startDate); + dynamicParameters.Add("EndDate", endDate); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeRepository.cs index f3ee4e690..99c599b22 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeRepository.cs @@ -48,8 +48,10 @@ public async Task GetShiftSignupTradeBySourceShiftSignupIdAsyn map: ShiftSignupTradeMapping(dictionary), splitOn: "ShiftSignupTradeUserId"); + // A signup can carry more than one trade over time (a denied trade stays on record), so + // hand back the live one: not denied, newest first. if (dictionary.Count > 0) - return dictionary.Select(y => y.Value).FirstOrDefault(); + return dictionary.Values.OrderBy(y => y.Denied).ThenByDescending(y => y.ShiftSignupTradeId).FirstOrDefault(); return result.FirstOrDefault(); }); @@ -96,8 +98,10 @@ public async Task GetShiftSignupTradeByTargetShiftSignupIdAsyn map: ShiftSignupTradeMapping(dictionary), splitOn: "ShiftSignupTradeUserId"); + // A signup can carry more than one trade over time (a denied trade stays on record), so + // hand back the live one: not denied, newest first. if (dictionary.Count > 0) - return dictionary.Select(y => y.Value).FirstOrDefault(); + return dictionary.Values.OrderBy(y => y.Denied).ThenByDescending(y => y.ShiftSignupTradeId).FirstOrDefault(); return result.FirstOrDefault(); }); @@ -262,6 +266,57 @@ public async Task> GetTradeRequestsAndSourceShifts } } + public async Task> GetShiftSignupTradesByDepartmentIdAsync(int departmentId, DateTime startDate) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", startDate); + + var query = _queryFactory.GetQuery(); + + // A trade with no swap-back signup comes out of the LEFT JOIN with null target columns, which Dapper + // maps to a null TargetShiftSignup. + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction, + map: (trade, source, target) => + { + trade.SourceShiftSignup = source; + trade.TargetShiftSignup = target; + return trade; + }, + splitOn: "ShiftSignupId,ShiftSignupId"); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } + private static Func ShiftSignupTradeMapping(Dictionary dictionary) { return new Func((shiftSignupTrade, shiftSignupTradeUser) => @@ -270,7 +325,7 @@ private static Func Sh if (shiftSignupTradeUser != null) { - if (dictionary.TryGetValue(shiftSignupTrade.ShiftSignupTradeId, out shiftSignupTrade)) + if (dictionary.TryGetValue(shiftSignupTrade.ShiftSignupTradeId, out dictionaryShiftSignupTrade)) { if (dictionaryShiftSignupTrade.Users.All(x => x.ShiftSignupTradeUserId != shiftSignupTradeUser.ShiftSignupTradeUserId)) dictionaryShiftSignupTrade.Users.Add(shiftSignupTradeUser); diff --git a/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeUserRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeUserRepository.cs index 61e1b4d67..04d256bb3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeUserRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeUserRepository.cs @@ -46,7 +46,7 @@ public async Task> GetShiftSignupTradeUsersByT param: dynamicParameters, transaction: _unitOfWork.Transaction, map: ShiftGroupMapping(dictionary), - splitOn: "ShiftGroupRoleId"); + splitOn: "ShiftSignupTradeUserShiftId"); if (dictionary.Count > 0) return dictionary.Select(y => y.Value); diff --git a/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeUserShiftsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeUserShiftsRepository.cs index 117da52c3..118646f37 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeUserShiftsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ShiftSignupTradeUserShiftsRepository.cs @@ -1,8 +1,15 @@ -using Resgrid.Model; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Framework; +using Resgrid.Model; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries.Shifts; namespace Resgrid.Repositories.DataRepository { @@ -21,5 +28,46 @@ public ShiftSignupTradeUserShiftsRepository(IConnectionProvider connectionProvid _queryFactory = queryFactory; _unitOfWork = unitOfWork; } + + public async Task> GetShiftSignupTradeUserShiftsBySignupIdAsync(int shiftSignupId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("ShiftSignupId", shiftSignupId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowProtectedReleaseRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowProtectedReleaseRepository.cs new file mode 100644 index 000000000..0e59a0089 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowProtectedReleaseRepository.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class WorkflowProtectedReleaseRepository : RepositoryBase, IWorkflowProtectedReleaseRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public WorkflowProtectedReleaseRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _unitOfWork = unitOfWork; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = _isPostgres + ? $"{sqlConfiguration.SchemaName}.workflowprotectedreleases" + : $"{sqlConfiguration.SchemaName}.[WorkflowProtectedReleases]"; + } + + public Task GetLatestByWorkflowIdAsync(string workflowId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE workflowid = @WorkflowId ORDER BY createdon DESC LIMIT 1" + : $"SELECT TOP 1 * FROM {_table} WHERE [WorkflowId] = @WorkflowId ORDER BY [CreatedOn] DESC"; + return WithConnectionAsync(c => c.QueryFirstOrDefaultAsync(sql, new { WorkflowId = workflowId }, _unitOfWork?.Transaction)); + } + + public Task> GetAllByWorkflowIdAsync(string workflowId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE workflowid = @WorkflowId ORDER BY createdon DESC" + : $"SELECT * FROM {_table} WHERE [WorkflowId] = @WorkflowId ORDER BY [CreatedOn] DESC"; + return WithConnectionAsync(c => c.QueryAsync(sql, new { WorkflowId = workflowId }, _unitOfWork?.Transaction)); + } + + public Task> GetAllByDepartmentIdAsync(int departmentId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId ORDER BY createdon DESC" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId ORDER BY [CreatedOn] DESC"; + return WithConnectionAsync(c => c.QueryAsync(sql, new { DepartmentId = departmentId }, _unitOfWork?.Transaction)); + } + + public Task> GetAllByCredentialIdAsync(string workflowCredentialId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE workflowcredentialid = @WorkflowCredentialId" + : $"SELECT * FROM {_table} WHERE [WorkflowCredentialId] = @WorkflowCredentialId"; + return WithConnectionAsync(c => c.QueryAsync(sql, new { WorkflowCredentialId = workflowCredentialId }, _unitOfWork?.Transaction)); + } + + public Task> GetAllByStatesAsync(IEnumerable states) + { + // Enum values only, never caller text, so the literal list is safe on both engines. + var list = string.Join(",", (states ?? Enumerable.Empty()).Select(s => ((int)s).ToString()).Distinct()); + if (list.Length == 0) + return Task.FromResult(Enumerable.Empty()); + + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE state IN ({list})" + : $"SELECT * FROM {_table} WHERE [State] IN ({list})"; + return WithConnectionAsync(c => c.QueryAsync(sql, null, _unitOfWork?.Transaction)); + } + + private static readonly string[] MutableColumns = + { + "State", "SuspendedReason", "AllowedFieldIds", "DestinationScheme", "DestinationHost", "TokenHost", "WorkflowCredentialId", + "AuthMethod", "AllowsRestricted", "RestrictedAckVersion", "RestrictedAckByUserId", "AllowsPart2", "Part2AckVersion", "Part2AckByUserId", + "ConfigFingerprint", "RecipientType", "RecipientName", "Purpose", "AckVersion", "RequestedByUserId", "RequestedOn", + "ApprovedByUserId", "ApprovedOn", "ExpiresOn", "ExpiryNoticeSentDays", "RevokedByUserId", "RevokedOn", "UpdatedOn" + }; + + public async Task TryUpdateAsync(WorkflowProtectedRelease release, CancellationToken cancellationToken = default) + { + if (release == null) + throw new ArgumentNullException(nameof(release)); + + Utf8WriteGuard.Sanitize(release); + string Column(string name) => _isPostgres ? name.ToLowerInvariant() : $"[{name}]"; + var assignments = string.Join(", ", MutableColumns.Select(c => $"{Column(c)} = @{c}")); + var sql = $"UPDATE {_table} SET {assignments}, {Column("Version")} = {Column("Version")} + 1 " + + $"WHERE {Column("WorkflowProtectedReleaseId")} = @WorkflowProtectedReleaseId AND {Column("Version")} = @Version"; + + var rows = await WithConnectionAsync(c => c.ExecuteAsync(new Dapper.CommandDefinition(sql, release, _unitOfWork?.Transaction, cancellationToken: cancellationToken))); + if (rows != 1) + return false; + + release.Version++; + return true; + } + + private async Task WithConnectionAsync(Func> operation) + { + if (_unitOfWork?.Connection != null) + return await operation(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await operation(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs index 3264177ce..8543924ff 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRepository.cs @@ -100,41 +100,6 @@ public async Task> GetAllByDepartmentIdAsync(int departmen } } - public async Task GetByDepartmentAndEventTypeAsync(int departmentId, int triggerEventType) - { - try - { - var selectFunction = new Func>(async x => - { - var dynamicParameters = new DynamicParametersExtension(); - dynamicParameters.Add("DepartmentId", departmentId); - dynamicParameters.Add("TriggerEventType", triggerEventType); - var query = _queryFactory.GetQuery(); - return await x.QueryFirstOrDefaultAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); - }); - - DbConnection conn = null; - if (_unitOfWork?.Connection == null) - { - using (conn = _connectionProvider.Create()) - { - await conn.OpenAsync(); - return await selectFunction(conn); - } - } - else - { - conn = _unitOfWork.CreateOrGetConnection(); - return await selectFunction(conn); - } - } - catch (Exception ex) - { - Logging.LogException(ex); - throw; - } - } - /// public async Task DeleteWorkflowWithAllDependenciesAsync(string workflowId) { diff --git a/Tests/Resgrid.Tests/Bootstrapper.cs b/Tests/Resgrid.Tests/Bootstrapper.cs index 696cec93c..95507b7c0 100644 --- a/Tests/Resgrid.Tests/Bootstrapper.cs +++ b/Tests/Resgrid.Tests/Bootstrapper.cs @@ -143,6 +143,12 @@ public static void Initialize() builder.RegisterInstance(new Moq.Mock().Object) .As(); + // Saving a call custom field definition notifies Protected Workflows (sensitivity retags re-fingerprint releases). + // The workflow repositories are not in the testing data module: loose mocks, nothing is pinned to anything. + builder.RegisterInstance(new Moq.Mock().Object).As(); + builder.RegisterInstance(new Moq.Mock().Object).As(); + builder.RegisterInstance(new Moq.Mock().Object).As(); + // UDF mock repositories builder.RegisterType() .As() diff --git a/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs b/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs index 914282b86..6c86eb0d6 100644 --- a/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs @@ -7,6 +7,7 @@ using Resgrid.Chatbot.Models; using Resgrid.Model; using Resgrid.Model.Services; +using Resgrid.Tests.Helpers; namespace Resgrid.Tests.Chatbot { @@ -98,7 +99,8 @@ public async Task HandleAsync_WhenCallScopedStatesAreNotCurrent_ExcludesPersonne units.Object, customStates.Object, profiles.Object, - authorization.Object); + authorization.Object, + DispatchScopeMocks.Off()); var intent = new ChatbotIntent { Type = ChatbotIntentType.CallResponders }; intent.Parameters["callId"] = "42"; @@ -177,7 +179,7 @@ public async Task HandleAsync_WithResponderMode_FiltersUsingClassifierMode(strin }); var handler = new CallRespondersActionHandler(calls.Object, actionLogs.Object, units.Object, - customStates.Object, profiles.Object, authorization.Object); + customStates.Object, profiles.Object, authorization.Object, DispatchScopeMocks.Off()); var intent = new ChatbotIntent { Type = ChatbotIntentType.CallResponders }; intent.Parameters["callId"] = "42"; intent.Parameters["mode"] = mode; diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs index 4fee6f1ab..1f73b5da0 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs @@ -25,6 +25,28 @@ public class ChatbotHandlerTests private static ChatbotSession Session(string userId = "user-1", int departmentId = 1) => new ChatbotSession { SessionId = "s1", UserId = userId, DepartmentId = departmentId, Platform = ChatbotPlatform.SmsTwilio }; + /// Group-scoped dispatch off: every user is department-wide. + private static IDispatchScopeService NoScope() + { + var scope = new Mock(); + scope.Setup(x => x.GetScopeForUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int departmentId, string userId) => DispatchScope.DepartmentWide(departmentId, userId, DispatchScopeReasons.ScopingDisabled)); + return scope.Object; + } + + /// Group-scoped dispatch on: only the calls with the given ids are in the user's area. + private static IDispatchScopeService ScopedTo(params int[] inScopeCallIds) + { + var inScope = new HashSet(inScopeCallIds); + var areaScope = new DispatchScope { DepartmentId = 1, UserId = "user-1", Reason = DispatchScopeReasons.GroupAdmin, GroupIds = new HashSet { 10 } }; + var scope = new Mock(); + scope.Setup(x => x.GetScopeForUserAsync(1, "user-1")).ReturnsAsync(areaScope); + scope.Setup(x => x.IsCallInScopeAsync(areaScope, It.IsAny())).ReturnsAsync((DispatchScope s, Call c) => inScope.Contains(c.CallId)); + scope.Setup(x => x.FilterCallsAsync(areaScope, It.IsAny>())) + .ReturnsAsync((DispatchScope s, List calls) => calls.FindAll(c => inScope.Contains(c.CallId))); + return scope.Object; + } + private static ChatbotIntent Intent(ChatbotIntentType type, params (string key, string value)[] parameters) { var intent = new ChatbotIntent { Type = type }; @@ -438,6 +460,64 @@ public async Task DispatchCall_Confirmed_CreatesActiveCall() // ===================== RespondToCallHandler ===================== + [Test] + public async Task RespondToCall_ScopedUser_CallOutsideTheirArea_ReturnsNotFound_AndDoesNotSetStatus() + { + var calls = new Mock(); + calls.Setup(c => c.GetCallByIdAsync(20, It.IsAny())) + .ReturnsAsync(new Call { CallId = 20, Name = "Crisis elsewhere", DepartmentId = 1 }); + var actionLogs = new Mock(); + + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, ScopedTo(7)); + var response = await handler.HandleAsync(Msg("respond to c20"), Intent(ChatbotIntentType.RespondToCall, ("callId", "20")), Session(departmentId: 1)); + + // Out of area reads exactly like a call that doesn't exist. + response.Text.Should().Contain("No active call found matching"); + actionLogs.Invocations.Should().BeEmpty(); + } + + [Test] + public async Task CallDetail_ScopedUser_Shorthand_MatchesOnlyCallsInTheirArea() + { + var calls = new Mock(); + calls.Setup(c => c.GetActiveCallsByDepartmentAsync(1)).ReturnsAsync(new List + { + new Call { CallId = 20, Name = "Fire elsewhere", DepartmentId = 1, LoggedOn = DateTime.UtcNow }, + new Call { CallId = 7, Name = "Fire in my area", DepartmentId = 1, LoggedOn = DateTime.UtcNow.AddMinutes(-30) } + }); + var depts = new Mock(); + depts.Setup(d => d.GetDepartmentByIdAsync(1, It.IsAny())).ReturnsAsync(new Department { DepartmentId = 1, TimeZone = "UTC" }); + var authz = new Mock(); + authz.Setup(a => a.CanUserViewCallAsync("user-1", It.IsAny())).ReturnsAsync(true); + + var handler = new CallDetailActionHandler(calls.Object, depts.Object, authz.Object, ScopedTo(7)); + var response = await handler.HandleAsync(Msg("call fire"), Intent(ChatbotIntentType.GetCallDetail, ("callRef", "fire")), Session(departmentId: 1)); + + // Without scoping the newer out-of-area fire would match and then be refused. + response.Text.Should().Contain("Fire in my area").And.NotContain("Fire elsewhere"); + } + + [Test] + public async Task RespondToCall_ScopedUser_Shorthand_MatchesOnlyCallsInTheirArea() + { + var calls = new Mock(); + calls.Setup(c => c.GetActiveCallsByDepartmentAsync(1)).ReturnsAsync(new List + { + new Call { CallId = 20, Name = "Fire elsewhere", DepartmentId = 1, LoggedOn = DateTime.UtcNow }, + new Call { CallId = 7, Name = "Fire in my area", DepartmentId = 1, LoggedOn = DateTime.UtcNow.AddMinutes(-30) } + }); + var actionLogs = new Mock(); + + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, ScopedTo(7)); + await handler.HandleAsync(Msg("respond to fire"), Intent(ChatbotIntentType.RespondToCall, ("callRef", "fire")), Session(departmentId: 1)); + + // The newer fire is in another area, so the older in-area one is the match. + actionLogs.Verify(a => a.SetUserActionAsync("user-1", 1, (int)ActionTypes.Responding, + It.IsAny(), 7, (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); + actionLogs.Verify(a => a.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), 20, It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task RespondToCall_CallInDifferentDepartment_ReturnsNotFound_AndDoesNotSetStatus() { @@ -446,7 +526,7 @@ public async Task RespondToCall_CallInDifferentDepartment_ReturnsNotFound_AndDoe .ReturnsAsync(new Call { CallId = 7, Name = "Fire", DepartmentId = 2 }); var actionLogs = new Mock(); - var handler = new RespondToCallHandler(calls.Object, actionLogs.Object); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, NoScope()); var response = await handler.HandleAsync(Msg("respond to c7"), Intent(ChatbotIntentType.RespondToCall, ("callId", "7")), Session(departmentId: 1)); // Cross-department call resolves to the same no-match reply as a nonexistent one (anti-IDOR). @@ -462,7 +542,7 @@ public async Task RespondToCall_Valid_SetsRespondingWithDestination() .ReturnsAsync(new Call { CallId = 7, Name = "Fire", DepartmentId = 1 }); var actionLogs = new Mock(); - var handler = new RespondToCallHandler(calls.Object, actionLogs.Object); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, NoScope()); var response = await handler.HandleAsync(Msg("respond to c7"), Intent(ChatbotIntentType.RespondToCall, ("callId", "7")), Session(departmentId: 1)); response.Processed.Should().BeTrue(); @@ -506,7 +586,7 @@ public async Task RespondToCall_BareResponse_UsesMostRecentDirectDispatchAndCust }); var actionLogs = new Mock(); - var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, customStates.Object); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, NoScope(), customStates.Object); var response = await handler.HandleAsync(Msg("omw"), Intent(ChatbotIntentType.RespondToCall, ("response", "yes")), Session()); @@ -540,7 +620,7 @@ public async Task RespondToCall_BareResponse_FallsBackToGroupDispatchMembership( new DepartmentGroupMember { UserId = "user-1", DepartmentGroupId = 5 } }); var actionLogs = new Mock(); - var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, null, groups.Object); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, NoScope(), null, groups.Object); var response = await handler.HandleAsync(Msg("responding"), Intent(ChatbotIntentType.RespondToCall, ("response", "yes")), Session()); @@ -574,7 +654,7 @@ public async Task RespondToCall_BareResponse_FallsBackToRoleDispatchMembership() new PersonnelRole { PersonnelRoleId = 7 } }); var actionLogs = new Mock(); - var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, null, null, roles.Object); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, NoScope(), null, null, roles.Object); var response = await handler.HandleAsync(Msg("omw"), Intent(ChatbotIntentType.RespondToCall, ("response", "yes")), Session()); @@ -624,7 +704,7 @@ public async Task RespondToCall_BareResponse_DirectAndNewerGroupDispatch_UsesNew new DepartmentGroupMember { UserId = "user-1", DepartmentGroupId = 5 } }); var actionLogs = new Mock(); - var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, null, groups.Object); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, NoScope(), null, groups.Object); // Act var response = await handler.HandleAsync(Msg("responding"), @@ -676,7 +756,7 @@ public async Task RespondToCall_BareResponse_DirectAndNewerRoleDispatch_UsesNewe new PersonnelRole { PersonnelRoleId = 7 } }); var actionLogs = new Mock(); - var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, null, null, roles.Object); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, NoScope(), null, null, roles.Object); // Act var response = await handler.HandleAsync(Msg("responding"), @@ -701,7 +781,7 @@ public async Task RespondToCall_NotGoing_UsesCustomNotRespondingStatus() }); var actionLogs = new Mock(); - var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, customStates.Object); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, NoScope(), customStates.Object); var response = await handler.HandleAsync(Msg("not going to c7"), Intent(ChatbotIntentType.RespondToCall, ("callId", "7"), ("response", "no")), Session()); diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotSecurityTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotSecurityTests.cs index 016b78448..2d046299a 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotSecurityTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotSecurityTests.cs @@ -12,6 +12,7 @@ using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; +using Resgrid.Tests.Helpers; using LinkingCodeEntity = Resgrid.Model.ChatbotLinkingCode; namespace Resgrid.Tests.Chatbot @@ -44,7 +45,7 @@ public async Task CallDetail_CallInDifferentDepartment_ReturnsNotFound_AndDoesNo var depts = new Mock(); var authz = new Mock(); - var handler = new CallDetailActionHandler(calls.Object, depts.Object, authz.Object); + var handler = new CallDetailActionHandler(calls.Object, depts.Object, authz.Object, DispatchScopeMocks.Off()); var response = await handler.HandleAsync(new ChatbotMessage { Text = "C5" }, CallDetailIntent("5"), Session(departmentId: 1)); @@ -65,7 +66,7 @@ public async Task CallDetail_CallInDepartment_WithoutViewPermission_IsDenied() var authz = new Mock(); authz.Setup(a => a.CanUserViewCallAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); - var handler = new CallDetailActionHandler(calls.Object, depts.Object, authz.Object); + var handler = new CallDetailActionHandler(calls.Object, depts.Object, authz.Object, DispatchScopeMocks.Off()); var response = await handler.HandleAsync(new ChatbotMessage { Text = "C5" }, CallDetailIntent("5"), Session(departmentId: 1)); diff --git a/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs b/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs index 048109e38..285ae57ee 100644 --- a/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs @@ -34,7 +34,7 @@ public async Task CallsList_FiltersForeignCallsBeforeApplyingTenCallLimit_WithOn calls.Setup(c => c.GetActiveCallsByDepartmentAsync(DepartmentId)).ReturnsAsync(candidates); var authorization = MemberAuthorization(); var handler = new CallsActionHandler(calls.Object, Mock.Of(), - Mock.Of(), Mock.Of(), authorization.Object); + Mock.Of(), Mock.Of(), authorization.Object, PassThroughScope()); var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.ListCalls }, Session()); @@ -46,6 +46,27 @@ public async Task CallsList_FiltersForeignCallsBeforeApplyingTenCallLimit_WithOn authorization.Verify(a => a.CanUserViewCallAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Test] + public async Task CallsList_ScopedUser_ListsOnlyCallsInTheirArea() + { + var active = new List + { + new Call { CallId = 101, DepartmentId = DepartmentId, Name = "MyAreaCall" }, + new Call { CallId = 202, DepartmentId = DepartmentId, Name = "OtherAreaCall" } + }; + var calls = new Mock(); + calls.Setup(c => c.GetActiveCallsByDepartmentAsync(DepartmentId)).ReturnsAsync(active); + var scope = new Mock(); + scope.Setup(x => x.FilterCallsForUserAsync(DepartmentId, UserId, active)) + .ReturnsAsync(new List { active[0] }); + var handler = new CallsActionHandler(calls.Object, Mock.Of(), + Mock.Of(), Mock.Of(), MemberAuthorization().Object, scope.Object); + + var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.ListCalls }, Session()); + + response.Text.Should().Contain("MyAreaCall").And.NotContain("OtherAreaCall"); + } + [Test] public async Task CallsList_OnlyForeignRows_ReportsNoActiveCalls() { @@ -53,7 +74,7 @@ public async Task CallsList_OnlyForeignRows_ReportsNoActiveCalls() calls.Setup(c => c.GetActiveCallsByDepartmentAsync(DepartmentId)) .ReturnsAsync(new List { new Call { CallId = 900, DepartmentId = 99, Name = "ForeignCall" } }); var handler = new CallsActionHandler(calls.Object, Mock.Of(), - Mock.Of(), Mock.Of(), MemberAuthorization().Object); + Mock.Of(), Mock.Of(), MemberAuthorization().Object, PassThroughScope()); var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.ListCalls }, Session()); @@ -68,7 +89,7 @@ public async Task CallsList_NonMember_IsDeniedBeforeAnyCallIsRead() var authorization = new Mock(); authorization.Setup(a => a.IsUserValidWithinLimitsAsync(UserId, DepartmentId)).ReturnsAsync(false); var handler = new CallsActionHandler(calls.Object, Mock.Of(), - Mock.Of(), Mock.Of(), authorization.Object); + Mock.Of(), Mock.Of(), authorization.Object, PassThroughScope()); var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.ListCalls }, Session()); @@ -251,6 +272,15 @@ private static List UnitCandidates() } private static UnitState State(int id, int departmentId, string name) => new() { UnitId = id, State = id, Unit = new Unit { UnitId = id, DepartmentId = departmentId, Name = name } }; + /// Group-scoped dispatch off: call lists come back unchanged. + private static IDispatchScopeService PassThroughScope() + { + var scope = new Mock(); + scope.Setup(x => x.FilterCallsForUserAsync(It.IsAny(), It.IsAny(), It.IsAny>())) + .ReturnsAsync((int departmentId, string userId, List calls) => calls); + return scope.Object; + } + private static Mock MemberAuthorization() { var authorization = new Mock(); diff --git a/Tests/Resgrid.Tests/Helpers/DispatchScopeMocks.cs b/Tests/Resgrid.Tests/Helpers/DispatchScopeMocks.cs new file mode 100644 index 000000000..152d9c494 --- /dev/null +++ b/Tests/Resgrid.Tests/Helpers/DispatchScopeMocks.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Moq; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Tests.Helpers +{ + /// Stand-ins for in tests that aren't about dispatch scope. + public static class DispatchScopeMocks + { + /// Group-scoped dispatch off: every user is department-wide and every call is in scope. + public static IDispatchScopeService Off() + { + var scope = new Mock(); + scope.Setup(x => x.GetScopeForUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int departmentId, string userId) => DispatchScope.DepartmentWide(departmentId, userId, DispatchScopeReasons.ScopingDisabled)); + scope.Setup(x => x.IsCallInScopeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + scope.Setup(x => x.CanUserAccessCallAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + scope.Setup(x => x.FilterCallsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((DispatchScope s, List calls) => calls); + scope.Setup(x => x.FilterCallsForUserAsync(It.IsAny(), It.IsAny(), It.IsAny>())) + .ReturnsAsync((int departmentId, string userId, List calls) => calls); + return scope.Object; + } + } +} diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs index ba20327a2..673b6579f 100644 --- a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs +++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs @@ -35,6 +35,8 @@ public class TranslationCompletenessTests "Areas/User/DataProtection/DataProtection", // Records (RMS) shipped fully translated in RMS-1; keep it that way. "Areas/User/Records/Records", + // ADP Protected Workflows (release panel, list, disclosure log, department toggle) shipped fully translated. + "Areas/User/ProtectedWorkflows/ProtectedWorkflows", }; private static string LocalizationRoot() @@ -197,6 +199,32 @@ private static Dictionary Load(string path) "Records|fr|AnalyticsTotal", // "Total" "Records|fr|AnalyticsIncidents", // "Incidents" + // Protected Workflows: a symbol and a protocol name in every locale, then words that are the same in the + // target language (checked side by side): the IT loanwords Workflow and Host, Status, Bytes, and French + // Destination/Actions/Type/Active (feminine, agreeing with "autorisation") and the noun "test". + "ProtectedWorkflows|de|ColSequence", "ProtectedWorkflows|es|ColSequence", "ProtectedWorkflows|fr|ColSequence", + "ProtectedWorkflows|it|ColSequence", "ProtectedWorkflows|pl|ColSequence", "ProtectedWorkflows|sv|ColSequence", + "ProtectedWorkflows|uk|ColSequence", "ProtectedWorkflows|el|ColSequence", "ProtectedWorkflows|ar|ColSequence", + // Protected Workflows for EHR integration: a regulation name and an HTTP header name, the same in every language. + "ProtectedWorkflows|de|SensitivityPart2", "ProtectedWorkflows|es|SensitivityPart2", "ProtectedWorkflows|fr|SensitivityPart2", + "ProtectedWorkflows|it|SensitivityPart2", "ProtectedWorkflows|pl|SensitivityPart2", "ProtectedWorkflows|sv|SensitivityPart2", + "ProtectedWorkflows|uk|SensitivityPart2", "ProtectedWorkflows|el|SensitivityPart2", "ProtectedWorkflows|ar|SensitivityPart2", + "ProtectedWorkflows|de|StepIfNoneExist", "ProtectedWorkflows|es|StepIfNoneExist", "ProtectedWorkflows|fr|StepIfNoneExist", + "ProtectedWorkflows|it|StepIfNoneExist", "ProtectedWorkflows|pl|StepIfNoneExist", "ProtectedWorkflows|sv|StepIfNoneExist", + "ProtectedWorkflows|uk|StepIfNoneExist", "ProtectedWorkflows|el|StepIfNoneExist", "ProtectedWorkflows|ar|StepIfNoneExist", + "ProtectedWorkflows|de|ColHttp", "ProtectedWorkflows|es|ColHttp", "ProtectedWorkflows|fr|ColHttp", + "ProtectedWorkflows|it|ColHttp", "ProtectedWorkflows|pl|ColHttp", "ProtectedWorkflows|sv|ColHttp", + "ProtectedWorkflows|uk|ColHttp", "ProtectedWorkflows|el|ColHttp", "ProtectedWorkflows|ar|ColHttp", + "ProtectedWorkflows|de|WorkflowsBreadcrumb", "ProtectedWorkflows|de|ColWorkflow", "ProtectedWorkflows|de|FilterWorkflow", + "ProtectedWorkflows|de|StatusLabel", "ProtectedWorkflows|de|ColStatus", "ProtectedWorkflows|de|ColHost", "ProtectedWorkflows|de|ColBytes", + "ProtectedWorkflows|es|ColHost", "ProtectedWorkflows|es|ColBytes", + "ProtectedWorkflows|fr|WorkflowsBreadcrumb", "ProtectedWorkflows|fr|ColWorkflow", "ProtectedWorkflows|fr|FilterWorkflow", + "ProtectedWorkflows|fr|State_Active", "ProtectedWorkflows|fr|DestinationHeader", "ProtectedWorkflows|fr|ColActions", + "ProtectedWorkflows|fr|ColType", "ProtectedWorkflows|fr|TestBadge", + "ProtectedWorkflows|it|ColHost", + "ProtectedWorkflows|pl|StatusLabel", "ProtectedWorkflows|pl|ColStatus", "ProtectedWorkflows|pl|ColHost", "ProtectedWorkflows|pl|TestBadge", + "ProtectedWorkflows|sv|StatusLabel", "ProtectedWorkflows|sv|ColStatus", "ProtectedWorkflows|sv|TestBadge", + // Counted phrases whose wording matches English. "CommunicationTest|es|ScopeRoles", // {0} roles "CommunicationTest|sv|ScopePerson", // 1 person diff --git a/Tests/Resgrid.Tests/Providers/ProtectedExecutorEhrTests.cs b/Tests/Resgrid.Tests/Providers/ProtectedExecutorEhrTests.cs new file mode 100644 index 000000000..608197d6d --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/ProtectedExecutorEhrTests.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Providers.Workflow.Executors; + +namespace Resgrid.Tests.Providers +{ + /// + /// The protected HTTP executor for EHR integration: success rules (HL7 ACK, FHIR OperationOutcome, JSON path), + /// response capture, the response size cap, the idempotency and If-None-Exist headers, and OAuth2 private_key_jwt. + /// + [TestFixture] + public class ProtectedExecutorEhrTests + { + private const string Host = "ehr.example.org"; + private const string Url = "https://ehr.example.org/fhir/r4"; + private const string TokenUrl = "https://ehr.example.org/oauth2/token"; + private const string Payload = "{\"resourceType\":\"Bundle\"}"; + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly Func _respond; + public List Requests { get; } = new List(); + public List Bodies { get; } = new List(); + + public RecordingHandler(Func respond) => _respond = respond; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + Bodies.Add(request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken)); + return _respond(request); + } + } + + [SetUp] + public void SetUp() => HttpApiExecutor.ClearTokenCache(); + + private static (HttpApiExecutor Executor, RecordingHandler Handler) Build(Func respond) + { + var handler = new RecordingHandler(respond); + return (new HttpApiExecutor(_ => handler, url => Task.FromResult((true, (string)null))), handler); + } + + private static WorkflowActionContext Context(object options, string credentialJson = "{\"token\":\"tok-1\"}", + int credentialType = (int)WorkflowCredentialType.HttpBearer, string pinnedAuthMethod = null, string idempotencyKey = "0123456789abcdef0123456789abcdef") + { + var config = JObject.FromObject(new { Url, ContentType = "application/fhir+json" }); + if (options != null) + config.Merge(JObject.FromObject(options)); + return new WorkflowActionContext + { + RenderedContent = Payload, + ActionConfigJson = config.ToString(Formatting.None), + DecryptedCredentialJson = credentialJson, + CredentialType = credentialType, + ActionType = (int)WorkflowActionType.CallApiPost, + ProtectedMode = true, + PinnedHost = Host, + PinnedTokenHost = credentialType == (int)WorkflowCredentialType.OAuth2ClientCredentials ? Host : null, + PinnedAuthMethod = pinnedAuthMethod, + IdempotencyKey = idempotencyKey + }; + } + + private static HttpResponseMessage Respond(HttpStatusCode status, string body = "", string mediaType = "application/json", Action headers = null) + { + var response = new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, mediaType) }; + headers?.Invoke(response); + return response; + } + + private const string Hl7Ack = "MSH|^~\\&|EHR|FAC|RESGRID|DEPT|20260924120000||ACK^T02^ACK|99|P|2.5.1\rMSA|{0}|0123456789abcdef0123456789abcdef|note\r"; + + // ── Success rules ─────────────────────────────────────────────────────────────────────────── + + [TestCase("AA", true)] + [TestCase("CA", true)] + [TestCase("AE", false)] + [TestCase("AR", false)] + public async Task an_hl7_acknowledgement_decides_success(string code, bool accepted) + { + var (executor, _) = Build(_ => Respond(HttpStatusCode.OK, string.Format(Hl7Ack, code), "x-application/hl7-v2+er7")); + + var result = await executor.ExecuteAsync(Context(new { ContentType = "x-application/hl7-v2+er7", SuccessRule = new { Type = "hl7_ack" } }), CancellationToken.None); + + result.Success.Should().Be(accepted); + if (!accepted) + { + result.ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedAck); + result.ErrorDetail.Should().Be($"ack_rejected: rule=hl7_ack ack={code}"); + ProtectedWorkflowRetryPolicy.IsRetryable(result.ProtectedOutcome, result.HttpStatus).Should().BeFalse("an AE or AR is a data problem: stop and alert"); + } + } + + [Test] + public async Task a_fhir_operation_outcome_error_is_a_rejection_even_inside_a_transaction_response() + { + const string bundle = "{\"resourceType\":\"Bundle\",\"type\":\"transaction-response\",\"entry\":[{\"response\":{\"status\":\"400\",\"outcome\":" + + "{\"resourceType\":\"OperationOutcome\",\"issue\":[{\"severity\":\"error\",\"code\":\"invalid\",\"diagnostics\":\"SECRET-ECHO\"}]}}}]}"; + var (executor, _) = Build(_ => Respond(HttpStatusCode.OK, bundle)); + + var result = await executor.ExecuteAsync(Context(new { SuccessRule = new { Type = "fhir_operation_outcome" } }), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedAck); + result.ErrorDetail.Should().NotContain("SECRET-ECHO", "response content never reaches the run log"); + + var (fine, _) = Build(_ => Respond(HttpStatusCode.OK, "{\"resourceType\":\"OperationOutcome\",\"issue\":[{\"severity\":\"information\"}]}")); + (await fine.ExecuteAsync(Context(new { SuccessRule = new { Type = "fhir_operation_outcome" } }), CancellationToken.None)).Success.Should().BeTrue(); + } + + [Test] + public async Task a_json_path_mismatch_is_a_rejection() + { + var (executor, _) = Build(_ => Respond(HttpStatusCode.OK, "{\"status\":\"queued\"}")); + + var result = await executor.ExecuteAsync(Context(new { SuccessRule = new { Type = "json_path", Path = "$.status", Expected = "ok" } }), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedAck); + + var (ok, _) = Build(_ => Respond(HttpStatusCode.OK, "{\"status\":\"ok\"}")); + (await ok.ExecuteAsync(Context(new { SuccessRule = new { Type = "json_path", Path = "$.status", Expected = "ok" } }), CancellationToken.None)).Success.Should().BeTrue(); + } + + [Test] + public async Task an_xpath_rule_reads_namespaced_xml_and_refuses_dtds() + { + const string soap = "accepted"; + var (executor, _) = Build(_ => Respond(HttpStatusCode.OK, soap, "application/soap+xml")); + var options = new { ContentType = "application/soap+xml", SuccessRule = new { Type = "xpath", Path = "//*[local-name()='status']", Expected = "accepted" } }; + + (await executor.ExecuteAsync(Context(options), CancellationToken.None)).Success.Should().BeTrue(); + + var (dtd, _) = Build(_ => Respond(HttpStatusCode.OK, "]>&s;", "application/xml")); + (await dtd.ExecuteAsync(Context(options), CancellationToken.None)).ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedAck); + } + + [TestCase(HttpStatusCode.ServiceUnavailable, true)] + [TestCase((HttpStatusCode)429, true)] + [TestCase(HttpStatusCode.BadRequest, false)] + [TestCase(HttpStatusCode.Conflict, false)] + public async Task only_5xx_and_429_are_retryable(HttpStatusCode status, bool retryable) + { + var (executor, _) = Build(_ => Respond(status, "{}")); + + var result = await executor.ExecuteAsync(Context(new { SuccessRule = new { Type = "fhir_operation_outcome" } }), CancellationToken.None); + + result.Success.Should().BeFalse(); + ProtectedWorkflowRetryPolicy.IsRetryable(result.ProtectedOutcome, result.HttpStatus).Should().Be(retryable); + } + + // ── Capture ───────────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task values_are_captured_from_headers_json_hl7_and_fhir_locations() + { + var (fhir, _) = Build(_ => Respond(HttpStatusCode.OK, + "{\"resourceType\":\"Bundle\",\"entry\":[{\"response\":{\"location\":\"Observation/77/_history/1\"}},{\"response\":{\"location\":\"Encounter/E-551/_history/1\"}}]}", + headers: r => r.Headers.Add("X-Correlation", "corr-9"))); + var fhirResult = await fhir.ExecuteAsync(Context(new + { + ResponseCapture = new object[] + { + new { Source = "fhir_location_id", Expression = "Encounter", Key = "ehr_encounter_id" }, + new { Source = "header", Expression = "X-Correlation", Key = "ehr_correlation" }, + new { Source = "json_path", Expression = "$.entry[0].response.location", Key = "first_location" } + } + }), CancellationToken.None); + + fhirResult.Success.Should().BeTrue(); + fhirResult.CapturedValues.Should().BeEquivalentTo(new Dictionary + { + ["ehr_encounter_id"] = "E-551", + ["ehr_correlation"] = "corr-9", + ["first_location"] = "Observation/77/_history/1" + }); + fhirResult.ResultMessage.Should().Be("HTTP 200 OK", "captured values never go into the result message"); + + var (created, _) = Build(_ => Respond(HttpStatusCode.Created, "", headers: r => r.Headers.Location = new Uri("https://ehr.example.org/fhir/r4/Encounter/12345/_history/1"))); + (await created.ExecuteAsync(Context(new { ResponseCapture = new[] { new { Source = "fhir_location_id", Key = "ehr_encounter_id" } } }), CancellationToken.None)) + .CapturedValues["ehr_encounter_id"].Should().Be("12345"); + + var (hl7, _) = Build(_ => Respond(HttpStatusCode.OK, string.Format(Hl7Ack, "AA"), "x-application/hl7-v2+er7")); + var hl7Result = await hl7.ExecuteAsync(Context(new + { + ContentType = "x-application/hl7-v2+er7", + SuccessRule = new { Type = "hl7_ack" }, + ResponseCapture = new[] { new { Source = "hl7_field", Expression = "MSA-2", Key = "ehr_message_id" }, new { Source = "hl7_field", Expression = "MSH-10", Key = "ehr_ack_id" } } + }), CancellationToken.None); + hl7Result.CapturedValues["ehr_message_id"].Should().Be("0123456789abcdef0123456789abcdef"); + hl7Result.CapturedValues["ehr_ack_id"].Should().Be("99"); + } + + [Test] + public async Task a_missing_capture_is_reported_by_key_only() + { + var (executor, _) = Build(_ => Respond(HttpStatusCode.OK, "{\"status\":\"ok\"}")); + + var result = await executor.ExecuteAsync(Context(new { ResponseCapture = new[] { new { Source = "json_path", Expression = "$.id", Key = "ehr_encounter_id" } } }), CancellationToken.None); + + result.Success.Should().BeTrue(); + result.CapturedValues.Should().BeNull(); + result.ResultMessage.Should().Be("HTTP 200 OK capture_missing=[ehr_encounter_id]"); + } + + [Test] + public async Task a_response_over_the_cap_fails_without_being_read_into_anything() + { + var big = new string('x', Resgrid.Config.DataProtectionConfig.ProtectedWorkflowMaxResponseBytes + 1); + var (executor, _) = Build(_ => Respond(HttpStatusCode.OK, "{\"id\":\"" + big + "\"}")); + + var result = await executor.ExecuteAsync(Context(new { ResponseCapture = new[] { new { Source = "json_path", Expression = "$.id", Key = "ehr_encounter_id" } } }), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedResponseTooLarge); + result.ErrorDetail.Should().Be(ProtectedWorkflowErrorCodes.ResponseTooLarge); + result.CapturedValues.Should().BeNull(); + } + + [Test] + public async Task without_a_rule_or_capture_the_body_is_never_read() + { + var (executor, _) = Build(_ => Respond(HttpStatusCode.OK, new string('x', Resgrid.Config.DataProtectionConfig.ProtectedWorkflowMaxResponseBytes * 2))); + + (await executor.ExecuteAsync(Context(null), CancellationToken.None)).Success.Should().BeTrue("an oversized body is only a problem when it has to be read"); + } + + // ── Idempotency ───────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task the_idempotency_key_and_if_none_exist_travel_as_headers() + { + var (executor, handler) = Build(_ => Respond(HttpStatusCode.Created)); + + await executor.ExecuteAsync(Context(new + { + IdempotencyHeader = "Idempotency-Key", + IfNoneExist = "identifier=https://resgrid.com/call|1001", + Headers = new Dictionary { ["Idempotency-Key"] = "overridden?", ["If-None-Exist"] = "overridden?" } + }), CancellationToken.None); + + var request = handler.Requests.Single(); + request.Headers.GetValues("Idempotency-Key").Should().Equal("0123456789abcdef0123456789abcdef"); + request.Headers.GetValues("If-None-Exist").Should().Equal("identifier=https://resgrid.com/call|1001"); + request.Content.Headers.ContentType.MediaType.Should().Be("application/fhir+json"); + } + + [Test] + public async Task a_content_type_outside_the_allowlist_is_refused_before_sending() + { + var (executor, handler) = Build(_ => Respond(HttpStatusCode.OK)); + + var result = await executor.ExecuteAsync(Context(new { ContentType = "text/html" }), CancellationToken.None); + + handler.Requests.Should().BeEmpty(); + result.ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedValidation); + } + + // ── OAuth2 private_key_jwt ────────────────────────────────────────────────────────────────── + + [TestCase(WorkflowJwtKeys.Rs384)] + [TestCase(WorkflowJwtKeys.Es384)] + public async Task private_key_jwt_sends_a_signed_client_assertion_instead_of_a_secret(string alg) + { + var (signing, published) = WorkflowJwtKeys.Generate(alg, DateTime.UtcNow); + var credential = JsonConvert.SerializeObject(new + { + tokenUrl = TokenUrl, clientId = "resgrid-client", scope = "system/Encounter.write system/Observation.write", + authMethod = "private_key_jwt", signingKeys = new[] { signing } + }); + var (executor, handler) = Build(request => request.RequestUri.AbsolutePath.EndsWith("/token") + ? Respond(HttpStatusCode.OK, "{\"access_token\":\"at-1\",\"expires_in\":300}") + : Respond(HttpStatusCode.Created)); + + var result = await executor.ExecuteAsync(Context(null, credential, (int)WorkflowCredentialType.OAuth2ClientCredentials, "private_key_jwt"), CancellationToken.None); + + result.Success.Should().BeTrue(result.ErrorDetail); + var form = handler.Bodies[0].Split('&').Select(p => p.Split('=')).ToDictionary(p => p[0], p => Uri.UnescapeDataString(p[1].Replace('+', ' '))); + form["grant_type"].Should().Be("client_credentials"); + form["client_assertion_type"].Should().Be(WorkflowJwtKeys.AssertionType); + form["scope"].Should().Be("system/Encounter.write system/Observation.write"); + form.Should().NotContainKey("client_secret"); + + var assertion = form["client_assertion"]; + WorkflowJwtKeys.Verify(assertion, published.Jwk).Should().BeTrue("the assertion is signed with the published key"); + var parts = assertion.Split('.'); + var header = JObject.Parse(Encoding.UTF8.GetString(WorkflowJwtKeys.FromBase64Url(parts[0]))); + var claims = JObject.Parse(Encoding.UTF8.GetString(WorkflowJwtKeys.FromBase64Url(parts[1]))); + header.Value("alg").Should().Be(alg); + header.Value("kid").Should().Be(signing.Kid); + claims.Value("iss").Should().Be("resgrid-client"); + claims.Value("sub").Should().Be("resgrid-client"); + claims.Value("aud").Should().Be(TokenUrl); + (claims.Value("exp") - claims.Value("iat")).Should().BeInRange(1, 300); + claims.Value("jti").Should().NotBeNullOrWhiteSpace(); + handler.Requests[1].Headers.Authorization.Parameter.Should().Be("at-1"); + } + + [Test] + public async Task an_auth_method_other_than_the_pinned_one_is_refused() + { + var (executor, handler) = Build(_ => Respond(HttpStatusCode.OK)); + var credential = JsonConvert.SerializeObject(new { tokenUrl = TokenUrl, clientId = "c", clientSecret = "s", authMethod = "client_secret" }); + + var result = await executor.ExecuteAsync(Context(null, credential, (int)WorkflowCredentialType.OAuth2ClientCredentials, "private_key_jwt"), CancellationToken.None); + + handler.Requests.Should().BeEmpty("the secret goes nowhere when the approval pinned a signed assertion"); + result.ErrorDetail.Should().Be(ProtectedWorkflowErrorCodes.AuthMethodMismatch); + } + } +} diff --git a/Tests/Resgrid.Tests/Providers/ProtectedHttpApiExecutorTests.cs b/Tests/Resgrid.Tests/Providers/ProtectedHttpApiExecutorTests.cs new file mode 100644 index 000000000..92a677708 --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/ProtectedHttpApiExecutorTests.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Providers.Workflow.Executors; + +namespace Resgrid.Tests.Providers +{ + /// + /// HttpApiExecutor in Protected Workflow mode: pinned host re-checked on the rendered URL, no redirects, status line + /// only (the body is never read), the payload hash of the exact bytes, the OAuth2 token host pinned too, and the + /// auth scheme inferred from the credential type. + /// + [TestFixture] + public class ProtectedHttpApiExecutorTests + { + private const string Host = "org.crm.dynamics.com"; + private const string Url = "https://org.crm.dynamics.com/api/data/v9.2/incidents(1)"; + private const string Payload = "{\"closure\":\"SENTINEL-EXEC-4471\"}"; + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly Func _respond; + public List Requests { get; } = new List(); + public List Bodies { get; } = new List(); + + public RecordingHandler(Func respond) => _respond = respond; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + Bodies.Add(request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken)); + return _respond(request); + } + } + + private static (HttpApiExecutor Executor, RecordingHandler Handler) Build(Func respond) + { + var handler = new RecordingHandler(respond); + var executor = new HttpApiExecutor(_ => handler, url => Task.FromResult((true, (string)null))); + return (executor, handler); + } + + private static WorkflowActionContext Context(string url = Url, string pinned = Host, int credentialType = (int)WorkflowCredentialType.HttpBearer, + string credentialJson = "{\"token\":\"tok-1\"}", string pinnedTokenHost = null, int actionType = (int)WorkflowActionType.CallApiPut, + string pinnedAuthMethod = WorkflowJwtKeys.ClientSecret, object extraConfig = null) => + new WorkflowActionContext + { + RenderedContent = Payload, + ActionConfigJson = ConfigJson(url, extraConfig), + DecryptedCredentialJson = credentialJson, + CredentialType = credentialType, + ActionType = actionType, + ProtectedMode = true, + PinnedHost = pinned, + PinnedTokenHost = pinnedTokenHost, + PinnedAuthMethod = credentialType == (int)WorkflowCredentialType.OAuth2ClientCredentials ? pinnedAuthMethod : null + }; + + private static string ConfigJson(string url, object extra) + { + var config = Newtonsoft.Json.Linq.JObject.FromObject(new { Url = url, Headers = new Dictionary { ["Host"] = "attacker.example", ["Prefer"] = "return=minimal" } }); + if (extra != null) + config.Merge(Newtonsoft.Json.Linq.JObject.FromObject(extra)); + return config.ToString(Formatting.None); + } + + [SetUp] + public void SetUp() => HttpApiExecutor.ClearTokenCache(); + + [Test] + public async Task a_successful_send_reports_the_status_line_and_the_hash_of_the_exact_bytes() + { + var (executor, handler) = Build(_ => new HttpResponseMessage(HttpStatusCode.NoContent) { Content = new StringContent("echo " + Payload) }); + + var result = await executor.ExecuteAsync(Context(), CancellationToken.None); + + result.Success.Should().BeTrue(); + result.ResultMessage.Should().Be("HTTP 204 No Content"); + result.HttpStatus.Should().Be(204); + result.PayloadBytes.Should().Be(Encoding.UTF8.GetByteCount(Payload)); + result.PayloadSha256.Should().Be(ProtectedWorkflowDisclosureChain.Sha256Hex(Encoding.UTF8.GetBytes(Payload))); + handler.Bodies.Single().Should().Be(Payload); + handler.Requests.Single().Method.Should().Be(HttpMethod.Put); + handler.Requests.Single().Headers.Authorization.ToString().Should().Be("Bearer tok-1", "the auth scheme comes from the credential type"); + handler.Requests.Single().Headers.Contains("Host").Should().BeFalse("a configured Host header could re-route the request"); + handler.Requests.Single().Headers.Contains("Prefer").Should().BeTrue(); + } + + [Test] + public async Task a_failure_never_carries_the_response_body() + { + var (executor, _) = Build(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("invalid: " + Payload) }); + + var result = await executor.ExecuteAsync(Context(), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedHttp); + (result.ResultMessage + result.ErrorDetail).Should().NotContain("SENTINEL-EXEC-4471"); + result.ResultMessage.Should().Be("HTTP 400 Bad Request"); + } + + [TestCase(HttpStatusCode.Found)] + [TestCase(HttpStatusCode.TemporaryRedirect)] + [TestCase(HttpStatusCode.PermanentRedirect)] + public async Task a_redirect_is_never_followed_and_is_blocked_host(HttpStatusCode status) + { + var (executor, handler) = Build(_ => + { + var response = new HttpResponseMessage(status); + response.Headers.Location = new Uri("https://attacker.example/collect"); + return response; + }); + + var result = await executor.ExecuteAsync(Context(), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.BlockedHost); + result.HttpStatus.Should().Be((int)status); + handler.Requests.Should().ContainSingle().Which.RequestUri.Host.Should().Be(Host); + } + + [TestCase("https://attacker.example/api", ProtectedWorkflowErrorCodes.HostMismatch)] + [TestCase("http://org.crm.dynamics.com/api", ProtectedWorkflowErrorCodes.SchemeNotHttps)] + [TestCase("https://org.crm.dynamics.com.attacker.example/api", ProtectedWorkflowErrorCodes.HostMismatch)] + [TestCase("https://user@org.crm.dynamics.com/api", ProtectedWorkflowErrorCodes.HostMismatch)] + public async Task a_rendered_url_off_the_pinned_host_never_leaves(string url, string code) + { + var (executor, handler) = Build(_ => new HttpResponseMessage(HttpStatusCode.OK)); + + var result = await executor.ExecuteAsync(Context(url), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.BlockedHost); + result.ErrorDetail.Should().Be(code); + handler.Requests.Should().BeEmpty(); + } + + [Test] + public async Task only_post_and_put_are_allowed_and_basic_is_refused_by_default() + { + var (executor, handler) = Build(_ => new HttpResponseMessage(HttpStatusCode.OK)); + + (await executor.ExecuteAsync(Context(actionType: (int)WorkflowActionType.CallApiGet), CancellationToken.None)).ProtectedOutcome + .Should().Be(ProtectedWorkflowDisclosureOutcomes.BlockedRelease); + (await executor.ExecuteAsync(Context(credentialType: (int)WorkflowCredentialType.HttpBasic, credentialJson: "{\"username\":\"u\",\"password\":\"p\"}"), CancellationToken.None)) + .ErrorDetail.Should().Be(ProtectedWorkflowErrorCodes.CredentialNotAllowed); + handler.Requests.Should().BeEmpty(); + } + + [Test] + public async Task oauth2_tokens_are_fetched_from_the_pinned_token_host_and_cached() + { + var (executor, handler) = Build(request => request.RequestUri.Host == "login.microsoftonline.com" + ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"access_token\":\"at-1\",\"expires_in\":3600,\"token_type\":\"Bearer\"}") } + : new HttpResponseMessage(HttpStatusCode.NoContent)); + var oauth = "{\"tokenUrl\":\"https://login.microsoftonline.com/tenant/oauth2/v2.0/token\",\"clientId\":\"c\",\"clientSecret\":\"s\",\"scope\":\"https://org.crm.dynamics.com/.default\"}"; + + var first = await executor.ExecuteAsync(Context(credentialType: (int)WorkflowCredentialType.OAuth2ClientCredentials, credentialJson: oauth, pinnedTokenHost: "login.microsoftonline.com"), CancellationToken.None); + var second = await executor.ExecuteAsync(Context(credentialType: (int)WorkflowCredentialType.OAuth2ClientCredentials, credentialJson: oauth, pinnedTokenHost: "login.microsoftonline.com"), CancellationToken.None); + + first.Success.Should().BeTrue(); + second.Success.Should().BeTrue(); + handler.Requests.Count(r => r.RequestUri.Host == "login.microsoftonline.com").Should().Be(1, "the token is reused until 60 seconds before it expires"); + handler.Bodies.First().Should().Contain("grant_type=client_credentials").And.Contain("scope=https"); + handler.Requests.Where(r => r.RequestUri.Host == Host).Should().OnlyContain(r => r.Headers.Authorization.ToString() == "Bearer at-1"); + } + + [Test] + public async Task an_oauth2_token_url_off_the_pinned_token_host_is_blocked_before_the_secret_leaves() + { + var (executor, handler) = Build(_ => new HttpResponseMessage(HttpStatusCode.OK)); + var oauth = "{\"tokenUrl\":\"https://attacker.example/token\",\"clientId\":\"c\",\"clientSecret\":\"s\"}"; + + var result = await executor.ExecuteAsync(Context(credentialType: (int)WorkflowCredentialType.OAuth2ClientCredentials, credentialJson: oauth, pinnedTokenHost: "login.microsoftonline.com"), CancellationToken.None); + + result.ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.BlockedHost); + result.ErrorDetail.Should().Be(ProtectedWorkflowErrorCodes.TokenHostMismatch); + handler.Requests.Should().BeEmpty(); + } + + [Test] + public async Task a_transport_failure_reports_a_code_and_type_without_the_payload() + { + var (executor, _) = Build(_ => throw new HttpRequestException("connection reset while sending " + Payload)); + + var result = await executor.ExecuteAsync(Context(), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ProtectedOutcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedHttp); + result.ErrorDetail.Should().StartWith(ProtectedWorkflowErrorCodes.HttpFailed + ": System.Net.Http.HttpRequestException"); + result.ErrorDetail.Should().NotContain("SENTINEL-EXEC-4471", "an exception message can quote the request, so it is never kept"); + } + + [Test] + public void the_protected_handler_never_follows_redirects_and_requires_tls12_or_later() + { + using var handler = (SocketsHttpHandler)HttpApiExecutor.CreateHandler(true); + + handler.AllowAutoRedirect.Should().BeFalse(); + handler.UseCookies.Should().BeFalse(); + handler.SslOptions.EnabledSslProtocols.Should().Be(System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13); + } + + [Test] + public void the_auth_scheme_is_inferred_from_the_credential_type_when_the_json_has_none() + { + HttpApiExecutor.ResolveAuthType(new HttpCredential { Token = "t" }, (int)WorkflowCredentialType.HttpBearer).Should().Be("bearer"); + HttpApiExecutor.ResolveAuthType(new HttpCredential { ApiKey = "k", HeaderName = "X" }, (int)WorkflowCredentialType.HttpApiKey).Should().Be("apikey"); + HttpApiExecutor.ResolveAuthType(new HttpCredential { ClientId = "c" }, (int)WorkflowCredentialType.OAuth2ClientCredentials).Should().Be("oauth2"); + HttpApiExecutor.ResolveAuthType(new HttpCredential { AuthType = "Basic" }, (int)WorkflowCredentialType.HttpBearer).Should().Be("basic"); + } + } +} diff --git a/Tests/Resgrid.Tests/Repositories/ShiftQueryTests.cs b/Tests/Resgrid.Tests/Repositories/ShiftQueryTests.cs new file mode 100644 index 000000000..6e9c5a5cd --- /dev/null +++ b/Tests/Resgrid.Tests/Repositories/ShiftQueryTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Repositories.DataRepository.Queries.Shifts; +using Resgrid.Repositories.DataRepository.Servers.PostgreSql; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; + +namespace Resgrid.Tests.Repositories +{ + [TestFixture] + public class ShiftQueryTests + { + [Test] + public void Open_trade_requests_match_on_the_invited_user() + { + var query = new SelectOpenShiftSignupTradesByUserIdQuery(new SqlServerConfiguration()).GetQuery(); + + // This used to read "sst.UserId = @UserId AND sst.UserId != @UserId", which can never be true, so nobody ever + // saw a trade they had been asked to take. + query.Should().Contain("sstu.[UserId] = @UserId"); + query.Should().NotContain("!="); + query.Should().Contain("[dbo].ShiftSignupTradeUsers sstu"); + query.Should().NotContain("%"); + } + + [Test] + public void Department_trades_join_both_signups_for_the_roster() + { + var query = new SelectShiftSignupTradesByDepartmentIdQuery(new SqlServerConfiguration()).GetQuery(); + + query.Should().Contain("SELECT sst.*, ss.*, ts.*"); + query.Should().Contain("LEFT JOIN [dbo].ShiftSignups ts ON ts.[ShiftSignupId] = sst.[TargetShiftSignupId]"); + query.Should().Contain("s.[DepartmentId] = @DepartmentId"); + query.Should().Contain("@StartDate"); + query.Should().NotContain("%"); + } + + [Test] + public void Department_signups_are_bounded_by_the_date_range() + { + var query = new SelectShiftSignupsByDepartmentIdAndDateRangeQuery(new SqlServerConfiguration()).GetQuery(); + + query.Should().Contain("ss.[ShiftDay] >= @StartDate AND ss.[ShiftDay] < @EndDate"); + query.Should().Contain("s.[DepartmentId] = @DepartmentId"); + query.Should().NotContain("%"); + } + + [Test] + public void Offered_trade_days_are_found_by_signup() + { + var query = new SelectShiftSignupTradeUserShiftsBySignupIdQuery(new SqlServerConfiguration()).GetQuery(); + + query.Should().Contain("ShiftSignupTradeUserShifts WHERE [ShiftSignupId] = @ShiftSignupId"); + } + + [Test] + public void Postgres_department_shift_json_carries_signups_and_standing_roster_groups() + { + var config = new PostgreSqlConfiguration(); + + // Without the alias the signups column came back as "jsonb_agg" and never reached Shift.Signups, and without + // GroupId every standing-roster person on Postgres lost their team. + foreach (var query in new[] { config.SelectShiftsByDidJSONQuery, config.SelectShiftByShiftIdJSONQuery, config.SelectShiftAndDaysJSONQuery, config.SelectUpcomingShiftAndDaysJSONQuery }) + { + query.Should().Contain(") signups"); + query.Should().Contain("'GroupId', sp.groupid"); + query.Should().Contain("'ApprovalPending', ss.approvalpending"); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs b/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs index 26b54684a..336cf23ac 100644 --- a/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs +++ b/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs @@ -103,7 +103,7 @@ public void SetUp() _unitsController = new UnitsController(_departments.Object, Mock.Of(), _units.Object, _authorization.Object, Mock.Of(), groups.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>(), Mock.Of(), - _protectedRead.Object, _cutover.Object) + _protectedRead.Object, _cutover.Object, Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = _http } }; } diff --git a/Tests/Resgrid.Tests/Services/AuthorizationServiceTests.cs b/Tests/Resgrid.Tests/Services/AuthorizationServiceTests.cs index ecb64f781..b1708ea99 100644 --- a/Tests/Resgrid.Tests/Services/AuthorizationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/AuthorizationServiceTests.cs @@ -36,6 +36,7 @@ public class with_the_authorization_service : TestBase protected Mock _cacheProviderMock; protected Mock _contactsServiceMock; protected Mock _eventAggregatorMock; + protected Mock _dispatchScopeServiceMock; protected with_the_authorization_service() { @@ -59,6 +60,12 @@ protected with_the_authorization_service() _cacheProviderMock = new Mock(); _contactsServiceMock = new Mock(); _eventAggregatorMock = new Mock(); + _dispatchScopeServiceMock = new Mock(); + + // Group-scoped dispatch is off by default: every call is in scope. + _dispatchScopeServiceMock + .Setup(x => x.CanUserAccessCallAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); _authorizationService = new AuthorizationService( _departmentsServiceMock.Object, @@ -80,7 +87,8 @@ protected with_the_authorization_service() _notesServiceMock.Object, _cacheProviderMock.Object, _contactsServiceMock.Object, - _eventAggregatorMock.Object); + _eventAggregatorMock.Object, + _dispatchScopeServiceMock.Object); } /// diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs index da0fa12fa..3422e764a 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs @@ -321,7 +321,8 @@ private void SetupAuthService() new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object); + new Mock().Object, + new Mock().Object); } [Test] diff --git a/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs b/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs index f5fc6032f..4071a6a42 100644 --- a/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Moq; @@ -66,13 +67,10 @@ public async Task ApplyDispatchStatusesAsync_uses_default_shift_and_unit_dispatc _departmentSettingsService.Setup(x => x.GetAutoSetStatusForShiftDispatchPersonnelAsync(7)).ReturnsAsync(true); _departmentSettingsService.Setup(x => x.GetShiftCallDispatchPersonnelStatusToSetAsync(7)).ReturnsAsync(-1); _departmentSettingsService.Setup(x => x.GetUnitCallDispatchStatusToSetAsync(7)).ReturnsAsync(-1); + // On duty at dispatch time (the call's logged time), from the resolved shift roster. _shiftsService - .Setup(x => x.GetShiftSignupsByDepartmentGroupIdAndDayAsync(5, It.Is(d => d == new DateTime(2026, 1, 12)))) - .ReturnsAsync(new List - { - new ShiftSignup { UserId = "user1" }, - new ShiftSignup { UserId = "user2" } - }); + .Setup(x => x.GetOnDutyUserIdsForGroupsAsync(7, It.Is>(g => g.Contains(5)), new DateTime(2026, 1, 12, 15, 0, 0, DateTimeKind.Utc))) + .ReturnsAsync(new Dictionary> { { 5, new List { "user1", "user2" } } }); await _service.ApplyDispatchStatusesAsync(call); @@ -104,8 +102,8 @@ public async Task ApplyReleaseStatusesAsync_uses_configured_release_statuses() _departmentSettingsService.Setup(x => x.GetShiftCallReleasePersonnelStatusToSetAsync(7)).ReturnsAsync((int)ActionTypes.AvailableStation); _departmentSettingsService.Setup(x => x.GetUnitCallReleaseStatusToSetAsync(7)).ReturnsAsync((int)UnitStateTypes.Returning); _shiftsService - .Setup(x => x.GetShiftSignupsByDepartmentGroupIdAndDayAsync(5, It.Is(d => d == new DateTime(2026, 2, 4)))) - .ReturnsAsync(new List { new ShiftSignup { UserId = "user1" } }); + .Setup(x => x.GetOnDutyUserIdsForGroupsAsync(7, It.Is>(g => g.Contains(5)), new DateTime(2026, 2, 4, 9, 30, 0, DateTimeKind.Utc))) + .ReturnsAsync(new Dictionary> { { 5, new List { "user1" } } }); await _service.ApplyReleaseStatusesAsync(call, new[] { 5 }, new[] { 11 }); @@ -137,7 +135,7 @@ public async Task ApplyDispatchStatusesAsync_skips_shift_personnel_when_auto_sta await _service.ApplyDispatchStatusesAsync(call, new[] { 5 }, new[] { 11 }); - _shiftsService.Verify(x => x.GetShiftSignupsByDepartmentGroupIdAndDayAsync(It.IsAny(), It.IsAny()), Times.Never); + _shiftsService.Verify(x => x.GetOnDutyUserIdsForGroupsAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); _actionLogsService.Verify(x => x.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _unitsService.Verify(x => x.SetUnitStateAsync( It.Is(s => diff --git a/Tests/Resgrid.Tests/Services/DepartmentGroupHierarchyTests.cs b/Tests/Resgrid.Tests/Services/DepartmentGroupHierarchyTests.cs new file mode 100644 index 000000000..206bfd5a0 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DepartmentGroupHierarchyTests.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class DepartmentGroupHierarchyTests + { + // Service Area 1 (org) -> Station A (station), Office B (org) -> Office B Night (org) + // Service Area 2 (org) -> Station C (station) + private const int ServiceArea1 = 1; + private const int StationA = 11; + private const int OfficeB = 12; + private const int OfficeBNight = 121; + private const int ServiceArea2 = 2; + private const int StationC = 21; + + private static List Groups() + { + return new List + { + new DepartmentGroup { DepartmentGroupId = ServiceArea1, Name = "Service Area 1", Type = (int)DepartmentGroupTypes.Orginizational }, + new DepartmentGroup { DepartmentGroupId = StationA, Name = "Station A", Type = (int)DepartmentGroupTypes.Station, ParentDepartmentGroupId = ServiceArea1 }, + new DepartmentGroup { DepartmentGroupId = OfficeB, Name = "Office B", Type = (int)DepartmentGroupTypes.Orginizational, ParentDepartmentGroupId = ServiceArea1 }, + new DepartmentGroup { DepartmentGroupId = OfficeBNight, Name = "Office B Night", Type = (int)DepartmentGroupTypes.Orginizational, ParentDepartmentGroupId = OfficeB }, + new DepartmentGroup { DepartmentGroupId = ServiceArea2, Name = "Service Area 2", Type = (int)DepartmentGroupTypes.Orginizational }, + new DepartmentGroup { DepartmentGroupId = StationC, Name = "Station C", Type = (int)DepartmentGroupTypes.Station, ParentDepartmentGroupId = ServiceArea2 } + }; + } + + [Test] + public void descendants_include_the_root_and_every_level_beneath_it() + { + DepartmentGroupHierarchy.GetSelfAndDescendantIds(Groups(), ServiceArea1) + .Should().BeEquivalentTo(new[] { ServiceArea1, StationA, OfficeB, OfficeBNight }); + } + + [Test] + public void descendants_of_a_leaf_are_just_the_leaf() + { + DepartmentGroupHierarchy.GetSelfAndDescendantIds(Groups(), StationC).Should().BeEquivalentTo(new[] { StationC }); + } + + [Test] + public void descendants_of_an_unknown_group_are_empty() + { + DepartmentGroupHierarchy.GetSelfAndDescendantIds(Groups(), 999).Should().BeEmpty(); + } + + [Test] + public void ancestors_are_nearest_first() + { + DepartmentGroupHierarchy.GetAncestorIds(Groups(), OfficeBNight).Should().Equal(OfficeB, ServiceArea1); + } + + [Test] + public void a_parent_cycle_terminates() + { + var groups = new List + { + new DepartmentGroup { DepartmentGroupId = 1, ParentDepartmentGroupId = 2 }, + new DepartmentGroup { DepartmentGroupId = 2, ParentDepartmentGroupId = 1 } + }; + + DepartmentGroupHierarchy.GetSelfAndDescendantIds(groups, 1).Should().BeEquivalentTo(new[] { 1, 2 }); + DepartmentGroupHierarchy.GetAncestorIds(groups, 1).Should().Equal(2); + } + + [Test] + public void a_station_group_can_sit_under_an_organizational_group() + { + DepartmentGroupHierarchy.IsValidParent(Groups(), StationC, ServiceArea1).Should().BeTrue(); + } + + [Test] + public void a_group_cannot_be_its_own_parent() + { + DepartmentGroupHierarchy.IsValidParent(Groups(), StationA, StationA).Should().BeFalse(); + } + + [Test] + public void a_group_cannot_move_under_its_own_descendant() + { + DepartmentGroupHierarchy.IsValidParent(Groups(), ServiceArea1, OfficeBNight).Should().BeFalse(); + } + + [Test] + public void a_parent_outside_the_department_list_is_rejected() + { + DepartmentGroupHierarchy.IsValidParent(Groups(), StationA, 999).Should().BeFalse(); + } + + [Test] + public void a_new_group_can_take_any_existing_parent() + { + DepartmentGroupHierarchy.IsValidParent(Groups(), 0, ServiceArea2).Should().BeTrue(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/DispatchScopeServiceTests.cs b/Tests/Resgrid.Tests/Services/DispatchScopeServiceTests.cs new file mode 100644 index 000000000..2e20f6b15 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DispatchScopeServiceTests.cs @@ -0,0 +1,306 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Two dispatch models on one department: area supervisors dispatch their own service area by day, + /// a central dispatch center (a department-wide role) dispatches every area after hours. + /// + [TestFixture] + public class DispatchScopeServiceTests + { + private const int DepartmentId = 1; + private const int ServiceArea1 = 10; + private const int StationA = 11; + private const int ServiceArea2 = 20; + private const int StationC = 21; + private const int AccessCenterRoleId = 500; + private const int ClinicianRoleId = 501; + + private const string Owner = "owner"; + private const string Admin = "admin-1"; + private const string Supervisor1 = "supervisor-1"; + private const string StationAMember = "member-a"; + private const string StationCMember = "member-c"; + private const string Dispatcher = "access-1"; + private const string Ungrouped = "ungrouped"; + + // Service Area 1 covers lat 34.00..34.10, Service Area 2 covers lat 34.20..34.30 (same longitudes). + private const string InArea1 = "34.05,-118.25"; + private const string InArea2 = "34.25,-118.25"; + + private Mock _departmentSettingsService; + private Mock _departmentsService; + private Mock _departmentGroupsService; + private Mock _personnelRolesService; + private Mock _unitsService; + private Mock _callsService; + + private GroupDispatchScopeConfig _config; + private Dictionary> _roles; + + [SetUp] + public void SetUp() + { + _departmentSettingsService = new Mock(); + _departmentsService = new Mock(); + _departmentGroupsService = new Mock(); + _personnelRolesService = new Mock(); + _unitsService = new Mock(); + _callsService = new Mock(); + + _config = new GroupDispatchScopeConfig { Enabled = true, DepartmentWideRoleIds = new List { AccessCenterRoleId } }; + _departmentSettingsService.Setup(x => x.GetGroupDispatchScopeConfigAsync(DepartmentId, It.IsAny())).ReturnsAsync(() => _config); + + _departmentsService.Setup(x => x.GetDepartmentMemberAsync(It.IsAny(), DepartmentId, It.IsAny())) + .ReturnsAsync((string userId, int departmentId, bool bypass) => + userId == "stranger" ? null : new DepartmentMember { UserId = userId, DepartmentId = DepartmentId, IsAdmin = userId == Admin }); + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = DepartmentId, ManagingUserId = Owner }); + + _roles = new Dictionary> + { + { Dispatcher, new List { new PersonnelRole { PersonnelRoleId = AccessCenterRoleId, Name = "ACCESS Center" } } }, + { StationAMember, new List { new PersonnelRole { PersonnelRoleId = ClinicianRoleId, Name = "Clinician" } } } + }; + _personnelRolesService.Setup(x => x.GetRolesForUserAsync(It.IsAny(), DepartmentId)) + .ReturnsAsync((string userId, int departmentId) => _roles.TryGetValue(userId, out var r) ? r : new List()); + + _departmentGroupsService.Setup(x => x.GetAllGroupsForDepartmentUnlimitedAsync(DepartmentId)).ReturnsAsync(() => new List + { + Group(ServiceArea1, null, DepartmentGroupTypes.Orginizational, Square(34.00, 34.10), new DepartmentGroupMember { UserId = Supervisor1, IsAdmin = true }), + Group(StationA, ServiceArea1, DepartmentGroupTypes.Station, null, new DepartmentGroupMember { UserId = StationAMember, IsAdmin = false }), + Group(ServiceArea2, null, DepartmentGroupTypes.Orginizational, Square(34.20, 34.30)), + Group(StationC, ServiceArea2, DepartmentGroupTypes.Orginizational, null, new DepartmentGroupMember { UserId = StationCMember, IsAdmin = false }) + }); + + _unitsService.Setup(x => x.GetUnitsForDepartmentUnlimitedAsync(DepartmentId)).ReturnsAsync(new List + { + new Unit { UnitId = 1, DepartmentId = DepartmentId, Name = "PMRT A1", StationGroupId = StationA }, + new Unit { UnitId = 2, DepartmentId = DepartmentId, Name = "PMRT C1", StationGroupId = StationC } + }); + } + + private DispatchScopeService BuildService() + { + return new DispatchScopeService(_departmentSettingsService.Object, _departmentsService.Object, _departmentGroupsService.Object, + _personnelRolesService.Object, _unitsService.Object, _callsService.Object); + } + + private static DepartmentGroup Group(int id, int? parentId, DepartmentGroupTypes type, string geofence, params DepartmentGroupMember[] members) + { + return new DepartmentGroup + { + DepartmentGroupId = id, + DepartmentId = DepartmentId, + Name = "Group " + id, + Type = (int)type, + ParentDepartmentGroupId = parentId, + Geofence = geofence, + Members = members.Select(m => { m.DepartmentGroupId = id; m.DepartmentId = DepartmentId; return m; }).ToList() + }; + } + + private static string Square(double south, double north) + { + return System.FormattableString.Invariant( + $"[{{\"lat\":{south},\"lng\":-118.30}},{{\"lat\":{north},\"lng\":-118.30}},{{\"lat\":{north},\"lng\":-118.20}},{{\"lat\":{south},\"lng\":-118.20}}]"); + } + + /// A call with its dispatch collections already loaded (so no lookups happen). + private static Call NewCall(int callId, string location, string reportedBy = "someone-else", int? groupDispatch = null, int? unitDispatch = null, string personDispatch = null) + { + return new Call + { + CallId = callId, + DepartmentId = DepartmentId, + GeoLocationData = location, + ReportingUserId = reportedBy, + GroupDispatches = groupDispatch.HasValue ? new List { new CallDispatchGroup { CallId = callId, DepartmentGroupId = groupDispatch.Value } } : new List(), + UnitDispatches = unitDispatch.HasValue ? new List { new CallDispatchUnit { CallId = callId, UnitId = unitDispatch.Value } } : new List(), + Dispatches = personDispatch != null ? new List { new CallDispatch { CallId = callId, UserId = personDispatch } } : new List() + }; + } + + #region Resolving scope + + [Test] + public async Task scoping_off_leaves_everyone_department_wide() + { + _config = new GroupDispatchScopeConfig(); + + var scope = await BuildService().GetScopeForUserAsync(DepartmentId, StationAMember); + + scope.IsDepartmentWide.Should().BeTrue(); + scope.Reason.Should().Be(DispatchScopeReasons.ScopingDisabled); + (await BuildService().IsCallInScopeAsync(scope, NewCall(1, InArea2))).Should().BeTrue(); + } + + [Test] + public async Task department_admins_and_the_managing_user_are_department_wide() + { + var service = BuildService(); + + (await service.GetScopeForUserAsync(DepartmentId, Admin)).Reason.Should().Be(DispatchScopeReasons.DepartmentAdmin); + (await service.GetScopeForUserAsync(DepartmentId, Owner)).Reason.Should().Be(DispatchScopeReasons.DepartmentAdmin); + } + + [Test] + public async Task a_dispatch_center_role_is_department_wide() + { + var scope = await BuildService().GetScopeForUserAsync(DepartmentId, Dispatcher); + + scope.IsDepartmentWide.Should().BeTrue(); + scope.Reason.Should().Be(DispatchScopeReasons.DepartmentWideRole); + } + + [Test] + public async Task hand_off_is_a_role_change_that_applies_on_the_next_request() + { + // After hours: the supervisor is given the dispatch center role... + _roles[Supervisor1] = new List { new PersonnelRole { PersonnelRoleId = AccessCenterRoleId } }; + (await BuildService().GetScopeForUserAsync(DepartmentId, Supervisor1)).IsDepartmentWide.Should().BeTrue(); + + // ...and in the morning it is taken away again; nothing else changed. + _roles.Remove(Supervisor1); + var scope = await BuildService().GetScopeForUserAsync(DepartmentId, Supervisor1); + + scope.IsDepartmentWide.Should().BeFalse(); + scope.GroupIds.Should().BeEquivalentTo(new[] { ServiceArea1, StationA }); + } + + [Test] + public async Task a_supervisor_scope_is_their_area_and_every_group_beneath_it() + { + var scope = await BuildService().GetScopeForUserAsync(DepartmentId, Supervisor1); + + scope.IsDepartmentWide.Should().BeFalse(); + scope.Reason.Should().Be(DispatchScopeReasons.GroupAdmin); + scope.AnchorGroupId.Should().Be(ServiceArea1); + scope.GroupIds.Should().BeEquivalentTo(new[] { ServiceArea1, StationA }); + } + + [Test] + public async Task a_station_member_scope_is_their_station() + { + var scope = await BuildService().GetScopeForUserAsync(DepartmentId, StationAMember); + + scope.Reason.Should().Be(DispatchScopeReasons.GroupMember); + scope.GroupIds.Should().BeEquivalentTo(new[] { StationA }); + } + + [Test] + public async Task an_ungrouped_member_and_a_non_member_get_an_empty_scope() + { + var service = BuildService(); + + var ungrouped = await service.GetScopeForUserAsync(DepartmentId, Ungrouped); + ungrouped.IsDepartmentWide.Should().BeFalse(); + ungrouped.Reason.Should().Be(DispatchScopeReasons.NoGroup); + ungrouped.GroupIds.Should().BeEmpty(); + + (await service.GetScopeForUserAsync(DepartmentId, "stranger")).IsDepartmentWide.Should().BeFalse(); + } + + #endregion + + #region Which calls are in scope + + [Test] + public async Task a_call_inside_the_supervisors_area_is_in_scope() + { + (await BuildService().CanUserAccessCallAsync(DepartmentId, Supervisor1, NewCall(1, InArea1))).Should().BeTrue(); + } + + [Test] + public async Task a_call_in_another_area_is_out_of_scope() + { + (await BuildService().CanUserAccessCallAsync(DepartmentId, Supervisor1, NewCall(1, InArea2))).Should().BeFalse(); + } + + [Test] + public async Task a_call_elsewhere_that_one_of_the_areas_stations_or_units_was_dispatched_to_is_in_scope() + { + var service = BuildService(); + + (await service.CanUserAccessCallAsync(DepartmentId, Supervisor1, NewCall(1, InArea2, groupDispatch: StationA))).Should().BeTrue("the station was dispatched"); + (await service.CanUserAccessCallAsync(DepartmentId, Supervisor1, NewCall(2, InArea2, unitDispatch: 1))).Should().BeTrue("a unit at the station was dispatched"); + (await service.CanUserAccessCallAsync(DepartmentId, Supervisor1, NewCall(3, InArea2, personDispatch: StationAMember))).Should().BeTrue("a member of the station was dispatched"); + (await service.CanUserAccessCallAsync(DepartmentId, Supervisor1, NewCall(4, InArea2, unitDispatch: 2))).Should().BeFalse("only another area's unit was dispatched"); + } + + [Test] + public async Task a_member_keeps_calls_they_reported_or_were_dispatched_to() + { + var service = BuildService(); + + (await service.CanUserAccessCallAsync(DepartmentId, Ungrouped, NewCall(1, InArea2, reportedBy: Ungrouped))).Should().BeTrue(); + (await service.CanUserAccessCallAsync(DepartmentId, Ungrouped, NewCall(2, InArea2, personDispatch: Ungrouped))).Should().BeTrue(); + (await service.CanUserAccessCallAsync(DepartmentId, Ungrouped, NewCall(3, InArea1))).Should().BeFalse(); + } + + [Test] + public async Task a_call_without_a_location_is_only_in_scope_through_its_dispatches() + { + var service = BuildService(); + + (await service.CanUserAccessCallAsync(DepartmentId, Supervisor1, NewCall(1, null))).Should().BeFalse(); + (await service.CanUserAccessCallAsync(DepartmentId, Supervisor1, NewCall(2, null, groupDispatch: ServiceArea1))).Should().BeTrue(); + } + + [Test] + public async Task the_dispatch_center_sees_every_area() + { + var service = BuildService(); + + (await service.CanUserAccessCallAsync(DepartmentId, Dispatcher, NewCall(1, InArea1))).Should().BeTrue(); + (await service.CanUserAccessCallAsync(DepartmentId, Dispatcher, NewCall(2, InArea2))).Should().BeTrue(); + } + + [Test] + public async Task a_call_from_another_department_is_never_in_scope() + { + var call = NewCall(1, InArea1); + call.DepartmentId = 99; + + (await BuildService().CanUserAccessCallAsync(DepartmentId, Admin, call)).Should().BeFalse(); + } + + [Test] + public async Task filtering_drops_out_of_scope_calls_and_keeps_order() + { + var calls = new List + { + NewCall(3, InArea1), + NewCall(1, InArea2), + NewCall(2, InArea2, groupDispatch: StationA) + }; + + var filtered = await BuildService().FilterCallsForUserAsync(DepartmentId, Supervisor1, calls); + + filtered.Select(c => c.CallId).Should().Equal(3, 2); + } + + [Test] + public async Task filtering_is_a_no_op_when_scoping_is_off() + { + _config = new GroupDispatchScopeConfig(); + var calls = new List { NewCall(1, InArea2), NewCall(2, null) }; + + var filtered = await BuildService().FilterCallsForUserAsync(DepartmentId, StationCMember, calls); + + filtered.Should().BeSameAs(calls); + _callsService.VerifyNoOtherCalls(); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs b/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs new file mode 100644 index 000000000..67d73ed07 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs @@ -0,0 +1,387 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// The nearest available unit board. A unit is whatever the department dispatches: here a crisis team, + /// an apparatus and an individual clinician set up as a unit. Every unit in scope is ranked with its + /// status, position, ETA, crew shift coverage and crew role mix side by side. + /// + [TestFixture] + public class NearestUnitServiceTests + { + private const int DepartmentId = 1; + private const string Viewer = "dispatcher"; + + // Service Area 1 contains the incident; stations A and B sit under it. Service Area 2 is ~20 km north + // with station C, and station D in between. + private const int ServiceArea1 = 10; + private const int StationA = 11; + private const int StationB = 12; + private const int ServiceArea2 = 20; + private const int StationC = 21; + private const int StationD = 22; + + private const int TeamUnit = 1; // crisis team with an assigned crew, ~1 km away + private const int FarTeamUnit = 2; // crisis team with no seats filled, ~20 km away + private const int ApparatusUnit = 3; // on scene elsewhere + private const int IndividualUnit = 4; // a clinician set up as a unit, no GPS + + private const int ClinicianRoleId = 300; + private const int PeerRoleId = 301; + + private const double IncidentLat = 34.05; + private const double IncidentLon = -118.25; + + private Mock _dispatchScopeService; + private Mock _departmentGroupsService; + private Mock _unitsService; + private Mock _usersService; + private Mock _personnelRolesService; + private Mock _actionLogsService; + private Mock _userStateService; + private Mock _customStateService; + private Mock _personnelLocationResolver; + private Mock _shiftsService; + private Mock _departmentSettingsService; + private Mock _geoService; + private Mock _authorizationService; + + private DispatchScope _scope; + private DispatchRecommendationConfig _config; + + [SetUp] + public void SetUp() + { + _dispatchScopeService = new Mock(); + _departmentGroupsService = new Mock(); + _unitsService = new Mock(); + _usersService = new Mock(); + _personnelRolesService = new Mock(); + _actionLogsService = new Mock(); + _userStateService = new Mock(); + _customStateService = new Mock(); + _personnelLocationResolver = new Mock(); + _shiftsService = new Mock(); + _departmentSettingsService = new Mock(); + _geoService = new Mock(); + _authorizationService = new Mock(); + + var now = DateTime.UtcNow; + + _scope = DispatchScope.DepartmentWide(DepartmentId, Viewer, DispatchScopeReasons.DepartmentWideRole); + _dispatchScopeService.Setup(x => x.GetScopeForUserAsync(DepartmentId, Viewer)).ReturnsAsync(() => _scope); + + _config = new DispatchRecommendationConfig(); + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationConfigAsync(DepartmentId, It.IsAny())).ReturnsAsync(() => _config); + + _departmentGroupsService.Setup(x => x.GetAllGroupsForDepartmentUnlimitedAsync(DepartmentId)).ReturnsAsync(new List + { + new DepartmentGroup { DepartmentGroupId = ServiceArea1, Name = "Service Area 1", Type = (int)DepartmentGroupTypes.Orginizational, Geofence = Square(34.00, 34.10) }, + new DepartmentGroup { DepartmentGroupId = StationA, Name = "Station A", Type = (int)DepartmentGroupTypes.Station, ParentDepartmentGroupId = ServiceArea1, Latitude = "34.04", Longitude = "-118.24" }, + new DepartmentGroup { DepartmentGroupId = StationB, Name = "Station B", Type = (int)DepartmentGroupTypes.Station, ParentDepartmentGroupId = ServiceArea1 }, + new DepartmentGroup { DepartmentGroupId = ServiceArea2, Name = "Service Area 2", Type = (int)DepartmentGroupTypes.Orginizational, Geofence = Square(34.20, 34.30) }, + new DepartmentGroup { DepartmentGroupId = StationC, Name = "Station C", Type = (int)DepartmentGroupTypes.Station, ParentDepartmentGroupId = ServiceArea2 }, + new DepartmentGroup { DepartmentGroupId = StationD, Name = "Station D", Type = (int)DepartmentGroupTypes.Station, ParentDepartmentGroupId = ServiceArea2, Latitude = "34.15", Longitude = "-118.25" } + }); + + _unitsService.Setup(x => x.GetUnitsForDepartmentUnlimitedAsync(DepartmentId)).ReturnsAsync(new List + { + new Unit { UnitId = TeamUnit, DepartmentId = DepartmentId, Name = "PMRT A1", Type = "Crisis Team", StationGroupId = StationA }, + new Unit { UnitId = FarTeamUnit, DepartmentId = DepartmentId, Name = "PMRT C1", Type = "Crisis Team", StationGroupId = StationC }, + new Unit { UnitId = ApparatusUnit, DepartmentId = DepartmentId, Name = "Medic B1", Type = "Ambulance", StationGroupId = StationB }, + new Unit { UnitId = IndividualUnit, DepartmentId = DepartmentId, Name = "Dr. Diaz", Type = "Individual", StationGroupId = StationD } + }); + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + new UnitState { UnitId = TeamUnit, State = (int)UnitStateTypes.Available, Timestamp = now }, + new UnitState { UnitId = FarTeamUnit, State = (int)UnitStateTypes.Available, Timestamp = now }, + new UnitState { UnitId = ApparatusUnit, State = (int)UnitStateTypes.OnScene, Timestamp = now }, + new UnitState { UnitId = IndividualUnit, State = (int)UnitStateTypes.Available, Timestamp = now } + }); + _unitsService.Setup(x => x.GetLatestUnitLocationsAsync(DepartmentId)).ReturnsAsync(new List + { + new UnitsLocation { UnitId = TeamUnit, Latitude = 34.059m, Longitude = -118.25m, Timestamp = now }, + new UnitsLocation { UnitId = FarTeamUnit, Latitude = 34.23m, Longitude = -118.25m, Timestamp = now }, + new UnitsLocation { UnitId = ApparatusUnit, Latitude = 34.051m, Longitude = -118.25m, Timestamp = now } + }); + _unitsService.Setup(x => x.GetAllActiveRolesForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + new UnitActiveRole { UnitId = TeamUnit, UserId = "clin-a", Role = "Clinician" }, + new UnitActiveRole { UnitId = TeamUnit, UserId = "peer-a", Role = "Peer" }, + new UnitActiveRole { UnitId = ApparatusUnit, UserId = "medic-b", Role = "Medic" } + }); + _customStateService.Setup(x => x.GetAllActiveUnitStatesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + _customStateService.Setup(x => x.GetActivePersonnelStateForDepartmentAsync(DepartmentId)).ReturnsAsync((CustomState)null); + _customStateService.Setup(x => x.GetActiveStaffingLevelsForDepartmentAsync(DepartmentId)).ReturnsAsync((CustomState)null); + + _usersService.Setup(x => x.GetUserGroupAndRolesByDepartmentIdAsync(DepartmentId, false, false, false)).ReturnsAsync(new List + { + new UserGroupRole { UserId = "clin-a", DepartmentGroupId = StationA, FirstName = "Ana", LastName = "Clinician" }, + new UserGroupRole { UserId = "peer-a", DepartmentGroupId = StationA, FirstName = "Pat", LastName = "Peer" }, + new UserGroupRole { UserId = "medic-b", DepartmentGroupId = StationB, FirstName = "Bo", LastName = "Medic" }, + new UserGroupRole { UserId = "night-c", DepartmentGroupId = StationC, FirstName = "Nia", LastName = "Night" } + }); + _personnelRolesService.Setup(x => x.GetAllRolesForUsersInDepartmentAsync(DepartmentId)).ReturnsAsync(new Dictionary> + { + { "clin-a", new List { new PersonnelRole { PersonnelRoleId = ClinicianRoleId, Name = "Clinician" } } }, + { "peer-a", new List { new PersonnelRole { PersonnelRoleId = PeerRoleId, Name = "Peer Support" } } }, + { "night-c", new List { new PersonnelRole { PersonnelRoleId = ClinicianRoleId, Name = "Clinician" } } } + }); + _actionLogsService.Setup(x => x.GetLastActionLogsForDepartmentAsync(DepartmentId, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List + { + new ActionLog { UserId = "medic-b", ActionTypeId = (int)ActionTypes.OnScene, Timestamp = now } + }); + _userStateService.Setup(x => x.GetLatestStatesForDepartmentAsync(DepartmentId, It.IsAny())).ReturnsAsync(new List()); + _personnelLocationResolver.Setup(x => x.GetLatestLocationsAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new Dictionary + { + { "clin-a", new ResolvedPersonnelLocation { UserId = "clin-a", Latitude = 34.068, Longitude = -118.25, Timestamp = now } } + }); + + // Ana is on the day shift at station A; Nia is on the night shift at station C, where no unit seats are filled. + _shiftsService.Setup(x => x.GetOnShiftPersonnelAsync(DepartmentId, It.IsAny())).ReturnsAsync(new List + { + new OnShiftAssignment { UserId = "clin-a", ShiftId = 7, ShiftName = "Day Shift", DepartmentGroupId = StationA }, + new OnShiftAssignment { UserId = "night-c", ShiftId = 8, ShiftName = "Night Shift", DepartmentGroupId = StationC } + }); + + _geoService.Setup(x => x.GetStationCoordinatesAsync(It.IsAny())) + .ReturnsAsync((DepartmentGroup g) => GeoMath.ParseCoordinatePair(g.Latitude, g.Longitude)); + + _authorizationService.Setup(x => x.CanUserViewUnitViaMatrixAsync(It.IsAny(), Viewer, DepartmentId)).ReturnsAsync(true); + _authorizationService.Setup(x => x.CanUserViewUnitLocationViaMatrixAsync(It.IsAny(), Viewer, DepartmentId)).ReturnsAsync(true); + _authorizationService.Setup(x => x.CanUserViewPersonViaMatrixAsync(It.IsAny(), Viewer, DepartmentId)).ReturnsAsync(true); + _authorizationService.Setup(x => x.CanUserViewPersonLocationViaMatrixAsync(It.IsAny(), Viewer, DepartmentId)).ReturnsAsync(true); + } + + private NearestUnitService BuildService() + { + return new NearestUnitService(_dispatchScopeService.Object, _departmentGroupsService.Object, _unitsService.Object, _usersService.Object, + _personnelRolesService.Object, _actionLogsService.Object, _userStateService.Object, _customStateService.Object, + _personnelLocationResolver.Object, _shiftsService.Object, _departmentSettingsService.Object, _geoService.Object, + _authorizationService.Object); + } + + private Task GetBoardAsync(bool? useRoadEta = null) + { + return BuildService().GetBoardAsync(new NearestUnitRequest + { + DepartmentId = DepartmentId, + UserId = Viewer, + Latitude = IncidentLat, + Longitude = IncidentLon, + UseRoadEta = useRoadEta + }); + } + + private static string Square(double south, double north) + { + return FormattableString.Invariant( + $"[{{\"lat\":{south},\"lng\":-118.30}},{{\"lat\":{north},\"lng\":-118.30}},{{\"lat\":{north},\"lng\":-118.20}},{{\"lat\":{south},\"lng\":-118.20}}]"); + } + + private static NearestUnitResult UnitRow(NearestUnitBoard board, int unitId) => board.Units.Single(u => u.UnitId == unitId); + + [Test] + public async Task every_unit_is_ranked_available_first_then_by_eta_whatever_kind_of_unit_it_is() + { + var board = await GetBoardAsync(); + + // A crisis team, an individual and another team are available; the ambulance is on scene. + board.Units.Select(u => u.UnitId).Should().Equal(TeamUnit, IndividualUnit, FarTeamUnit, ApparatusUnit); + UnitRow(board, ApparatusUnit).IsAvailable.Should().BeFalse(); + UnitRow(board, ApparatusUnit).StatusText.Should().Be(UnitStateTypes.OnScene.ToString()); + } + + [Test] + public async Task a_unit_row_carries_its_type_station_area_and_boundary() + { + var team = UnitRow(await GetBoardAsync(), TeamUnit); + + team.Name.Should().Be("PMRT A1"); + team.UnitType.Should().Be("Crisis Team"); + team.GroupName.Should().Be("Station A"); + team.ParentGroupName.Should().Be("Service Area 1"); + team.IncidentInGroupBoundary.Should().BeFalse("station A has no boundary of its own"); + team.IncidentInParentBoundary.Should().BeTrue(); + } + + [Test] + public async Task a_unit_with_assigned_seats_takes_its_crew_shift_coverage_and_role_mix_from_them() + { + var team = UnitRow(await GetBoardAsync(), TeamUnit); + + team.CrewSource.Should().Be(UnitCrewSources.Assigned); + team.Crew.Should().Equal("Ana Clinician", "Pat Peer"); + team.CrewCount.Should().Be(2); + team.CrewAvailableCount.Should().Be(2); + team.OnShiftCount.Should().Be(1); + team.ShiftNames.Should().Equal("Day Shift"); + team.RoleMix.Select(r => (r.Name, r.Count)).Should().Equal(("Clinician", 1), ("Peer Support", 1)); + } + + [Test] + public async Task a_unit_with_no_seats_filled_takes_its_crew_from_the_shift_running_at_its_station() + { + var board = await GetBoardAsync(); + var farTeam = UnitRow(board, FarTeamUnit); + + farTeam.CrewSource.Should().Be(UnitCrewSources.StationShift); + farTeam.Crew.Should().Equal("Nia Night"); + farTeam.OnShiftCount.Should().Be(1); + farTeam.ShiftNames.Should().Equal("Night Shift"); + farTeam.RoleMix.Should().ContainSingle(r => r.Name == "Clinician"); + + UnitRow(board, IndividualUnit).CrewSource.Should().Be(UnitCrewSources.None); + } + + [Test] + public async Task the_eta_comes_from_the_units_live_position() + { + var team = UnitRow(await GetBoardAsync(), TeamUnit); + + team.PositionSource.Should().Be(UnitPositionSources.Live); + team.Latitude.Should().Be(34.059); + team.DistanceMeters.Should().BeApproximately(1000, 50); + team.EtaSource.Should().Be(EtaSources.Estimated); + team.EtaSeconds.Should().Be(NearestUnitService.EstimateEtaSeconds(team.DistanceMeters.Value)); + } + + [Test] + public async Task a_unit_without_gps_is_placed_at_its_station() + { + var individual = UnitRow(await GetBoardAsync(), IndividualUnit); + + individual.PositionSource.Should().Be(UnitPositionSources.Station); + individual.Latitude.Should().Be(34.15); + individual.EtaSource.Should().Be(EtaSources.Estimated); + individual.DistanceMeters.Should().BeApproximately(11100, 200); + } + + [Test] + public async Task individual_responders_are_listed_with_the_unit_they_are_on() + { + var board = await GetBoardAsync(); + + board.Personnel.Should().HaveCount(4); + var ana = board.Personnel.Single(p => p.UserId == "clin-a"); + ana.UnitId.Should().Be(TeamUnit); + ana.UnitName.Should().Be("PMRT A1"); + ana.IsOnShift.Should().BeTrue(); + board.Personnel.Single(p => p.UserId == "night-c").UnitId.Should().BeNull(); + board.Personnel.Single(p => p.UserId == "medic-b").IsAvailable.Should().BeFalse(); + } + + [Test] + public async Task boundaries_containing_the_incident_are_listed() + { + var board = await GetBoardAsync(); + + board.ContainingBoundaries.Select(b => b.DepartmentGroupId).Should().Equal(ServiceArea1); + } + + [Test] + public async Task a_scoped_supervisor_sees_only_their_areas_units() + { + _scope = new DispatchScope + { + DepartmentId = DepartmentId, + UserId = Viewer, + Reason = DispatchScopeReasons.GroupAdmin, + AnchorGroupId = ServiceArea1, + GroupIds = new HashSet { ServiceArea1, StationA, StationB } + }; + + var board = await GetBoardAsync(); + + board.IsDepartmentWide.Should().BeFalse(); + board.Units.Select(u => u.UnitId).Should().BeEquivalentTo(new[] { TeamUnit, ApparatusUnit }); + board.Personnel.Should().NotContain(p => p.UserId == "night-c"); + board.ContainingBoundaries.Should().ContainSingle(b => b.DepartmentGroupId == ServiceArea1); + } + + [Test] + public async Task road_etas_go_to_the_closest_available_units_first_and_failures_keep_the_estimate() + { + _config.EtaShortlistSize = 2; + _geoService.Setup(x => x.GetEtaInSecondsAsync("34.059,-118.25", "34.05,-118.25")).ReturnsAsync(180); + _geoService.Setup(x => x.GetEtaInSecondsAsync(It.Is(s => s != "34.059,-118.25"), "34.05,-118.25")).ReturnsAsync(-1); + + var board = await GetBoardAsync(useRoadEta: true); + + // The two closest available units (the team, then the individual at station D), not the responders. + _geoService.Verify(x => x.GetEtaInSecondsAsync("34.059,-118.25", "34.05,-118.25"), Times.Once); + _geoService.Verify(x => x.GetEtaInSecondsAsync("34.15,-118.25", "34.05,-118.25"), Times.Once); + _geoService.Verify(x => x.GetEtaInSecondsAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + + UnitRow(board, TeamUnit).EtaSeconds.Should().Be(180); + UnitRow(board, TeamUnit).EtaSource.Should().Be(EtaSources.Road); + UnitRow(board, IndividualUnit).EtaSource.Should().Be(EtaSources.Estimated); + } + + [Test] + public async Task without_routing_no_lookups_are_made() + { + var board = await GetBoardAsync(); + + _geoService.Verify(x => x.GetEtaInSecondsAsync(It.IsAny(), It.IsAny()), Times.Never); + board.Notes.Should().NotBeEmpty(); + } + + [Test] + public async Task a_hidden_unit_location_is_withheld_but_still_ranked() + { + _authorizationService.Setup(x => x.CanUserViewUnitLocationViaMatrixAsync(TeamUnit, Viewer, DepartmentId)).ReturnsAsync(false); + + var team = UnitRow(await GetBoardAsync(), TeamUnit); + + team.LocationHidden.Should().BeTrue(); + team.Latitude.Should().BeNull(); + team.DistanceMeters.Should().NotBeNull(); + team.EtaSeconds.Should().NotBeNull(); + } + + [Test] + public async Task crew_the_viewer_cannot_see_count_toward_the_unit_but_are_not_named() + { + _authorizationService.Setup(x => x.CanUserViewPersonViaMatrixAsync("peer-a", Viewer, DepartmentId)).ReturnsAsync(false); + + var board = await GetBoardAsync(); + var team = UnitRow(board, TeamUnit); + + team.CrewCount.Should().Be(2); + team.Crew.Should().Equal("Ana Clinician"); + board.Personnel.Should().NotContain(p => p.UserId == "peer-a"); + } + + [Test] + public async Task units_the_viewer_cannot_see_are_left_off() + { + _authorizationService.Setup(x => x.CanUserViewUnitViaMatrixAsync(TeamUnit, Viewer, DepartmentId)).ReturnsAsync(false); + + var board = await GetBoardAsync(); + + board.Units.Should().NotContain(u => u.UnitId == TeamUnit); + } + + [Test] + public async Task an_incident_without_a_location_returns_an_empty_board() + { + var board = await BuildService().GetBoardAsync(new NearestUnitRequest { DepartmentId = DepartmentId, UserId = Viewer, Latitude = 0, Longitude = 0 }); + + board.Units.Should().BeEmpty(); + board.Notes.Should().ContainSingle(); + _dispatchScopeService.Verify(x => x.GetScopeForUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowCompositionTests.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowCompositionTests.cs new file mode 100644 index 000000000..7d005075a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowCompositionTests.cs @@ -0,0 +1,37 @@ +using Autofac; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Tests.Services.ProtectedWorkflows +{ + /// + /// Autofac composition for Protected Workflows: WorkflowService now depends on the protected runtime and service, + /// so a missing registration or a construction cycle must fail here, not at worker start. The shared test + /// container has never registered the workflow definition repositories, so the scope supplies them. + /// + [TestFixture] + public class ProtectedWorkflowCompositionTests : TestBase + { + [Test] + public void protected_workflow_services_resolve_from_the_container() + { + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(builder => + { + builder.RegisterInstance(Mock.Of()).As(); + builder.RegisterInstance(Mock.Of()).As(); + builder.RegisterInstance(Mock.Of()).As(); + builder.RegisterInstance(Mock.Of()).As(); + builder.RegisterInstance(Mock.Of()).As(); + }); + + scope.Resolve().Should().NotBeNull(); + scope.Resolve().Should().NotBeNull(); + scope.Resolve().Should().BeOfType(); + scope.Resolve().Should().BeOfType(); + scope.Resolve().Should().NotBeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowEhrTests.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowEhrTests.cs new file mode 100644 index 000000000..59fd95ea8 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowEhrTests.cs @@ -0,0 +1,643 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Services; +using Scriban; +using Scriban.Runtime; + +namespace Resgrid.Tests.Services.ProtectedWorkflows +{ + /// + /// Protected Workflows for EHR integration, end to end through the real WorkflowService, runtime and write pipeline: + /// subject identifiers, per-field custom field release with sensitivity tags, response capture, idempotency, payload + /// validation, the retry policy and the gallery samples. + /// + [TestFixture] + public class ProtectedWorkflowEhrTests + { + private const string ClientSentinel = "CLIENT-SENTINEL-4471"; + private const string CaseSentinel = "CASE-SENTINEL-9902"; + private const string DispositionSentinel = "DISPOSITION-SENTINEL-17"; + private const string SubstanceSentinel = "SUBSTANCE-SENTINEL-83"; + private const string EncounterSentinel = "ENCOUNTER-SENTINEL-5520"; + + private ProtectedWorkflowHarness _h; + + [SetUp] + public void SetUp() => _h = new ProtectedWorkflowHarness(); + + private void Configure(string template, object extraConfig = null, string contentType = "text/plain") + { + var config = JObject.FromObject(new { Url = ProtectedWorkflowHarness.Url, ContentType = contentType }); + if (extraConfig != null) + config.Merge(JObject.FromObject(extraConfig)); + _h.Steps[0].ActionConfig = config.ToString(Formatting.None); + _h.Steps[0].OutputTemplate = template; + } + + private string Sent => _h.Capturing.Calls.Single().RenderedContent; + + private void AssertNothingPersistedContains(params string[] sentinels) + { + var persisted = _h.EverythingPersisted(); + foreach (var sentinel in sentinels) + persisted.Should().NotContain(sentinel); + } + + // ── Subject identifiers ───────────────────────────────────────────────────────────────────── + + [Test] + public async Task only_allow_listed_subject_identifier_keys_reach_the_template() + { + _h.SetSubjectIdentifiers($"{{\"dynamics_case_id\":\"{CaseSentinel}\",\"ehr_client_id\":\"{ClientSentinel}\"}}"); + Configure("{{ protected.call.subject_ids.ehr_client_id }}|{{ protected.call.subject_ids.dynamics_case_id }}|{{ protected.call.completed_notes }}"); + _h.ActivateRelease("calls.subjectidentifiers#ehr_client_id"); + + var run = await _h.RunAsync(); + + run.Status.Should().Be((int)WorkflowRunStatus.Completed, string.Join("; ", _h.Logs.Select(l => l.ErrorMessage))); + Sent.Should().Be($"{ClientSentinel}||", "a key that is not allow-listed is dropped in memory, and nothing else was released"); + _h.Broker.Calls.Single().Items.Select(i => i.FieldId).Should().Equal("calls.subjectidentifiers"); + WorkflowProtectedRelease.ParseFieldIds(_h.DisclosureRecords.Single().FieldIds).Should().Equal("calls.subjectidentifiers#ehr_client_id"); + AssertNothingPersistedContains(ClientSentinel, CaseSentinel); + } + + [Test] + public async Task the_whole_subject_identifiers_field_releases_every_key() + { + _h.SetSubjectIdentifiers($"{{\"dynamics_case_id\":\"{CaseSentinel}\",\"ehr_client_id\":\"{ClientSentinel}\"}}"); + Configure("{{ protected.call.subject_ids.ehr_client_id }}|{{ protected.call.subject_ids.dynamics_case_id }}"); + _h.ActivateRelease("calls.subjectidentifiers"); + + await _h.RunAsync(); + + Sent.Should().Be($"{ClientSentinel}|{CaseSentinel}"); + } + + [Test] + public async Task malformed_subject_identifiers_fail_the_step_as_failed_projection_and_send_nothing() + { + _h.SetSubjectIdentifiers("{\"ehr_client_id\":" + ClientSentinel + "}"); + Configure("{{ protected.call.subject_ids.ehr_client_id }}"); + _h.ActivateRelease("calls.subjectidentifiers#ehr_client_id"); + + await _h.RunAsync(); + + _h.Capturing.Calls.Should().BeEmpty(); + var disclosure = _h.DisclosureRecords.Single(); + disclosure.Outcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedProjection); + disclosure.Detail.Should().Be(ProtectedWorkflowErrorCodes.ProjectionFailed); + _h.AttemptRecords.Should().BeEmpty("nothing was about to be sent"); + AssertNothingPersistedContains(ClientSentinel); + } + + [Test] + public void subject_identifier_rules_are_enforced() + { + CallSubjectIdentifiers.Validate(new Dictionary { ["ehr_client_id"] = "123456" }).Should().BeEmpty(); + CallSubjectIdentifiers.Validate(new Dictionary { ["EHR-Client"] = "1" }).Should().Contain(CallSubjectIdentifiers.InvalidKey); + CallSubjectIdentifiers.Validate(new Dictionary { [new string('k', 65)] = "1" }).Should().Contain(CallSubjectIdentifiers.InvalidKey); + CallSubjectIdentifiers.Validate(new Dictionary { ["k"] = new string('v', 257) }).Should().Contain(CallSubjectIdentifiers.ValueTooLong); + CallSubjectIdentifiers.Validate(new Dictionary { ["k"] = new string('v', 256) }).Should().BeEmpty(); + CallSubjectIdentifiers.Validate(Enumerable.Range(0, 21).ToDictionary(i => "k" + i, i => "v")).Should().Contain(CallSubjectIdentifiers.TooManyKeys); + CallSubjectIdentifiers.Validate(Enumerable.Range(0, 20).ToDictionary(i => "k" + i, i => "v")).Should().BeEmpty(); + + CallSubjectIdentifiers.Serialize(new Dictionary { ["b"] = "2", ["a"] = "1" }).Should().Be("{\"a\":\"1\",\"b\":\"2\"}"); + CallSubjectIdentifiers.TryParse("{\"a\":1}", out _).Should().BeFalse("values are strings"); + CallSubjectIdentifiers.TryParse("[\"a\"]", out _).Should().BeFalse(); + CallSubjectIdentifiers.TryParse(null, out var empty).Should().BeTrue(); + empty.Should().BeEmpty(); + } + + [Test] + public async Task a_release_cannot_list_the_whole_subject_identifiers_field_and_one_of_its_keys() + { + var result = await _h.Service.SaveDraftAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, new ProtectedReleaseDraft + { + FieldIds = new[] { "calls.subjectidentifiers", "calls.subjectidentifiers#ehr_client_id" } + }, new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA }); + + result.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.FieldConflict); + + (await _h.Service.SaveDraftAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, new ProtectedReleaseDraft + { + FieldIds = new[] { "calls.subjectidentifiers#Not-A-Key" } + }, new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA })).ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.UnknownField); + } + + [Test] + public async Task subject_identifiers_are_never_in_the_ordinary_call_context() + { + _h.SetSubjectIdentifiers($"{{\"ehr_client_id\":\"{ClientSentinel}\"}}"); + Configure("[{{ call.subject_identifiers }}{{ call.subject_ids }}{{ call.part2_consent_on_file }}]"); + + await _h.RunAsync(); // no release: an ordinary run + + Sent.Should().Be("[false]"); + } + + // ── Call custom fields and sensitivity ────────────────────────────────────────────────────── + + [Test] + public async Task only_allow_listed_custom_fields_are_decrypted_and_rendered() + { + _h.AddCustomField("disposition", UdfFieldSensitivity.None, DispositionSentinel); + _h.AddCustomField("substance_use", UdfFieldSensitivity.None, SubstanceSentinel); + Configure("{{ protected.call.udf.disposition }}|{{ protected.call.udf.substance_use }}"); + _h.ActivateRelease("calls.udf#disposition"); + + await _h.RunAsync(); + + Sent.Should().Be($"{DispositionSentinel}|"); + _h.Broker.Calls.Single().Items.Should().ContainSingle() + .Which.Should().Match(i => i.FieldId == "udffieldvalues.value" && i.RowKey == "udfv-disposition"); + AssertNothingPersistedContains(DispositionSentinel, SubstanceSentinel); + } + + private async Task DraftAndRequestAsync(IEnumerable fields, ProtectedSensitiveAttestation sensitive = null) + { + var draft = await _h.Service.SaveDraftAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, new ProtectedReleaseDraft + { + FieldIds = fields.ToList(), + RecipientType = (int)ProtectedReleaseRecipientType.CoveredEntity, + RecipientName = "County behavioral health EHR", + Purpose = "Crisis response encounter documentation" + }, new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA }); + draft.Success.Should().BeTrue(draft.ErrorCode); + + return await _h.Service.RequestApprovalAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, true, + ProtectedWorkflowDefaults.WarningTextVersion, _h.StepsFingerprint(), ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA), sensitive); + } + + private static ProtectedSensitiveAttestation Attest(bool restricted = false, bool part2 = false) => new ProtectedSensitiveAttestation + { + Restricted = restricted, + RestrictedVersion = ProtectedWorkflowDefaults.RestrictedAttestationVersion, + Part2 = part2, + Part2Version = ProtectedWorkflowDefaults.Part2AttestationVersion + }; + + [Test] + public async Task a_restricted_field_needs_the_restricted_attestation() + { + _h.AddCustomField("safety_plan", UdfFieldSensitivity.Restricted, "x"); + + (await DraftAndRequestAsync(new[] { "calls.udf#safety_plan" })).ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.RestrictedAttestationRequired); + (await DraftAndRequestAsync(new[] { "calls.udf#safety_plan" }, new ProtectedSensitiveAttestation { Restricted = true, RestrictedVersion = "PW-RESTRICTED-0" })) + .ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.RestrictedAttestationRequired, "only the current attestation text counts"); + + var approved = await DraftAndRequestAsync(new[] { "calls.udf#safety_plan" }, Attest(restricted: true)); + + approved.Success.Should().BeTrue(approved.ErrorCode); + var release = _h.StoredRelease(); + release.AllowsRestricted.Should().BeTrue(); + release.RestrictedAckVersion.Should().Be(ProtectedWorkflowDefaults.RestrictedAttestationVersion); + release.RestrictedAckByUserId.Should().Be(ProtectedWorkflowHarness.AdminA); + release.AllowsPart2.Should().BeFalse(); + } + + [Test] + public async Task a_part2_field_needs_the_part2_attestation_and_consent_on_the_call() + { + _h.AddCustomField("substance_use", UdfFieldSensitivity.Part2, SubstanceSentinel); + Configure("{{ protected.call.udf.substance_use }}"); + + (await DraftAndRequestAsync(new[] { "calls.udf#substance_use" }, Attest(restricted: true))).ErrorCode + .Should().Be(ProtectedWorkflowErrorCodes.Part2AttestationRequired); + (await DraftAndRequestAsync(new[] { "calls.udf#substance_use" }, Attest(part2: true))).Success.Should().BeTrue(); + _h.StoredRelease().AllowsPart2.Should().BeTrue(); + + // No consent on file: blocked before anything is decrypted. + _h.Calls[ProtectedWorkflowHarness.CallId].Part2ConsentOnFile = false; + var blocked = await _h.RunAsync(); + + _h.Broker.Calls.Should().BeEmpty(); + _h.Capturing.Calls.Should().BeEmpty(); + _h.DisclosureRecords.Single().Outcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.BlockedConsent); + blocked.Status.Should().Be((int)WorkflowRunStatus.Failed, "a missing consent is not something a retry fixes"); + + // Consent on file: sent. + _h.Calls[ProtectedWorkflowHarness.CallId].Part2ConsentOnFile = true; + (await _h.RunAsync()).Status.Should().Be((int)WorkflowRunStatus.Completed); + _h.Capturing.Calls.Single().RenderedContent.Should().Be(SubstanceSentinel); + } + + [Test] + public async Task the_approver_makes_the_same_sensitive_attestations() + { + _h.Egress.ProtectedWorkflowsRequireSecondApprover = true; + _h.AddCustomField("substance_use", UdfFieldSensitivity.Part2, "x"); + (await DraftAndRequestAsync(new[] { "calls.udf#substance_use" }, Attest(part2: true))).Success.Should().BeTrue(); + + var releaseId = _h.StoredRelease().WorkflowProtectedReleaseId; + (await _h.Service.ApproveAsync(ProtectedWorkflowHarness.DepartmentId, releaseId, true, ProtectedWorkflowDefaults.WarningTextVersion, + _h.StoredRelease().ConfigFingerprint, ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminB))).ErrorCode + .Should().Be(ProtectedWorkflowErrorCodes.Part2AttestationRequired); + + (await _h.Service.ApproveAsync(ProtectedWorkflowHarness.DepartmentId, releaseId, true, ProtectedWorkflowDefaults.WarningTextVersion, + _h.StoredRelease().ConfigFingerprint, ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminB), Attest(part2: true))).Success.Should().BeTrue(); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Active); + } + + [Test] + public async Task retagging_a_released_field_sends_the_release_back_for_approval() + { + var field = _h.AddCustomField("disposition", UdfFieldSensitivity.None, DispositionSentinel); + Configure("{{ protected.call.udf.disposition }}"); + _h.ActivateRelease("calls.udf#disposition"); + + field.Sensitivity = (int)UdfFieldSensitivity.Part2; + await _h.Service.OnCallCustomFieldsChangedAsync(ProtectedWorkflowHarness.DepartmentId, ProtectedWorkflowHarness.AdminB); + + var release = _h.StoredRelease(); + release.ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval); + release.SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.ConfigChanged); + (await _h.RunAsync()).SkipReason.Should().Be("protected_release_pending_approval"); + _h.Broker.Calls.Should().BeEmpty(); + } + + [Test] + public async Task a_retag_that_bypassed_the_hook_is_caught_at_send_time() + { + var field = _h.AddCustomField("disposition", UdfFieldSensitivity.None, DispositionSentinel); + Configure("{{ protected.call.udf.disposition }}"); + _h.ActivateRelease("calls.udf#disposition"); + field.Sensitivity = (int)UdfFieldSensitivity.Restricted; + + await _h.RunAsync(); + + _h.Broker.Calls.Should().BeEmpty(); + _h.DisclosureRecords.Single().Detail.Should().Be(ProtectedWorkflowErrorCodes.ConfigChanged); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval); + } + + [Test] + public async Task a_custom_field_that_does_not_exist_cannot_be_released() + { + (await _h.Service.SaveDraftAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, + new ProtectedReleaseDraft { FieldIds = new[] { "calls.udf#no_such_field" } }, + new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA })).ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.UnknownField); + } + + // ── Response capture ──────────────────────────────────────────────────────────────────────── + + [Test] + public async Task captured_values_land_encrypted_in_the_subject_identifiers_and_only_key_names_are_recorded() + { + _h.SetSubjectIdentifiers($"{{\"ehr_client_id\":\"{ClientSentinel}\"}}"); + Configure("{{ protected.call.subject_ids.ehr_client_id }}", new + { + ResponseCapture = new[] { new { Source = "header", Expression = "Location", Key = "ehr_encounter_id" } } + }); + _h.ActivateRelease("calls.subjectidentifiers#ehr_client_id"); + _h.Capturing.Respond = ctx => new WorkflowActionResult + { + Success = true, + ResultMessage = "HTTP 201 Created", + HttpStatus = 201, + CapturedValues = new Dictionary { ["ehr_encounter_id"] = EncounterSentinel } + }; + + var run = await _h.RunAsync(); + + run.Status.Should().Be((int)WorkflowRunStatus.Completed, string.Join("; ", _h.Logs.Select(l => l.ErrorMessage))); + var stored = _h.Calls[ProtectedWorkflowHarness.CallId].SubjectIdentifiers; + ProtectedDataEnvelope.HasEnvelopePrefix(stored).Should().BeTrue("the merged identifiers are written through the encrypt lane"); + JToken.DeepEquals(JObject.Parse(_h.StoredSubjectIdentifiersPlaintext()), + JObject.Parse($"{{\"ehr_client_id\":\"{ClientSentinel}\",\"ehr_encounter_id\":\"{EncounterSentinel}\"}}")).Should().BeTrue(); + _h.Broker.Encrypts.Single().Items.Single().Should().Match(i => + i.FieldId == "calls.subjectidentifiers" && i.RowKey == ProtectedWorkflowHarness.CallId.ToString() && i.CatalogVersion == 29); + + var disclosure = _h.DisclosureRecords.Single(); + JArray.Parse(disclosure.CapturedKeys).Select(k => (string)k).Should().Equal("ehr_encounter_id"); + _h.Logs.Single().ActionResult.Should().EndWith("captured=[ehr_encounter_id]"); + AssertNothingPersistedContains(EncounterSentinel, ClientSentinel); + } + + [Test] + public async Task existing_keys_are_overwritten_by_a_capture() + { + _h.SetSubjectIdentifiers($"{{\"ehr_client_id\":\"{ClientSentinel}\",\"ehr_encounter_id\":\"OLD\"}}"); + Configure("x", new { ResponseCapture = new[] { new { Source = "header", Expression = "Location", Key = "ehr_encounter_id" } } }); + _h.ActivateRelease("calls.subjectidentifiers"); + _h.Capturing.Respond = ctx => new WorkflowActionResult { Success = true, HttpStatus = 201, CapturedValues = new Dictionary { ["ehr_encounter_id"] = "NEW" } }; + + await _h.RunAsync(); + + JObject.Parse(_h.StoredSubjectIdentifiersPlaintext()).Value("ehr_encounter_id").Should().Be("NEW"); + } + + [Test] + public async Task a_capture_step_needs_a_release_that_reads_the_subject_identifiers() + { + Configure("{{ protected.call.completed_notes | json_escape }}", new { ResponseCapture = new[] { new { Source = "header", Expression = "Location", Key = "ehr_encounter_id" } } }); + + var result = await DraftAndRequestAsync(new[] { "calls.completednotes" }); + + result.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.CaptureRequiresSubjectIdentifiers); + } + + [Test] + public async Task a_capture_that_keeps_losing_to_concurrent_edits_fails_without_resending() + { + _h.SetSubjectIdentifiers($"{{\"ehr_client_id\":\"{ClientSentinel}\"}}"); + Configure("x", new { ResponseCapture = new[] { new { Source = "header", Expression = "Location", Key = "ehr_encounter_id" } } }); + _h.ActivateRelease("calls.subjectidentifiers"); + _h.SubjectIdentifierWriteConflicts = 10; + _h.Capturing.Respond = ctx => new WorkflowActionResult { Success = true, HttpStatus = 201, CapturedValues = new Dictionary { ["ehr_encounter_id"] = EncounterSentinel } }; + + var run = await _h.RunAsync(); + + run.Status.Should().Be((int)WorkflowRunStatus.Failed, "the record was delivered; sending it again would duplicate it"); + var disclosure = _h.DisclosureRecords.Single(); + disclosure.Outcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.Sent); + disclosure.Detail.Should().Be(ProtectedWorkflowErrorCodes.ConcurrentChange); + disclosure.CapturedKeys.Should().BeNull(); + AssertNothingPersistedContains(EncounterSentinel); + } + + // ── Idempotency ───────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task the_idempotency_key_is_stable_across_retries_and_differs_across_events() + { + Configure("{{ run.idempotency_key }}"); + _h.ActivateRelease(); + _h.Capturing.Respond = ctx => new WorkflowActionResult { Success = false, HttpStatus = 503, ErrorDetail = "http_failed: HTTP 503", ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.FailedHttp }; + + var first = await _h.RunAsync(); + await _h.RunAsync(first.InputPayload, attempt: 2, runId: first.WorkflowRunId); + await _h.RunAsync(); + + var keys = _h.Capturing.Calls.Select(c => c.IdempotencyKey).ToList(); + keys[0].Should().MatchRegex("^[0-9a-f]{32}$"); + keys[1].Should().Be(keys[0], "a retry is the same delivery"); + keys[2].Should().NotBe(keys[0], "another event is another delivery"); + _h.Capturing.Calls.Select(c => c.RenderedContent).Should().Equal(keys, "run.idempotency_key renders the same value the header carries"); + WorkflowIdempotency.Key("wf", "event-1", "run-1", "step").Should().Be(WorkflowIdempotency.Key("wf", "event-1", "run-2", "step"), + "with a domain event id the event, not the run, identifies the delivery"); + } + + // ── Payload validation ────────────────────────────────────────────────────────────────────── + + [TestCase("application/json", "{\"closure\":\"{{ protected.call.completed_notes }}\"")] + [TestCase("application/fhir+json", "{\"closure\":\"{{ protected.call.completed_notes | json_escape }}\"}")] + [TestCase("application/xml", "{{ protected.call.completed_notes | xml_escape }} e.Code).Should().Contain(ProtectedStepOptions.ContentTypeNotAllowed); + } + + [Test] + public async Task a_protected_value_without_an_escape_helper_is_a_warning_not_a_block() + { + Configure("{\"closure\":\"{{ protected.call.completed_notes }}\"}", contentType: "application/json"); + + var view = await _h.Service.GetReleaseViewAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, ProtectedWorkflowHarness.AdminA); + view.Validation.Warnings.Select(w => w.Code).Should().Contain(ProtectedWorkflowValidator.UnescapedProtectedValue); + view.Validation.IsValid.Should().BeTrue(); + + (await DraftAndRequestAsync(new[] { "calls.completednotes" })).Success.Should().BeTrue(); + } + + // ── Retry policy ──────────────────────────────────────────────────────────────────────────── + + [TestCase(503, ProtectedWorkflowDisclosureOutcomes.FailedHttp, WorkflowRunStatus.Retrying)] + [TestCase(429, ProtectedWorkflowDisclosureOutcomes.FailedHttp, WorkflowRunStatus.Retrying)] + [TestCase(400, ProtectedWorkflowDisclosureOutcomes.FailedHttp, WorkflowRunStatus.Failed)] + [TestCase(409, ProtectedWorkflowDisclosureOutcomes.FailedHttp, WorkflowRunStatus.Failed)] + [TestCase(200, ProtectedWorkflowDisclosureOutcomes.FailedAck, WorkflowRunStatus.Failed)] + [TestCase(500, ProtectedWorkflowDisclosureOutcomes.FailedAck, WorkflowRunStatus.Retrying)] + [TestCase(200, ProtectedWorkflowDisclosureOutcomes.FailedResponseTooLarge, WorkflowRunStatus.Failed)] + public async Task only_transport_failures_5xx_and_429_are_retried(int status, string outcome, WorkflowRunStatus expected) + { + _h.ActivateRelease(); + _h.Capturing.Respond = ctx => new WorkflowActionResult { Success = false, HttpStatus = status, ErrorDetail = "detail", ProtectedOutcome = outcome }; + + var run = await _h.RunAsync(); + + run.Status.Should().Be((int)expected); + if (expected == WorkflowRunStatus.Failed) + _h.Notifications.Should().NotBeEmpty("a final failure alerts the administrators at once"); + } + + // ── Fingerprint ───────────────────────────────────────────────────────────────────────────── + + private static readonly (string Name, Func Change)[] ExtraChanges = + { + ("auth method", e => new ProtectedFingerprintExtras { AuthMethod = "private_key_jwt", AllowsRestricted = e.AllowsRestricted, AllowsPart2 = e.AllowsPart2, FieldSensitivities = e.FieldSensitivities }), + ("restricted attestation", e => new ProtectedFingerprintExtras { AuthMethod = e.AuthMethod, AllowsRestricted = true, AllowsPart2 = e.AllowsPart2, FieldSensitivities = e.FieldSensitivities }), + ("part2 attestation", e => new ProtectedFingerprintExtras { AuthMethod = e.AuthMethod, AllowsRestricted = e.AllowsRestricted, AllowsPart2 = true, FieldSensitivities = e.FieldSensitivities }), + ("sensitivity", e => new ProtectedFingerprintExtras { AuthMethod = e.AuthMethod, AllowsRestricted = e.AllowsRestricted, AllowsPart2 = e.AllowsPart2, FieldSensitivities = new Dictionary { ["calls.udf#disposition"] = 2 } }) + }; + + private static readonly (string Name, object Config)[] StepOptionChanges = + { + ("content type", new { ContentType = "application/fhir+json" }), + ("success rule", new { SuccessRule = new { Type = "hl7_ack" } }), + ("response capture", new { ResponseCapture = new[] { new { Source = "header", Expression = "Location", Key = "ehr_encounter_id" } } }), + ("idempotency header", new { IdempotencyHeader = "Idempotency-Key" }), + ("if-none-exist", new { IfNoneExist = "identifier=https://resgrid.com/call|{{ call.id }}" }) + }; + + [Test] + public void every_new_fingerprint_input_changes_the_fingerprint() + { + var steps = new List { ProtectedWorkflowHarness.Clone(_h.Steps[0]) }; + var fields = new[] { "calls.udf#disposition" }; + var baseline = new ProtectedFingerprintExtras { AuthMethod = "client_secret", FieldSensitivities = new Dictionary { ["calls.udf#disposition"] = 0 } }; + var original = ProtectedWorkflowFingerprint.Compute((int)WorkflowTriggerEventType.CallClosed, steps, fields, ProtectedWorkflowHarness.Host, null, baseline); + + foreach (var (name, change) in ExtraChanges) + ProtectedWorkflowFingerprint.Compute((int)WorkflowTriggerEventType.CallClosed, steps, fields, ProtectedWorkflowHarness.Host, null, change(baseline)) + .Should().NotBe(original, name); + + foreach (var (name, config) in StepOptionChanges) + { + var changed = ProtectedWorkflowHarness.Clone(steps[0]); + var json = JObject.Parse(changed.ActionConfig); + json.Merge(JObject.FromObject(config)); + changed.ActionConfig = json.ToString(Formatting.None); + ProtectedWorkflowFingerprint.Compute((int)WorkflowTriggerEventType.CallClosed, new[] { changed }, fields, ProtectedWorkflowHarness.Host, null, baseline) + .Should().NotBe(original, name); + } + } + + [Test] + public async Task a_step_option_change_suspends_an_active_release() + { + _h.ActivateRelease(); + foreach (var (name, config) in StepOptionChanges) + { + var step = ProtectedWorkflowHarness.Clone(_h.Steps[0]); + var json = JObject.Parse(step.ActionConfig); + json.Merge(JObject.FromObject(config)); + step.ActionConfig = json.ToString(Formatting.None); + + await _h.WorkflowService.SaveWorkflowStepAsync(step); + + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval, name); + var release = _h.StoredRelease(); + release.State = (int)ProtectedReleaseState.Active; + release.SuspendedReason = null; + _h.Refingerprint(release); + } + } + + // ── Acceptance: two protected sends on one call close ─────────────────────────────────────── + + [Test] + public async Task two_protected_workflows_on_call_closed_send_independently_to_their_own_destinations() + { + // The DMH case: the Dynamics case write-back (base spec) and a FHIR Bundle to the EHR, both on Call Closed, each + // with its own release, pinned host and field list. + const string EhrHost = "ehr.example.org"; + _h.SetSubjectIdentifiers($"{{\"dynamics_case_id\":\"{CaseSentinel}\",\"ehr_client_id\":\"{ClientSentinel}\"}}"); + _h.AddCustomField("disposition", UdfFieldSensitivity.None, DispositionSentinel); + _h.AddCustomField("substance_use", UdfFieldSensitivity.Part2, SubstanceSentinel); + var dynamicsRelease = _h.ActivateRelease("calls.completednotes", "calls.callformdata"); + + var fhirStep = WorkflowTemplateGallery.Find(WorkflowTemplateGallery.FhirEncounterBundle).Steps.Single(); + var ehr = _h.AddWorkflow("wf-ehr", "EHR encounter", fhirStep.ActionType, + fhirStep.ActionConfig.Replace(WorkflowTemplateGallery.FhirPlaceholderUrl, $"https://{EhrHost}/fhir/r4"), fhirStep.OutputTemplate); + var ehrRelease = _h.ActivateReleaseFor(ehr, EhrHost, "calls.subjectidentifiers#ehr_client_id", "calls.udf#disposition"); + + _h.Capturing.Respond = ctx => ctx.PinnedHost == EhrHost + ? new WorkflowActionResult { Success = true, HttpStatus = 201, ResultMessage = "HTTP 201 Created", CapturedValues = new Dictionary { ["ehr_encounter_id"] = EncounterSentinel } } + : new WorkflowActionResult { Success = true, HttpStatus = 204, ResultMessage = "HTTP 204 No Content" }; + + // The event provider gives every active workflow on the trigger its own run for the same event. + var payload = _h.ClosedPayload(); + (await _h.RunAsync(payload)).Status.Should().Be((int)WorkflowRunStatus.Completed, string.Join("; ", _h.Logs.Select(l => l.ErrorMessage))); + (await _h.WorkflowService.ExecuteWorkflowAsync(ehr.WorkflowId, payload, ProtectedWorkflowHarness.DepartmentId, ProtectedWorkflowHarness.DepartmentCode)) + .Status.Should().Be((int)WorkflowRunStatus.Completed, string.Join("; ", _h.Logs.Select(l => l.ErrorMessage))); + + var toDynamics = _h.Capturing.Calls.Single(c => c.PinnedHost == ProtectedWorkflowHarness.Host); + var toEhr = _h.Capturing.Calls.Single(c => c.PinnedHost == EhrHost); + + toDynamics.RenderedContent.Should().Contain(ProtectedWorkflowHarness.SentinelCompletedNotes) + .And.NotContain(ClientSentinel).And.NotContain(DispositionSentinel).And.NotContain(SubstanceSentinel); + + ProtectedPayloadValidator.Validate("application/fhir+json", toEhr.RenderedContent).Ok.Should().BeTrue(); + var bundle = JObject.Parse(toEhr.RenderedContent); + bundle["entry"][0]["resource"]["subject"].Value("reference").Should().Be("Patient/" + ClientSentinel); + bundle["identifier"].Value("value").Should().Be(toEhr.IdempotencyKey); + toEhr.RenderedContent.Should().Contain(DispositionSentinel, "the released custom field becomes an Observation") + .And.NotContain(SubstanceSentinel).And.NotContain(CaseSentinel).And.NotContain(ProtectedWorkflowHarness.SentinelCompletedNotes); + toEhr.IdempotencyKey.Should().NotBe(toDynamics.IdempotencyKey); + + _h.DisclosureRecords.Select(d => (d.WorkflowProtectedReleaseId, d.DestinationHost)).Should().BeEquivalentTo(new[] + { + (dynamicsRelease.WorkflowProtectedReleaseId, ProtectedWorkflowHarness.Host), + (ehrRelease.WorkflowProtectedReleaseId, EhrHost) + }); + JObject.Parse(_h.StoredSubjectIdentifiersPlaintext()).Value("ehr_encounter_id").Should().Be(EncounterSentinel, + "the EHR's encounter id is stored on the call, encrypted"); + ProtectedDataEnvelope.HasEnvelopePrefix(_h.Calls[ProtectedWorkflowHarness.CallId].SubjectIdentifiers).Should().BeTrue(); + AssertNothingPersistedContains(ClientSentinel, CaseSentinel, DispositionSentinel, SubstanceSentinel, EncounterSentinel, + ProtectedWorkflowHarness.SentinelCompletedNotes); + } + + // ── Gallery samples ───────────────────────────────────────────────────────────────────────── + + private static string RenderGallery(string key, IEnumerable releasedFields, out string idempotencyKey) + { + var template = WorkflowTemplateGallery.Find(key); + var sample = (ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData(template.Trigger); + idempotencyKey = (string)((ScriptObject)sample["run"])["idempotency_key"]; + var harness = new ProtectedWorkflowHarness(); + var values = harness.Runtime.BuildSampleValues((int)template.Trigger, releasedFields); + + var context = new TemplateContext { EnableRelaxedTargetAccess = true }; + context.PushGlobal(sample); + context.PushGlobal((ScriptObject)values.Namespace); + context.PushGlobal(new ScriptObject()); + var step = template.Steps.Single(); + var rendered = Template.Parse(step.OutputTemplate).Render(context); + var options = ProtectedStepOptions.Read(step.ActionConfig, out var errors); + errors.Should().BeEmpty(); + return ProtectedPayloadValidator.NormalizeBody(options.MediaType, rendered); + } + + [Test] + public void the_fhir_sample_renders_a_valid_transaction_bundle() + { + var body = RenderGallery(WorkflowTemplateGallery.FhirEncounterBundle, + new[] { "calls.subjectidentifiers#ehr_client_id", "calls.udf#disposition", "calls.udf#follow_up" }, out var key); + + ProtectedPayloadValidator.Validate("application/fhir+json", body).Ok.Should().BeTrue(body); + var bundle = JObject.Parse(body); + bundle.Value("resourceType").Should().Be("Bundle"); + bundle["identifier"].Value("value").Should().Be(key); + var entries = (JArray)bundle["entry"]; + entries.Should().HaveCount(3, "the Encounter plus one Observation per released custom field"); + var encounter = entries[0]["resource"]; + encounter.Value("resourceType").Should().Be("Encounter"); + encounter["class"].Value("code").Should().Be("FLD"); + encounter.Value("status").Should().Be("finished"); + encounter["subject"].Value("reference").Should().Be("Patient/SAMPLE-CLIENT-000123"); + entries[0]["request"].Value("ifNoneExist").Should().Be("identifier=https://resgrid.com/call|1001"); + entries[0].Value("fullUrl").Should().MatchRegex("^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); + entries.Skip(1).Select(e => e["resource"].Value("valueString")).Should() + .Equal("SAMPLE disposition (synthetic test data)", "SAMPLE follow_up (synthetic test data)"); + } + + [Test] + public void the_hl7_sample_renders_a_valid_mdm_t02() + { + var body = RenderGallery(WorkflowTemplateGallery.Hl7MdmT02, + new[] { "calls.subjectidentifiers#ehr_client_id", "calls.udf#disposition" }, out var key); + + ProtectedPayloadValidator.Validate(ProtectedPayloadValidator.Hl7MediaType, body).Ok.Should().BeTrue(body.Replace('\r', '\n')); + var segments = body.Split('\r'); + segments.Select(s => s.Substring(0, 3)).Should().Equal("MSH", "EVN", "PID", "PV1", "TXA", "OBX"); + var msh = segments[0].Split('|'); + msh[8].Should().Be("MDM^T02^MDM_T02"); + msh[9].Should().Be(key, "MSH-10 is the idempotency key"); + segments[2].Split('|')[3].Should().Be("SAMPLE-CLIENT-000123^^^EHR^MR", "PID-3 is the EHR client id"); + segments[4].Split('|')[2].Should().Be("CFR^Crisis Field Response"); + segments[5].Split('|')[5].Should().Be("SAMPLE disposition (synthetic test data)"); + } + + [Test] + public void the_ehr_samples_are_only_offered_with_protected_workflows_enabled() + { + WorkflowTemplateGallery.Available(false).Select(t => t.Key).Should().NotContain(new[] { WorkflowTemplateGallery.FhirEncounterBundle, WorkflowTemplateGallery.Hl7MdmT02 }); + WorkflowTemplateGallery.Available(true).Select(t => t.Key).Should().Contain(new[] { WorkflowTemplateGallery.FhirEncounterBundle, WorkflowTemplateGallery.Hl7MdmT02 }); + foreach (var template in WorkflowTemplateGallery.All.Where(t => t.RequiresProtectedWorkflows)) + template.Steps.Should().OnlyContain(s => !ProtectedWorkflowValidator.HasUnescapedProtectedReference(s.OutputTemplate), template.Key); + foreach (var template in WorkflowTemplateGallery.All) + template.Steps.Should().NotBeEmpty().And.OnlyContain(s => !string.IsNullOrWhiteSpace(s.OutputTemplate) && !string.IsNullOrWhiteSpace(s.ActionConfig), template.Key); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowHarness.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowHarness.cs new file mode 100644 index 000000000..8042f26b6 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowHarness.cs @@ -0,0 +1,697 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Identity; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services.ProtectedWorkflows +{ + /// + /// In-memory Protected Workflows world: repositories backed by lists (reads return copies, the way a database + /// would), a fake broker, a capturing executor, and the REAL WorkflowService, ProtectedWorkflowService, + /// ProtectedWorkflowRuntime and WorkflowTemplateContextBuilder wired together. + /// + internal sealed class ProtectedWorkflowHarness + { + public const int DepartmentId = 42; + public const string DepartmentCode = "ABCD"; + public const string AdminA = "admin-a"; + public const string AdminB = "admin-b"; + public const string Member = "member-1"; + public const string CredentialId = "cred-1"; + public const string Host = "org.crm.dynamics.com"; + public const string Url = "https://org.crm.dynamics.com/api/data/v9.2/incidents(00000000-0000-0000-0000-000000000001)"; + public const int CallId = 1001; + + // Distinctive plaintext: it must never appear in anything the platform writes. + public const string SentinelCompletedNotes = "SENTINEL-COMPLETED-7f3a91"; + public const string SentinelFormOutcome = "SENTINEL-FORM-OUTCOME-2c8e44"; + public const string SentinelNotes = "SENTINEL-CALL-NOTES-b61d02"; + + public const string EnvelopeCompletedNotes = "rgdp:1:1:Q09NUExFVEVETk9URVM="; + public const string EnvelopeForm = "rgdp:1:1:Rk9STURBVEE="; + public const string EnvelopeNotes = "rgdp:1:1:Tk9URVM="; + public const string EnvelopeNature = "rgdp:1:1:TkFUVVJF"; + + public Workflow Workflow { get; } + + /// Further workflows in the same department (several workflows may share a trigger). + public List OtherWorkflows { get; } = new List(); + public List Steps { get; } = new List(); + public List Credentials { get; } = new List(); + public List Releases { get; } = new List(); + public List Disclosures { get; } = new List(); + public Dictionary Runs { get; } = new Dictionary(); + public List Logs { get; } = new List(); + public Dictionary Calls { get; } = new Dictionary(); + + /// The department's active call custom field definition (enabled fields) and the stored values. + public List CustomFields { get; } = new List(); + public List CustomValues { get; } = new List(); + public const string CustomDefinitionId = "udf-def-1"; + + /// Conditional subject-identifier writes that lost to a concurrent edit (test seam: set to force losses). + public int SubjectIdentifierWriteConflicts { get; set; } + public bool FailDisclosureAppends { get; set; } + + /// Runs inside a conditional release write, before the version check: a concurrent writer racing it. + public Action BeforeReleaseUpdate { get; set; } + public DepartmentDataProtectionPolicy Policy { get; } + public DepartmentProtectedDataEgressPolicy Egress { get; set; } + public int EpochBumps { get; private set; } + public List Notifications { get; } = new List(); + + public FakeBroker Broker { get; } = new FakeBroker(); + public IWorkflowActionExecutor Executor { get; set; } + public CapturingExecutor Capturing { get; } = new CapturingExecutor(); + + public ProtectedWorkflowService Service { get; } + public ProtectedWorkflowRuntime Runtime { get; } + public WorkflowService WorkflowService { get; } + + private readonly ProtectedProjectionService _projection; + + public ProtectedWorkflowHarness(int triggerEventType = (int)WorkflowTriggerEventType.CallClosed) + { + Executor = Capturing; + Workflow = new Workflow + { + WorkflowId = "wf-1", + DepartmentId = DepartmentId, + Name = "DMH case write-back", + TriggerEventType = triggerEventType, + IsEnabled = true, + MaxRetryCount = 3, + CreatedByUserId = AdminA, + CreatedOn = DateTime.UtcNow.AddDays(-10) + }; + + Steps.Add(new WorkflowStep + { + WorkflowStepId = "step-1", + WorkflowId = Workflow.WorkflowId, + ActionType = (int)WorkflowActionType.CallApiPut, + StepOrder = 1, + IsEnabled = true, + WorkflowCredentialId = CredentialId, + ActionConfig = JsonConvert.SerializeObject(new { Url, ContentType = "application/json" }), + OutputTemplate = "{\"closure\":\"{{ protected.call.completed_notes }}\",\"outcome\":\"{{ protected.call.form.outcome }}\",\"nature\":\"{{ call.nature }}\",\"number\":\"{{ call.number }}\"}", + CreatedByUserId = AdminA, + CreatedOn = DateTime.UtcNow.AddDays(-10) + }); + + Credentials.Add(new WorkflowCredential + { + WorkflowCredentialId = CredentialId, + DepartmentId = DepartmentId, + Name = "Dataverse", + CredentialType = (int)WorkflowCredentialType.HttpBearer, + EncryptedData = "enc:" + JsonConvert.SerializeObject(new { token = "token-abc" }), + CreatedByUserId = AdminA + }); + + Policy = new DepartmentDataProtectionPolicy + { + DepartmentId = DepartmentId, + State = (int)DepartmentDataProtectionState.Enabled, + CatalogVersion = 29, + PolicyEpoch = 7 + }; + + Egress = new DepartmentProtectedDataEgressPolicy + { + DepartmentProtectedDataEgressPolicyId = 1, + DepartmentId = DepartmentId, + ProtectedWorkflowsEnabled = true, + ProtectedWorkflowsAckVersion = ProtectedWorkflowDefaults.WarningTextVersion, + ProtectedWorkflowsAckByUserId = AdminA, + ProtectedWorkflowsAckOn = DateTime.UtcNow.AddDays(-1) + }; + + Broker.Plaintext[EnvelopeCompletedNotes] = SentinelCompletedNotes; + // Call form data is the form builder's field array with each answer in userData. + Broker.Plaintext[EnvelopeForm] = "[{\"type\":\"text\",\"label\":\"Outcome\",\"name\":\"outcome\",\"userData\":[\"" + SentinelFormOutcome + + "\"]},{\"type\":\"select\",\"label\":\"Follow up\",\"name\":\"follow_up\",\"userData\":[\"yes\"]}]"; + Calls[CallId] = BuildCall(); + Broker.Plaintext[EnvelopeNotes] = SentinelNotes; + Broker.Plaintext[EnvelopeNature] = "Behavioral health crisis"; + + var releases = ReleaseRepository(); + var disclosures = DisclosureRepository(); + var workflows = WorkflowRepository(); + var steps = StepRepository(); + var credentials = CredentialRepository(); + var dataProtection = DataProtection(); + var departments = Departments(); + + var permissions = new Mock(); + permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(It.IsAny(), It.IsAny())).ReturnsAsync((Permission)null); + permissions.Setup(p => p.IsUserAllowed(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .Returns((Permission permission, bool isAdmin, bool isGroupAdmin, List roles) => + permission.Action == (int)PermissionActions.Everyone || + (permission.Action == (int)PermissionActions.DepartmentAdminsOnly && isAdmin) || + (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && (isAdmin || isGroupAdmin))); + + var groups = new Mock(); + groups.Setup(g => g.GetGroupForUserAsync(It.IsAny(), It.IsAny())).ReturnsAsync((DepartmentGroup)null); + var roles = new Mock(); + roles.Setup(r => r.GetRolesForUserAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + var encryption = new Mock(); + encryption.Setup(e => e.EncryptForDepartment(It.IsAny(), It.IsAny(), It.IsAny())).Returns((string text, int d, string c) => "enc:" + text); + encryption.Setup(e => e.DecryptForDepartment(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string text, int d, string c) => text != null && text.StartsWith("enc:") ? text.Substring(4) : text); + + var communication = new Mock(); + communication.Setup(c => c.SendNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((string user, int dept, string message, string number, Department department, string title, UserProfile profile, bool ic) => + Notifications.Add($"{user}|{title}|{message}")) + .ReturnsAsync(true); + + var udfDefinitions = new Mock(); + udfDefinitions.Setup(r => r.GetActiveDefinitionByDepartmentAndEntityTypeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, int t) => t == (int)UdfEntityType.Call && CustomFields.Count > 0 + ? new UdfDefinition { UdfDefinitionId = CustomDefinitionId, DepartmentId = d, EntityType = t, IsActive = true } + : null); + var udfFields = new Mock(); + udfFields.Setup(r => r.GetFieldsByDefinitionIdAsync(It.IsAny())) + .ReturnsAsync((string id) => id == CustomDefinitionId ? CustomFields.Select(Clone).ToList() : new List()); + var udfValues = new Mock(); + udfValues.Setup(r => r.GetFieldValuesByEntityAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int t, string e, string d) => CustomValues.Where(v => v.EntityType == t && v.EntityId == e && v.UdfDefinitionId == d).Select(Clone).ToList()); + + Service = new ProtectedWorkflowService(releases, disclosures, workflows, steps, credentials, dataProtection, departments, + permissions.Object, groups.Object, roles.Object, encryption.Object, Mock.Of(), + new Lazy(() => communication.Object), udfDefinitions.Object, udfFields.Object); + + var calls = new Mock(); + calls.Setup(c => c.GetByIdAsync(It.IsAny())).ReturnsAsync((object id) => Calls.TryGetValue((int)id, out var call) ? Clone(call) : null); + calls.Setup(c => c.TryUpdateSubjectIdentifiersAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int callId, int departmentId, string expected, string value, CancellationToken c) => + { + if (!Calls.TryGetValue(callId, out var stored) || stored.DepartmentId != departmentId || stored.SubjectIdentifiers != expected) + return false; + if (SubjectIdentifierWriteConflicts > 0) + { + SubjectIdentifierWriteConflicts--; + return false; + } + stored.SubjectIdentifiers = value; + return true; + }); + + // The REAL write pipeline (encrypt-only workload lane) over the fake broker. + var writes = new ProtectedReadService(dataProtection, Mock.Of(), Broker, new ProtectedFieldCatalog()); + + Runtime = new ProtectedWorkflowRuntime(releases, steps, credentials, dataProtection, Broker, Service, calls.Object, + udfValues.Object, writes); + + // The payload a workflow actually receives: the SAFE projection, with every cataloged value already REDACTED. + var enforced = new Mock(); + enforced.Setup(d => d.IsProtectionEnforcedAsync(It.IsAny())).ReturnsAsync(true); + _projection = new ProtectedProjectionService(enforced.Object, new ProtectedFieldCatalog()); + + var contextBuilder = new WorkflowTemplateContextBuilder(departments, Mock.Of(), Mock.Of(), + groups.Object, roles.Object, Mock.Of(), Mock.Of(), Mock.Of()); + + var factory = new Mock(); + factory.Setup(f => f.GetExecutor(It.IsAny())).Returns(() => Executor); + + WorkflowService = new WorkflowService(workflows, steps, credentials, RunRepository(), LogRepository(), + Mock.Of(), encryption.Object, factory.Object, contextBuilder, + Mock.Of(), Mock.Of(), protectedRuntime: Runtime, protectedWorkflows: Service); + } + + // ── Scenario helpers ───────────────────────────────────────────────────────────────────────── + + /// Declares the first step's payload as text/plain (for tests whose template is not JSON). Before ActivateRelease. + public void UsePlainText(int stepIndex = 0) => + Steps[stepIndex].ActionConfig = JsonConvert.SerializeObject(new { Url, ContentType = "text/plain" }); + + public static ProtectedWorkflowActor SteppedUp(string userId, DateTime? at = null) => + new ProtectedWorkflowActor { UserId = userId, IsInteractive = true, StepUpVerifiedAtUtc = at ?? DateTime.UtcNow }; + + /// An Active release over the CURRENT steps (fingerprint computed exactly as the service does). + public WorkflowProtectedRelease ActivateRelease(params string[] fieldIds) => ActivateReleaseFor(Workflow, Host, fieldIds); + + /// An Active release of any workflow in the harness, pinned to . + public WorkflowProtectedRelease ActivateReleaseFor(Workflow workflow, string host, params string[] fieldIds) + { + var release = new WorkflowProtectedRelease + { + WorkflowProtectedReleaseId = "rel-" + (Releases.Count + 1), + WorkflowId = workflow.WorkflowId, + DepartmentId = DepartmentId, + State = (int)ProtectedReleaseState.Active, + DestinationScheme = "https", + DestinationHost = host, + WorkflowCredentialId = CredentialId, + RecipientType = (int)ProtectedReleaseRecipientType.CoveredEntity, + RecipientName = "County DMH, Dynamics 365 case management", + Purpose = "Close the loop on crisis calls", + AckVersion = ProtectedWorkflowDefaults.WarningTextVersion, + RequestedByUserId = AdminA, + RequestedOn = DateTime.UtcNow.AddDays(-1), + ApprovedByUserId = AdminA, + ApprovedOn = DateTime.UtcNow.AddDays(-1), + ExpiresOn = DateTime.UtcNow.AddDays(300), + CreatedOn = DateTime.UtcNow.AddDays(-2).AddSeconds(Releases.Count), + Version = 1 + }; + release.SetAllowedFieldIds(fieldIds.Length == 0 ? new[] { "calls.completednotes", "calls.callformdata" } : fieldIds); + Refingerprint(release); + Releases.Add(Clone(release)); + return release; + } + + /// Re-binds a stored release's fingerprint to the current steps (a test shortcut for "an admin approved this exact config"). + public void Refingerprint(WorkflowProtectedRelease release) + { + var stored0 = Releases.FirstOrDefault(r => r.WorkflowProtectedReleaseId == release.WorkflowProtectedReleaseId); + if (stored0 != null) + { + stored0.AllowsRestricted = release.AllowsRestricted; + stored0.AllowsPart2 = release.AllowsPart2; + stored0.AuthMethod = release.AuthMethod; + stored0.AllowedFieldIds = release.AllowedFieldIds; + } + var workflow = release.WorkflowId == Workflow.WorkflowId ? Workflow : OtherWorkflows.Single(w => w.WorkflowId == release.WorkflowId); + release.ConfigFingerprint = ProtectedWorkflowService.ComputeFingerprint(workflow, Steps.Where(st => st.WorkflowId == workflow.WorkflowId), + release, CustomFields.Where(f => f.IsEnabled).ToList()); + var stored = Releases.FirstOrDefault(r => r.WorkflowProtectedReleaseId == release.WorkflowProtectedReleaseId); + if (stored != null) + stored.ConfigFingerprint = release.ConfigFingerprint; + } + + public WorkflowProtectedRelease StoredRelease(string releaseId = null) => + releaseId == null + ? Releases.OrderByDescending(r => r.CreatedOn).First() + : Releases.Single(r => r.WorkflowProtectedReleaseId == releaseId); + + /// A call custom field in the active definition, with a stored (enveloped) value for the harness call. + public UdfField AddCustomField(string name, UdfFieldSensitivity sensitivity, string plaintext) + { + var field = new UdfField + { + UdfFieldId = "udf-" + name, + UdfDefinitionId = CustomDefinitionId, + Name = name, + Label = name.Replace('_', ' '), + IsEnabled = true, + SortOrder = CustomFields.Count, + Sensitivity = (int)sensitivity + }; + CustomFields.Add(field); + + if (plaintext != null) + { + var envelope = "rgdp:1:1:" + Convert.ToBase64String(Encoding.UTF8.GetBytes("UDF-" + name)); + Broker.Plaintext[envelope] = plaintext; + CustomValues.Add(new UdfFieldValue + { + UdfFieldValueId = "udfv-" + name, + UdfFieldId = field.UdfFieldId, + UdfDefinitionId = CustomDefinitionId, + EntityType = (int)UdfEntityType.Call, + EntityId = CallId.ToString(), + Value = envelope + }); + } + + return field; + } + + /// Stores subject identifiers on the harness call as an envelope the fake broker can open. + public void SetSubjectIdentifiers(string plaintextJson) + { + if (plaintextJson == null) + { + Calls[CallId].SubjectIdentifiers = null; + return; + } + var envelope = "rgdp:1:1:" + Convert.ToBase64String(Encoding.UTF8.GetBytes("SUBJECT-" + Guid.NewGuid().ToString("N"))); + Broker.Plaintext[envelope] = plaintextJson; + Calls[CallId].SubjectIdentifiers = envelope; + } + + /// The call's stored subject identifiers, opened with the fake broker (null when none). + public string StoredSubjectIdentifiersPlaintext() + { + var stored = Calls[CallId].SubjectIdentifiers; + return stored != null && Broker.Plaintext.TryGetValue(stored, out var plain) ? plain : stored; + } + + public static Call BuildCall() + { + return new Call + { + CallId = CallId, + DepartmentId = DepartmentId, + Number = "26-000123", + Name = "rgdp:1:1:TkFNRQ==", + NatureOfCall = EnvelopeNature, + Notes = EnvelopeNotes, + CompletedNotes = EnvelopeCompletedNotes, + CallFormData = EnvelopeForm, + Address = "rgdp:1:1:QUREUkVTUw==", + State = 1, + LoggedOn = DateTime.UtcNow.AddHours(-2), + ClosedOn = DateTime.UtcNow + }; + } + + /// What WorkflowEventProvider queues for an enforced department: the safe projection of the event. + public string ClosedPayload(Call call = null) => + _projection.BuildSafeWorkflowPayloadAsync(DepartmentId, new CallClosedEvent { DepartmentId = DepartmentId, Call = call ?? Calls[CallId] }) + .GetAwaiter().GetResult(); + + public Task RunAsync(string payload = null, int attempt = 1, string runId = null) => + WorkflowService.ExecuteWorkflowAsync(Workflow.WorkflowId, payload ?? ClosedPayload(), DepartmentId, DepartmentCode, attempt, runId); + + /// Adds another workflow on the same trigger, with one API step (the event provider gives each its own run). + public Workflow AddWorkflow(string workflowId, string name, WorkflowActionType actionType, string actionConfig, string outputTemplate) + { + var workflow = new Workflow + { + WorkflowId = workflowId, + DepartmentId = DepartmentId, + Name = name, + TriggerEventType = Workflow.TriggerEventType, + IsEnabled = true, + MaxRetryCount = 3, + CreatedByUserId = AdminA, + CreatedOn = DateTime.UtcNow.AddDays(-5) + }; + OtherWorkflows.Add(workflow); + Steps.Add(new WorkflowStep + { + WorkflowStepId = workflowId + "-step-1", + WorkflowId = workflowId, + ActionType = (int)actionType, + StepOrder = 1, + IsEnabled = true, + WorkflowCredentialId = CredentialId, + ActionConfig = actionConfig, + OutputTemplate = outputTemplate, + CreatedByUserId = AdminA, + CreatedOn = DateTime.UtcNow.AddDays(-5) + }); + return workflow; + } + + /// What the editor panel shows right now: the steps fingerprint a request must present back. + public string StepsFingerprint(string tokenHost = null, string authMethod = null) => + ProtectedWorkflowService.ComputeStepsFingerprint(Workflow, Steps.Where(st => st.WorkflowId == Workflow.WorkflowId), Host, tokenHost, authMethod); + + /// Outcome records (the pre-send "attempted" records are separate). + public IEnumerable DisclosureRecords => + Disclosures.Where(d => d.RecordType == ProtectedWorkflowRecordTypes.Disclosure && d.Outcome != ProtectedWorkflowDisclosureOutcomes.Attempted); + + public IEnumerable AttemptRecords => + Disclosures.Where(d => d.RecordType == ProtectedWorkflowRecordTypes.Disclosure && d.Outcome == ProtectedWorkflowDisclosureOutcomes.Attempted); + + public IEnumerable AdminEvents => + Disclosures.Where(d => d.RecordType == ProtectedWorkflowRecordTypes.AdminEvent); + + /// Everything the platform persisted for runs, logs and the chain, flattened to text. + public string EverythingPersisted() => + JsonConvert.SerializeObject(new { Runs = Runs.Values, Logs, Disclosures, Releases }); + + public static T Clone(T value) => value == null ? default : JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value)); + + // ── Repositories ───────────────────────────────────────────────────────────────────────────── + + private IWorkflowProtectedReleaseRepository ReleaseRepository() + { + var mock = new Mock(); + mock.Setup(r => r.GetLatestByWorkflowIdAsync(It.IsAny())) + .ReturnsAsync((string id) => Clone(Releases.Where(r => r.WorkflowId == id).OrderByDescending(r => r.CreatedOn).FirstOrDefault())); + mock.Setup(r => r.GetAllByWorkflowIdAsync(It.IsAny())) + .ReturnsAsync((string id) => Releases.Where(r => r.WorkflowId == id).Select(Clone).ToList()); + mock.Setup(r => r.GetAllByDepartmentIdAsync(It.IsAny())) + .ReturnsAsync((int id) => Releases.Where(r => r.DepartmentId == id).OrderByDescending(r => r.CreatedOn).Select(Clone).ToList()); + mock.Setup(r => r.GetAllByCredentialIdAsync(It.IsAny())) + .ReturnsAsync((string id) => Releases.Where(r => r.WorkflowCredentialId == id).Select(Clone).ToList()); + mock.Setup(r => r.GetAllByStatesAsync(It.IsAny>())) + .ReturnsAsync((IEnumerable states) => Releases.Where(r => states.Contains(r.ReleaseState)).Select(Clone).ToList()); + mock.Setup(r => r.GetByIdAsync(It.IsAny())) + .ReturnsAsync((object id) => Clone(Releases.FirstOrDefault(r => r.WorkflowProtectedReleaseId == (string)id))); + mock.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowProtectedRelease r, CancellationToken c, bool f) => { Releases.Add(Clone(r)); return r; }); + mock.Setup(r => r.TryUpdateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowProtectedRelease r, CancellationToken c) => + { + BeforeReleaseUpdate?.Invoke(r); + var index = Releases.FindIndex(x => x.WorkflowProtectedReleaseId == r.WorkflowProtectedReleaseId); + if (index < 0 || Releases[index].Version != r.Version) + return false; + r.Version++; + Releases[index] = Clone(r); + return true; + }); + mock.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowProtectedRelease r, CancellationToken c) => Releases.RemoveAll(x => x.WorkflowProtectedReleaseId == r.WorkflowProtectedReleaseId) > 0); + return mock.Object; + } + + private IProtectedWorkflowDisclosureRepository DisclosureRepository() + { + var mock = new Mock(); + mock.Setup(r => r.AppendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectedWorkflowDisclosure d, CancellationToken c) => + { + if (FailDisclosureAppends) + throw new InvalidOperationException("chain unavailable"); + d.ProtectedWorkflowDisclosureId ??= Guid.NewGuid().ToString(); + ProtectedWorkflowDisclosureChain.Link(d, Disclosures.Where(x => x.DepartmentId == d.DepartmentId).OrderBy(x => x.ChainSequence).LastOrDefault()); + Disclosures.Add(Clone(d)); + return d; + }); + mock.Setup(r => r.GetChainForDepartmentAsync(It.IsAny())) + .ReturnsAsync((int id) => Disclosures.Where(d => d.DepartmentId == id).OrderBy(d => d.ChainSequence).Select(Clone).ToList()); + mock.Setup(r => r.GetForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int id, ProtectedWorkflowDisclosureFilter f) => Disclosures + .Where(d => d.DepartmentId == id) + .Where(d => f?.WorkflowId == null || d.WorkflowId == f.WorkflowId) + .Where(d => f?.EntityId == null || d.EntityId == f.EntityId) + .Where(d => f?.RecordType == null || d.RecordType == f.RecordType) + .OrderByDescending(d => d.ChainSequence).Select(Clone).ToList()); + return mock.Object; + } + + private IWorkflowRepository WorkflowRepository() + { + var mock = new Mock(); + mock.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((object id) => + (string)id == Workflow.WorkflowId ? Clone(Workflow) : Clone(OtherWorkflows.FirstOrDefault(w => w.WorkflowId == (string)id))); + mock.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Workflow w, CancellationToken c, bool f) => w); + mock.Setup(r => r.DeleteWorkflowWithAllDependenciesAsync(It.IsAny())) + .Returns((string id) => { Steps.RemoveAll(s => s.WorkflowId == id); return Task.CompletedTask; }); + return mock.Object; + } + + private IWorkflowStepRepository StepRepository() + { + var mock = new Mock(); + mock.Setup(r => r.GetAllByWorkflowIdAsync(It.IsAny())) + .ReturnsAsync((string id) => Steps.Where(s => s.WorkflowId == id).Select(Clone).ToList()); + mock.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((object id) => Clone(Steps.FirstOrDefault(s => s.WorkflowStepId == (string)id))); + mock.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowStep s, CancellationToken c, bool f) => { Steps.Add(Clone(s)); return s; }); + mock.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowStep s, CancellationToken c, bool f) => + { + Steps[Steps.FindIndex(x => x.WorkflowStepId == s.WorkflowStepId)] = Clone(s); + return s; + }); + mock.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowStep s, CancellationToken c) => Steps.RemoveAll(x => x.WorkflowStepId == s.WorkflowStepId) > 0); + return mock.Object; + } + + private IWorkflowCredentialRepository CredentialRepository() + { + var mock = new Mock(); + mock.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((object id) => Clone(Credentials.FirstOrDefault(c => c.WorkflowCredentialId == (string)id))); + mock.Setup(r => r.GetAllByDepartmentIdAsync(It.IsAny())).ReturnsAsync((int id) => Credentials.Where(c => c.DepartmentId == id).Select(Clone).ToList()); + mock.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowCredential c, CancellationToken t, bool f) => + { + Credentials[Credentials.FindIndex(x => x.WorkflowCredentialId == c.WorkflowCredentialId)] = Clone(c); + return c; + }); + mock.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowCredential c, CancellationToken t, bool f) => { Credentials.Add(Clone(c)); return c; }); + mock.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowCredential c, CancellationToken t) => Credentials.RemoveAll(x => x.WorkflowCredentialId == c.WorkflowCredentialId) > 0); + return mock.Object; + } + + private IWorkflowRunRepository RunRepository() + { + var mock = new Mock(); + mock.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((object id) => Runs.TryGetValue((string)id, out var run) ? Clone(run) : null); + mock.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowRun r, CancellationToken c, bool f) => { Runs[r.WorkflowRunId] = Clone(r); return r; }); + mock.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowRun r, CancellationToken c, bool f) => { Runs[r.WorkflowRunId] = Clone(r); return r; }); + return mock.Object; + } + + private IWorkflowRunLogRepository LogRepository() + { + var mock = new Mock(); + mock.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowRunLog l, CancellationToken c, bool f) => { Logs.Add(Clone(l)); return l; }); + return mock.Object; + } + + private IDepartmentDataProtectionService DataProtection() + { + var mock = new Mock(); + mock.Setup(s => s.GetPolicyByDepartmentIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(() => Clone(Policy)); + mock.Setup(s => s.GetStateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(() => (DepartmentDataProtectionState)Policy.State); + mock.Setup(s => s.ShouldEncryptNewWritesAsync(It.IsAny())).ReturnsAsync(() => Policy.State == (int)DepartmentDataProtectionState.Enabled); + mock.Setup(s => s.GetEgressPolicyByDepartmentIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(() => Clone(Egress)); + mock.Setup(s => s.SaveEgressPolicyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentProtectedDataEgressPolicy p, string user, CancellationToken c) => + { + Egress = Clone(p); + EpochBumps++; + return p; + }); + return mock.Object; + } + + private IDepartmentsService Departments() + { + var mock = new Mock(); + mock.Setup(s => s.GetDepartmentByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((int id, bool b) => new Department + { + DepartmentId = id, + Name = "County Behavioral Health", + Code = DepartmentCode, + ManagingUserId = "managing-user", + AdminUsers = new List { AdminA, AdminB } + }); + mock.Setup(s => s.GetActiveAdminsForDepartmentAsync(It.IsAny())) + .ReturnsAsync(new List { new IdentityUser { Id = AdminA }, new IdentityUser { Id = AdminB } }); + return mock.Object; + } + } + + /// Broker double: decrypts known envelopes, records every call, fails on request. + internal sealed class FakeBroker : IProtectedDataBrokerClient + { + public Dictionary Plaintext { get; } = new Dictionary(StringComparer.Ordinal); + public List<(int DepartmentId, string Purpose, string RequestId, List Items)> Calls { get; } = + new List<(int, string, string, List)>(); + public string FailWith { get; set; } + public bool Throw { get; set; } + public HashSet FailFields { get; } = new HashSet(StringComparer.Ordinal); + + public bool IsConfigured => true; + + public Task IsHealthyAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); + + public Task DecryptAsync(int departmentId, string grantToken, string requestId, + IReadOnlyList items, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Protected workflows must never use the attended lane."); + + public List<(int DepartmentId, string RequestId, List Items)> Encrypts { get; } = + new List<(int, string, List)>(); + + /// The encrypt-only workload lane (no grant). An attended (grant) encrypt is never expected here. + public Task EncryptAsync(int departmentId, string grantToken, string requestId, + IReadOnlyList items, CancellationToken cancellationToken = default) + { + if (grantToken != null) + throw new InvalidOperationException("Protected workflows never use the attended lane."); + + Encrypts.Add((departmentId, requestId, items.Select(i => new ProtectedFieldOperationItem + { + FieldId = i.FieldId, RowKey = i.RowKey, CatalogVersion = i.CatalogVersion + }).ToList())); + + var result = new ProtectedDataBrokerResult { Success = FailWith == null }; + foreach (var item in items) + { + var envelope = "rgdp:1:" + item.CatalogVersion + ":" + Convert.ToBase64String(Encoding.UTF8.GetBytes("ENC-" + Guid.NewGuid().ToString("N"))); + Plaintext[envelope] = item.Value; + result.Items.Add(new ProtectedFieldOperationResult { FieldId = item.FieldId, RowKey = item.RowKey, Value = envelope }); + } + return Task.FromResult(result); + } + + public Task DecryptForWorkloadAsync(int departmentId, string purpose, string requestId, + IReadOnlyList items, CancellationToken cancellationToken = default) + { + Calls.Add((departmentId, purpose, requestId, items.Select(i => new ProtectedFieldOperationItem + { + FieldId = i.FieldId, RowKey = i.RowKey, Value = i.Value, CatalogVersion = i.CatalogVersion + }).ToList())); + + if (Throw) + throw new System.Net.Http.HttpRequestException("broker unreachable"); + if (FailWith != null) + return Task.FromResult(new ProtectedDataBrokerResult { Success = false, ErrorCode = FailWith }); + + var result = new ProtectedDataBrokerResult { Success = true }; + foreach (var item in items) + { + var failed = FailFields.Contains(item.FieldId) || !Plaintext.ContainsKey(item.Value); + result.Items.Add(new ProtectedFieldOperationResult + { + FieldId = item.FieldId, + RowKey = item.RowKey, + Value = failed ? null : Plaintext[item.Value], + ErrorCode = failed ? "decrypt_failed" : null + }); + } + + return Task.FromResult(result); + } + } + + /// Executor double: records every context it is handed and answers like the protected HTTP executor. + internal sealed class CapturingExecutor : IWorkflowActionExecutor + { + public List Calls { get; } = new List(); + public Func Respond { get; set; } + public Exception Throw { get; set; } + + public WorkflowActionType ActionType => WorkflowActionType.CallApiPost; + + public Task ExecuteAsync(WorkflowActionContext context, CancellationToken cancellationToken) + { + Calls.Add(context); + if (Throw != null) + throw Throw; + if (Respond != null) + return Task.FromResult(Respond(context)); + + var bytes = Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); + return Task.FromResult(new WorkflowActionResult + { + Success = true, + ResultMessage = "HTTP 204 No Content", + HttpStatus = 204, + PayloadBytes = bytes.Length, + PayloadSha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant() + }); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowLifecycleTests.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowLifecycleTests.cs new file mode 100644 index 000000000..38eca27c3 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowLifecycleTests.cs @@ -0,0 +1,632 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services.ProtectedWorkflows +{ + /// + /// The administrative lifecycle of a Protected Workflow release: "change means re-approval", the credential rules, + /// the two-person rule and step-up, the department toggle, ADP offboarding, expiry, deletion and the sweep. + /// + [TestFixture] + public class ProtectedWorkflowLifecycleTests + { + private ProtectedWorkflowHarness _h; + + [SetUp] + public void SetUp() => _h = new ProtectedWorkflowHarness(); + + // ── Change means re-approval ──────────────────────────────────────────────────────────────── + + private static readonly (string Name, Action Change)[] StepChanges = + { + ("output template", s => s.OutputTemplate += "{{ call.priority }}"), + ("condition", s => s.ConditionExpression = "{{ call.priority > 1 }}"), + ("action type", s => s.ActionType = (int)WorkflowActionType.CallApiPost), + ("step order", s => s.StepOrder = 5), + ("url", s => s.ActionConfig = JsonConvert.SerializeObject(new { Url = ProtectedWorkflowHarness.Url + "/v2" })), + ("header", s => s.ActionConfig = JsonConvert.SerializeObject(new { Url = ProtectedWorkflowHarness.Url, Headers = new { Prefer = "return=minimal" } })), + ("credential id", s => s.WorkflowCredentialId = "cred-2"), + ("disabled", s => s.IsEnabled = false) + }; + + [TestCaseSource(nameof(StepChangeNames))] + public async Task every_step_change_sends_an_active_release_back_to_pending_approval(string change) + { + _h.ActivateRelease(); + var step = ProtectedWorkflowHarness.Clone(_h.Steps[0]); + StepChanges.Single(c => c.Name == change).Change(step); + step.UpdatedByUserId = ProtectedWorkflowHarness.Member; + + await _h.WorkflowService.SaveWorkflowStepAsync(step); + + var release = _h.StoredRelease(); + release.ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval, change); + release.SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.ConfigChanged); + _h.AdminEvents.Should().Contain(e => e.EventType == ProtectedWorkflowAdminEventTypes.ReleaseSuspended && + e.Detail == ProtectedWorkflowSuspendReasons.ConfigChanged && e.ActorUserId == ProtectedWorkflowHarness.Member); + } + + private static string[] StepChangeNames() => StepChanges.Select(c => c.Name).ToArray(); + + [Test] + public async Task adding_or_deleting_a_step_sends_the_release_back_to_pending_approval() + { + _h.ActivateRelease(); + await _h.WorkflowService.SaveWorkflowStepAsync(new WorkflowStep + { + WorkflowId = _h.Workflow.WorkflowId, + ActionType = (int)WorkflowActionType.SendEmail, + StepOrder = 2, + IsEnabled = true, + OutputTemplate = "{{ call.number }}", + CreatedByUserId = ProtectedWorkflowHarness.Member + }); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval); + + var second = new ProtectedWorkflowHarness(); + second.ActivateRelease(); + await second.WorkflowService.DeleteWorkflowStepAsync("step-1"); + second.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval); + } + + [Test] + public async Task changing_the_released_fields_sends_the_release_back_to_pending_approval() + { + var release = _h.ActivateRelease(); + + var result = await _h.Service.SaveDraftAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, new ProtectedReleaseDraft + { + FieldIds = new[] { "calls.completednotes", "calls.callformdata", "calls.contactnumber" }, + RecipientType = release.RecipientType, + RecipientName = release.RecipientName, + Purpose = release.Purpose + }, new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA }); + + result.Success.Should().BeTrue(); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval); + _h.StoredRelease().SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.ConfigChanged); + } + + [Test] + public async Task saving_an_unchanged_step_keeps_the_release_active() + { + _h.ActivateRelease(); + + await _h.WorkflowService.SaveWorkflowStepAsync(ProtectedWorkflowHarness.Clone(_h.Steps[0])); + + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Active); + } + + // ── Credentials ───────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task rotating_a_credential_secret_keeps_the_release_active_and_is_recorded() + { + _h.ActivateRelease(); + var credential = ProtectedWorkflowHarness.Clone(_h.Credentials[0]); + credential.EncryptedData = JsonConvert.SerializeObject(new { token = "rotated-token" }); + credential.UpdatedByUserId = ProtectedWorkflowHarness.AdminB; + + await _h.WorkflowService.SaveCredentialAsync(credential, ProtectedWorkflowHarness.DepartmentCode); + + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Active); + _h.AdminEvents.Should().ContainSingle(e => e.EventType == ProtectedWorkflowAdminEventTypes.CredentialRotated && e.ActorUserId == ProtectedWorkflowHarness.AdminB); + } + + [Test] + public async Task re_saving_a_credential_unchanged_writes_nothing() + { + _h.ActivateRelease(); + var credential = ProtectedWorkflowHarness.Clone(_h.Credentials[0]); + credential.EncryptedData = credential.EncryptedData.Substring(4); + + await _h.WorkflowService.SaveCredentialAsync(credential, ProtectedWorkflowHarness.DepartmentCode); + + _h.AdminEvents.Should().BeEmpty(); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Active); + } + + [Test] + public async Task changing_a_credential_type_suspends_the_release() + { + _h.ActivateRelease(); + var credential = ProtectedWorkflowHarness.Clone(_h.Credentials[0]); + credential.CredentialType = (int)WorkflowCredentialType.HttpApiKey; + credential.EncryptedData = JsonConvert.SerializeObject(new { headerName = "X-Key", apiKey = "k" }); + + await _h.WorkflowService.SaveCredentialAsync(credential, ProtectedWorkflowHarness.DepartmentCode); + + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Suspended); + _h.StoredRelease().SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.CredentialChanged); + } + + [Test] + public async Task deleting_the_credential_suspends_the_release() + { + _h.ActivateRelease(); + + await _h.WorkflowService.DeleteCredentialAsync(ProtectedWorkflowHarness.CredentialId); + + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Suspended); + _h.StoredRelease().SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.CredentialChanged); + } + + // ── Request / approve / two-person rule / step-up ─────────────────────────────────────────── + + private async Task DraftAndRequestAsync(string requester, DateTime? stepUpAt = null) + { + var draft = await _h.Service.SaveDraftAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, new ProtectedReleaseDraft + { + FieldIds = new[] { "calls.completednotes", "calls.callformdata" }, + RecipientType = (int)ProtectedReleaseRecipientType.CoveredEntity, + RecipientName = "County DMH", + Purpose = "Case write-back" + }, new ProtectedWorkflowActor { UserId = requester }); + draft.Success.Should().BeTrue(draft.ErrorCode); + + return await _h.Service.RequestApprovalAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, true, + ProtectedWorkflowDefaults.WarningTextVersion, _h.StepsFingerprint(), ProtectedWorkflowHarness.SteppedUp(requester, stepUpAt)); + } + + [Test] + public async Task a_single_approver_request_activates_with_a_bound_fingerprint_and_one_year_expiry() + { + var result = await DraftAndRequestAsync(ProtectedWorkflowHarness.AdminA); + + result.Success.Should().BeTrue(result.ErrorCode); + var release = _h.StoredRelease(); + release.ReleaseState.Should().Be(ProtectedReleaseState.Active); + release.DestinationHost.Should().Be(ProtectedWorkflowHarness.Host); + release.WorkflowCredentialId.Should().Be(ProtectedWorkflowHarness.CredentialId); + release.ConfigFingerprint.Should().Be(ProtectedWorkflowService.ComputeFingerprint(_h.Workflow, _h.Steps, release)); + release.ApprovedByUserId.Should().Be(ProtectedWorkflowHarness.AdminA); + release.ExpiresOn.Should().BeCloseTo(DateTime.UtcNow.AddDays(365), TimeSpan.FromMinutes(1)); + _h.AdminEvents.Select(e => e.EventType).Should().ContainInOrder(ProtectedWorkflowAdminEventTypes.ReleaseRequested, ProtectedWorkflowAdminEventTypes.ReleaseApproved); + } + + [Test] + public async Task with_the_two_person_rule_the_requester_cannot_approve_their_own_release() + { + _h.Egress.ProtectedWorkflowsRequireSecondApprover = true; + var requested = await DraftAndRequestAsync(ProtectedWorkflowHarness.AdminA); + requested.Success.Should().BeTrue(requested.ErrorCode); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval); + + var self = await _h.Service.ApproveAsync(ProtectedWorkflowHarness.DepartmentId, _h.StoredRelease().WorkflowProtectedReleaseId, true, + ProtectedWorkflowDefaults.WarningTextVersion, _h.StoredRelease().ConfigFingerprint, ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + self.Success.Should().BeFalse(); + self.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.SelfApproval); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval); + + var second = await _h.Service.ApproveAsync(ProtectedWorkflowHarness.DepartmentId, _h.StoredRelease().WorkflowProtectedReleaseId, true, + ProtectedWorkflowDefaults.WarningTextVersion, _h.StoredRelease().ConfigFingerprint, ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminB)); + second.Success.Should().BeTrue(second.ErrorCode); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Active); + _h.StoredRelease().ApprovedByUserId.Should().Be(ProtectedWorkflowHarness.AdminB); + } + + [Test] + public async Task approval_without_a_step_up_or_with_a_stale_one_is_refused() + { + _h.Egress.ProtectedWorkflowsRequireSecondApprover = true; + (await DraftAndRequestAsync(ProtectedWorkflowHarness.AdminA)).Success.Should().BeTrue(); + var releaseId = _h.StoredRelease().WorkflowProtectedReleaseId; + + var none = await _h.Service.ApproveAsync(ProtectedWorkflowHarness.DepartmentId, releaseId, true, ProtectedWorkflowDefaults.WarningTextVersion, _h.StoredRelease().ConfigFingerprint, + new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminB, IsInteractive = true }); + var stale = await _h.Service.ApproveAsync(ProtectedWorkflowHarness.DepartmentId, releaseId, true, ProtectedWorkflowDefaults.WarningTextVersion, _h.StoredRelease().ConfigFingerprint, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminB, DateTime.UtcNow.AddHours(-2))); + + none.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.StepUpRequired); + stale.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.StepUpRequired); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval); + } + + [Test] + public async Task a_request_without_step_up_is_refused_and_nothing_activates() + { + var result = await DraftAndRequestAsync(ProtectedWorkflowHarness.AdminA, DateTime.UtcNow.AddHours(-1)); + + result.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.StepUpRequired); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Draft); + } + + [Test] + public async Task a_request_without_the_attestation_or_with_an_old_warning_version_is_refused() + { + await DraftAndRequestAsync(ProtectedWorkflowHarness.AdminA, DateTime.UtcNow.AddHours(-1)); + + var unattested = await _h.Service.RequestApprovalAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, false, + ProtectedWorkflowDefaults.WarningTextVersion, _h.StepsFingerprint(), ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + var oldVersion = await _h.Service.RequestApprovalAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, true, + "PW-WARN-0", _h.StepsFingerprint(), ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + + unattested.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.AttestationRequired); + oldVersion.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.AckVersionMismatch); + } + + [Test] + public async Task a_member_without_the_adp_egress_permission_cannot_request_suspend_or_revoke() + { + var release = _h.ActivateRelease(); + var member = ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.Member); + + (await _h.Service.SuspendAsync(ProtectedWorkflowHarness.DepartmentId, release.WorkflowProtectedReleaseId, member)).ErrorCode + .Should().Be(ProtectedWorkflowErrorCodes.PermissionDenied); + (await _h.Service.RevokeAsync(ProtectedWorkflowHarness.DepartmentId, release.WorkflowProtectedReleaseId, member)).ErrorCode + .Should().Be(ProtectedWorkflowErrorCodes.PermissionDenied); + (await _h.Service.RenewAsync(ProtectedWorkflowHarness.DepartmentId, release.WorkflowProtectedReleaseId, true, ProtectedWorkflowDefaults.WarningTextVersion, _h.StoredRelease().ConfigFingerprint, member)).ErrorCode + .Should().Be(ProtectedWorkflowErrorCodes.PermissionDenied); + } + + [Test] + public async Task an_invalid_workflow_cannot_be_requested() + { + _h.Steps.Add(new WorkflowStep { WorkflowStepId = "step-mail", WorkflowId = _h.Workflow.WorkflowId, ActionType = (int)WorkflowActionType.SendEmail, StepOrder = 2, IsEnabled = true, OutputTemplate = "x" }); + + var result = await DraftAndRequestAsync(ProtectedWorkflowHarness.AdminA); + + result.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.ValidationFailed); + result.ValidationErrors.Select(e => e.Code).Should().Contain(ProtectedWorkflowValidator.ActionNotAllowed); + } + + [Test] + public async Task a_renewal_under_the_two_person_rule_keeps_sending_until_the_second_approval() + { + _h.Egress.ProtectedWorkflowsRequireSecondApprover = true; + var release = _h.ActivateRelease(); + var originalExpiry = _h.StoredRelease().ExpiresOn; + + var renewed = await _h.Service.RenewAsync(ProtectedWorkflowHarness.DepartmentId, release.WorkflowProtectedReleaseId, true, + ProtectedWorkflowDefaults.WarningTextVersion, _h.StoredRelease().ConfigFingerprint, ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminB)); + renewed.Success.Should().BeTrue(renewed.ErrorCode); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Active); + _h.StoredRelease().ExpiresOn.Should().Be(originalExpiry); + + var approved = await _h.Service.ApproveAsync(ProtectedWorkflowHarness.DepartmentId, release.WorkflowProtectedReleaseId, true, + ProtectedWorkflowDefaults.WarningTextVersion, _h.StoredRelease().ConfigFingerprint, ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + approved.Success.Should().BeTrue(approved.ErrorCode); + _h.StoredRelease().ExpiresOn.Should().BeCloseTo(DateTime.UtcNow.AddDays(365), TimeSpan.FromMinutes(1)); + } + + [Test] + public async Task a_renewal_cannot_rebind_an_approval_to_a_configuration_that_changed_underneath_it() + { + _h.Egress.ProtectedWorkflowsRequireSecondApprover = true; + var release = _h.ActivateRelease(); + _h.Steps[0].OutputTemplate += "{{ protected.call.notes }}"; // an edit whose save hook never ran + + var renewed = await _h.Service.RenewAsync(ProtectedWorkflowHarness.DepartmentId, release.WorkflowProtectedReleaseId, true, + ProtectedWorkflowDefaults.WarningTextVersion, _h.StoredRelease().ConfigFingerprint, ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + + renewed.Success.Should().BeTrue(renewed.ErrorCode); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval, "the changed configuration needs a second administrator"); + (await _h.RunAsync()).SkipReason.Should().Be("protected_release_pending_approval"); + (await _h.Service.ApproveAsync(ProtectedWorkflowHarness.DepartmentId, release.WorkflowProtectedReleaseId, true, + ProtectedWorkflowDefaults.WarningTextVersion, _h.StoredRelease().ConfigFingerprint, ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA))).ErrorCode + .Should().Be(ProtectedWorkflowErrorCodes.SelfApproval); + } + + // ── Department toggle ─────────────────────────────────────────────────────────────────────── + + [Test] + public async Task turning_the_department_toggle_off_suspends_every_release_and_bumps_the_epoch() + { + _h.ActivateRelease(); + + var result = await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, false, false, null, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + + result.Success.Should().BeTrue(result.ErrorCode); + _h.Egress.ProtectedWorkflowsEnabled.Should().BeFalse(); + _h.EpochBumps.Should().Be(1); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Suspended); + _h.StoredRelease().SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.DepartmentDisabled); + _h.AdminEvents.Should().Contain(e => e.EventType == ProtectedWorkflowAdminEventTypes.DepartmentDisabled); + } + + [Test] + public async Task turning_the_toggle_on_needs_the_current_warning_acknowledged_step_up_and_active_adp() + { + _h.Egress = new DepartmentProtectedDataEgressPolicy { DepartmentProtectedDataEgressPolicyId = 1, DepartmentId = ProtectedWorkflowHarness.DepartmentId }; + + (await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, true, false, null, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA))).ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.AckVersionMismatch); + + (await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, true, false, ProtectedWorkflowDefaults.WarningTextVersion, + new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA, IsInteractive = true })).ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.StepUpRequired); + + _h.Policy.State = (int)DepartmentDataProtectionState.Disabled; + (await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, true, false, ProtectedWorkflowDefaults.WarningTextVersion, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA))).ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.AdpNotEnabled); + + _h.Policy.State = (int)DepartmentDataProtectionState.Enabled; + var enabled = await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, true, true, ProtectedWorkflowDefaults.WarningTextVersion, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + enabled.Success.Should().BeTrue(enabled.ErrorCode); + _h.Egress.ProtectedWorkflowsEnabled.Should().BeTrue(); + _h.Egress.ProtectedWorkflowsRequireSecondApprover.Should().BeTrue(); + _h.Egress.ProtectedWorkflowsAckVersion.Should().Be(ProtectedWorkflowDefaults.WarningTextVersion); + _h.Egress.ProtectedWorkflowsAckByUserId.Should().Be(ProtectedWorkflowHarness.AdminA); + _h.AdminEvents.Should().ContainSingle(e => e.EventType == ProtectedWorkflowAdminEventTypes.DepartmentEnabled); + } + + [Test] + public async Task relaxing_the_two_person_rule_needs_a_second_administrator() + { + _h.Egress.ProtectedWorkflowsRequireSecondApprover = true; + + var requested = await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, true, false, ProtectedWorkflowDefaults.WarningTextVersion, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + requested.Success.Should().BeTrue(requested.ErrorCode); + requested.PendingConfirmation.Should().BeTrue(); + _h.Egress.ProtectedWorkflowsRequireSecondApprover.Should().BeTrue("one administrator alone cannot drop the second approver"); + _h.Egress.ProtectedWorkflowsRelaxRequestedByUserId.Should().Be(ProtectedWorkflowHarness.AdminA); + + var self = await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, true, false, ProtectedWorkflowDefaults.WarningTextVersion, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + self.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.SelfApproval); + _h.Egress.ProtectedWorkflowsRequireSecondApprover.Should().BeTrue(); + + var confirmed = await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, true, false, ProtectedWorkflowDefaults.WarningTextVersion, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminB)); + confirmed.Success.Should().BeTrue(confirmed.ErrorCode); + confirmed.PendingConfirmation.Should().BeFalse(); + _h.Egress.ProtectedWorkflowsRequireSecondApprover.Should().BeFalse(); + _h.Egress.ProtectedWorkflowsRelaxRequestedByUserId.Should().BeNull(); + _h.AdminEvents.Select(e => e.EventType).Should().ContainInOrder( + ProtectedWorkflowAdminEventTypes.SecondApproverRelaxRequested, ProtectedWorkflowAdminEventTypes.SecondApproverRelaxed); + } + + [Test] + public async Task tightening_the_two_person_rule_is_immediate_and_cancels_a_pending_relax() + { + _h.Egress.ProtectedWorkflowsRequireSecondApprover = true; + await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, true, false, ProtectedWorkflowDefaults.WarningTextVersion, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + + var kept = await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, true, true, ProtectedWorkflowDefaults.WarningTextVersion, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + + kept.Success.Should().BeTrue(kept.ErrorCode); + _h.Egress.ProtectedWorkflowsRequireSecondApprover.Should().BeTrue(); + _h.Egress.ProtectedWorkflowsRelaxRequestedByUserId.Should().BeNull(); + + var laterConfirm = await _h.Service.SetDepartmentSettingsAsync(ProtectedWorkflowHarness.DepartmentId, true, false, ProtectedWorkflowDefaults.WarningTextVersion, + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminB)); + laterConfirm.PendingConfirmation.Should().BeTrue("a cancelled request cannot be confirmed; this starts a new one"); + _h.Egress.ProtectedWorkflowsRequireSecondApprover.Should().BeTrue(); + } + + // ── Concurrent writers ────────────────────────────────────────────────────────────────────── + + [Test] + public async Task a_sweep_working_from_a_stale_read_cannot_write_a_revoked_release_back() + { + var release = _h.ActivateRelease(); + _h.StoredRelease(release.WorkflowProtectedReleaseId).ExpiresOn = DateTime.UtcNow.AddDays(-1); + RevokeUnderneathTheNextWrite(release.WorkflowProtectedReleaseId); + + var result = await _h.Service.RunSweepAsync(DateTime.UtcNow); + + result.Expired.Should().Be(0); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Revoked); + _h.Notifications.Should().BeEmpty(); + } + + [Test] + public async Task an_expiry_notice_is_not_sent_for_a_release_revoked_while_the_sweep_ran() + { + var release = _h.ActivateRelease(); + _h.StoredRelease(release.WorkflowProtectedReleaseId).ExpiresOn = DateTime.UtcNow.AddDays(6); + RevokeUnderneathTheNextWrite(release.WorkflowProtectedReleaseId); + + var result = await _h.Service.RunSweepAsync(DateTime.UtcNow); + + result.NoticesSent.Should().Be(0); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Revoked); + _h.Notifications.Should().BeEmpty(); + } + + [Test] + public async Task an_approval_racing_a_revoke_does_not_resurrect_the_release() + { + _h.Egress.ProtectedWorkflowsRequireSecondApprover = true; + (await DraftAndRequestAsync(ProtectedWorkflowHarness.AdminA)).Success.Should().BeTrue(); + var releaseId = _h.StoredRelease().WorkflowProtectedReleaseId; + RevokeUnderneathTheNextWrite(releaseId); + + var approved = await _h.Service.ApproveAsync(ProtectedWorkflowHarness.DepartmentId, releaseId, true, ProtectedWorkflowDefaults.WarningTextVersion, + _h.StoredRelease().ConfigFingerprint, ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminB)); + + approved.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.ConcurrentChange); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Revoked); + } + + [Test] + public async Task approving_with_a_fingerprint_other_than_the_one_reviewed_is_refused() + { + _h.Egress.ProtectedWorkflowsRequireSecondApprover = true; + (await DraftAndRequestAsync(ProtectedWorkflowHarness.AdminA)).Success.Should().BeTrue(); + + var approved = await _h.Service.ApproveAsync(ProtectedWorkflowHarness.DepartmentId, _h.StoredRelease().WorkflowProtectedReleaseId, true, + ProtectedWorkflowDefaults.WarningTextVersion, _h.StepsFingerprint() + "x", ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminB)); + + approved.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.ConfigChanged); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval); + } + + [Test] + public async Task a_request_over_a_configuration_the_requester_did_not_review_is_refused() + { + var reviewed = _h.StepsFingerprint(); + _h.Steps[0].OutputTemplate += "{{ protected.call.notes }}"; // changed after the panel was loaded + + await _h.Service.SaveDraftAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, new ProtectedReleaseDraft + { + FieldIds = new[] { "calls.completednotes" }, + RecipientType = (int)ProtectedReleaseRecipientType.CoveredEntity, + RecipientName = "County DMH", + Purpose = "Case write-back" + }, new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA }); + var result = await _h.Service.RequestApprovalAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, true, + ProtectedWorkflowDefaults.WarningTextVersion, reviewed, ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + + result.ErrorCode.Should().Be(ProtectedWorkflowErrorCodes.ConfigChanged); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Draft); + } + + /// The next conditional write to the release finds it already revoked by someone else. + private void RevokeUnderneathTheNextWrite(string releaseId) + { + var raced = false; + _h.BeforeReleaseUpdate = r => + { + if (raced || r.WorkflowProtectedReleaseId != releaseId) + return; + raced = true; + var stored = _h.StoredRelease(releaseId); + stored.State = (int)ProtectedReleaseState.Revoked; + stored.RevokedOn = DateTime.UtcNow; + stored.Version++; + }; + } + + // ── ADP offboarding, expiry, deletion, sweep ──────────────────────────────────────────────── + + [TestCase(DepartmentDataProtectionState.OffboardingScheduled)] + [TestCase(DepartmentDataProtectionState.Decrypting)] + [TestCase(DepartmentDataProtectionState.Disabled)] + public async Task adp_offboarding_revokes_releases_at_send_time(DepartmentDataProtectionState state) + { + _h.ActivateRelease(); + _h.Policy.State = (int)state; + + await _h.RunAsync(); + + _h.Capturing.Calls.Should().BeEmpty(); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Revoked); + _h.StoredRelease().SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.AdpOffboarding); + _h.DisclosureRecords.Single().Outcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.BlockedDepartment); + } + + [Test] + public async Task the_sweep_revokes_offboarding_departments_expires_old_releases_and_sends_notices_once() + { + var release = _h.ActivateRelease(); + _h.StoredRelease(release.WorkflowProtectedReleaseId).ExpiresOn = DateTime.UtcNow.AddDays(6); + + var first = await _h.Service.RunSweepAsync(DateTime.UtcNow); + var again = await _h.Service.RunSweepAsync(DateTime.UtcNow); + + first.NoticesSent.Should().Be(1, "the 7-day notice is due (the 30-day one is skipped once 7 applies)"); + again.NoticesSent.Should().Be(0, "a notice is sent once per threshold"); + _h.Notifications.Should().HaveCount(2).And.OnlyContain(n => n.Contains(_h.Workflow.Name)); + + var expired = await _h.Service.RunSweepAsync(DateTime.UtcNow.AddDays(7)); + expired.Expired.Should().Be(1); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Expired); + + _h.Policy.State = (int)DepartmentDataProtectionState.OffboardingScheduled; + var offboarded = await _h.Service.RunSweepAsync(DateTime.UtcNow); + offboarded.Revoked.Should().Be(1); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Revoked); + } + + [Test] + public async Task the_sweep_suspends_releases_when_the_toggle_is_off() + { + _h.ActivateRelease(); + _h.Egress.ProtectedWorkflowsEnabled = false; + + var result = await _h.Service.RunSweepAsync(DateTime.UtcNow); + + result.Suspended.Should().Be(1); + _h.StoredRelease().SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.DepartmentDisabled); + } + + [Test] + public async Task deleting_the_workflow_revokes_its_release_and_keeps_the_disclosures() + { + _h.ActivateRelease(); + await _h.RunAsync(); + var disclosures = _h.DisclosureRecords.Count(); + + await _h.WorkflowService.DeleteWorkflowAsync(_h.Workflow.WorkflowId); + + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Revoked); + _h.StoredRelease().SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.WorkflowDeleted); + _h.DisclosureRecords.Should().HaveCount(disclosures); + } + + [Test] + public async Task a_draft_that_was_never_requested_can_be_discarded_and_the_workflow_runs_redacted_again() + { + await _h.Service.SaveDraftAsync(ProtectedWorkflowHarness.DepartmentId, _h.Workflow.WorkflowId, + new ProtectedReleaseDraft { FieldIds = new[] { "calls.completednotes" } }, new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA }); + (await _h.RunAsync()).SkipReason.Should().Be("protected_release_draft"); + + var discarded = await _h.Service.DiscardDraftAsync(ProtectedWorkflowHarness.DepartmentId, _h.StoredRelease().WorkflowProtectedReleaseId, + new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA }); + + discarded.Success.Should().BeTrue(); + _h.Releases.Should().BeEmpty(); + (await _h.RunAsync()).Status.Should().Be((int)WorkflowRunStatus.Completed); + } + + // ── Save-time template validation ─────────────────────────────────────────────────────────── + + [Test] + public async Task protected_references_are_flagged_at_save_time() + { + var plain = new WorkflowStep { WorkflowId = _h.Workflow.WorkflowId, OutputTemplate = "{{ protected.call.notes }}" }; + (await _h.Service.ValidateStepTemplatesAsync(plain)).Should().Be(ProtectedWorkflowValidator.ProtectedWithoutRelease); + + _h.ActivateRelease(); + (await _h.Service.ValidateStepTemplatesAsync(plain)).Should().BeNull("a protected workflow may reference its released fields"); + (await _h.Service.ValidateStepTemplatesAsync(new WorkflowStep { WorkflowId = _h.Workflow.WorkflowId, ConditionExpression = "{{ protected.call.notes != '' }}" })) + .Should().Be(ProtectedWorkflowValidator.ProtectedInCondition); + (await _h.Service.ValidateStepTemplatesAsync(new WorkflowStep { WorkflowId = _h.Workflow.WorkflowId, ActionConfig = "{\"Url\":\"https://x.example/{{ protected.call.notes }}\"}" })) + .Should().Be(ProtectedWorkflowValidator.ProtectedInActionConfig); + } + + // ── Chain over the service ────────────────────────────────────────────────────────────────── + + [Test] + public async Task the_department_chain_verifies_and_breaks_when_a_row_is_tampered_with() + { + _h.ActivateRelease(); + await _h.RunAsync(); + await _h.Service.SuspendAsync(ProtectedWorkflowHarness.DepartmentId, _h.StoredRelease().WorkflowProtectedReleaseId, + new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA }); + + var valid = await _h.Service.VerifyChainAsync(ProtectedWorkflowHarness.DepartmentId); + valid.IsValid.Should().BeTrue(); + valid.RecordCount.Should().Be(3, "the attempted record, the sent record and the suspension event"); + + _h.Disclosures.First().HttpStatus = 200; + var tampered = await _h.Service.VerifyChainAsync(ProtectedWorkflowHarness.DepartmentId); + tampered.IsValid.Should().BeFalse(); + tampered.FirstInvalidSequence.Should().Be(1); + } + + [Test] + public async Task the_csv_export_is_metadata_only() + { + _h.ActivateRelease(); + await _h.RunAsync(); + + var csv = await _h.Service.ExportDisclosuresCsvAsync(ProtectedWorkflowHarness.DepartmentId, new ProtectedWorkflowDisclosureFilter()); + + csv.Should().StartWith("Sequence,OccurredOnUtc,RecordType"); + csv.Should().Contain("sent").And.Contain(ProtectedWorkflowHarness.Host); + csv.Should().NotContain(ProtectedWorkflowHarness.SentinelCompletedNotes).And.NotContain(ProtectedWorkflowHarness.SentinelFormOutcome); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowModelTests.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowModelTests.cs new file mode 100644 index 000000000..fc7dbec18 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowModelTests.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services.ProtectedWorkflows +{ + /// The pure pieces: the configuration fingerprint, the disclosure hash chain, the validator and the field catalog. + [TestFixture] + public class ProtectedWorkflowModelTests + { + private static List Steps() => new List + { + new WorkflowStep + { + WorkflowStepId = "s1", StepOrder = 1, IsEnabled = true, ActionType = (int)WorkflowActionType.CallApiPut, + OutputTemplate = "{{ protected.call.completed_notes }}", ConditionExpression = null, WorkflowCredentialId = "cred-1", + ActionConfig = "{\"Url\":\"https://org.crm.dynamics.com/api\",\"Headers\":{\"b\":\"2\",\"a\":\"1\"}}" + } + }; + + private static readonly string[] Fields = { "calls.completednotes" }; + + private static string Fingerprint(List steps, IEnumerable fields = null, string host = "org.crm.dynamics.com", + string tokenHost = null, int trigger = (int)WorkflowTriggerEventType.CallClosed) => + ProtectedWorkflowFingerprint.Compute(trigger, steps, fields ?? Fields, host, tokenHost); + + // ── Fingerprint ───────────────────────────────────────────────────────────────────────────── + + [Test] + public void every_fingerprint_input_changes_the_fingerprint() + { + var baseline = Fingerprint(Steps()); + + var variants = new Dictionary + { + ["trigger"] = Fingerprint(Steps(), trigger: (int)WorkflowTriggerEventType.CallUpdated), + ["action type"] = Fingerprint(Mutate(s => s.ActionType = (int)WorkflowActionType.CallApiPost)), + ["step order"] = Fingerprint(Mutate(s => s.StepOrder = 2)), + ["output template"] = Fingerprint(Mutate(s => s.OutputTemplate += "!")), + ["condition"] = Fingerprint(Mutate(s => s.ConditionExpression = "{{ true }}")), + ["url"] = Fingerprint(Mutate(s => s.ActionConfig = s.ActionConfig.Replace("/api", "/api2"))), + ["header"] = Fingerprint(Mutate(s => s.ActionConfig = s.ActionConfig.Replace("\"1\"", "\"9\""))), + ["credential id"] = Fingerprint(Mutate(s => s.WorkflowCredentialId = "cred-2")), + ["enabled"] = Fingerprint(Mutate(s => s.IsEnabled = false)), + ["fields"] = Fingerprint(Steps(), new[] { "calls.completednotes", "calls.callformdata" }), + ["destination host"] = Fingerprint(Steps(), host: "other.crm.dynamics.com"), + ["token host"] = Fingerprint(Steps(), tokenHost: "login.microsoftonline.com") + }; + + foreach (var variant in variants) + variant.Value.Should().NotBe(baseline, variant.Key); + } + + [Test] + public void the_fingerprint_ignores_json_key_order_host_casing_and_line_endings() + { + var reordered = Mutate(s => + { + s.ActionConfig = "{\"Headers\":{\"a\":\"1\",\"b\":\"2\"},\"Url\":\"https://org.crm.dynamics.com/api\"}"; + }); + Fingerprint(reordered, new[] { "CALLS.COMPLETEDNOTES" }, "ORG.crm.dynamics.com").Should().Be(Fingerprint(Steps())); + + Fingerprint(Mutate(s => s.OutputTemplate = "line1\r\nline2")).Should().Be(Fingerprint(Mutate(s => s.OutputTemplate = "line1\nline2"))); + } + + [Test] + public void date_looking_values_are_fingerprinted_verbatim() + { + // Parsed into a local DateTime, a date-looking header would make the fingerprint depend on the host's time + // zone, and web and worker hosts must agree byte for byte. + var dated = Mutate(s => s.ActionConfig = "{\"Url\":\"https://org.crm.dynamics.com/api\",\"Headers\":{\"X-Since\":\"2026-09-24T10:00:00+02:00\"}}"); + var utc = Mutate(s => s.ActionConfig = "{\"Url\":\"https://org.crm.dynamics.com/api\",\"Headers\":{\"X-Since\":\"2026-09-24T08:00:00Z\"}}"); + + Fingerprint(dated).Should().MatchRegex("^[0-9a-f]{64}$"); + Fingerprint(dated).Should().NotBe(Fingerprint(utc), "the same instant written differently is a different configuration"); + } + + private static List Mutate(Action change) + { + var steps = Steps(); + change(steps[0]); + return steps; + } + + // ── Disclosure chain ──────────────────────────────────────────────────────────────────────── + + private static List Chain(int count) + { + var rows = new List(); + for (var i = 0; i < count; i++) + { + var row = new ProtectedWorkflowDisclosure + { + ProtectedWorkflowDisclosureId = "d" + i, + DepartmentId = 42, + RecordType = ProtectedWorkflowRecordTypes.Disclosure, + WorkflowId = "wf-1", + EntityType = "call", + EntityId = (1000 + i).ToString(), + FieldIds = "[\"calls.completednotes\"]", + DestinationHost = "org.crm.dynamics.com", + PayloadSha256 = new string('a', 64), + PayloadBytes = 120 + i, + HttpStatus = 204, + Outcome = ProtectedWorkflowDisclosureOutcomes.Sent, + OccurredOn = new DateTime(2026, 9, 24, 10, 0, i, 123, DateTimeKind.Utc).AddTicks(4567) + }; + ProtectedWorkflowDisclosureChain.Link(row, rows.LastOrDefault()); + rows.Add(row); + } + return rows; + } + + [Test] + public void hashes_chain_from_the_genesis_value() + { + var rows = Chain(4); + + rows[0].PrevHash.Should().Be(ProtectedWorkflowDisclosureChain.GenesisHash); + rows[0].ChainSequence.Should().Be(1); + for (var i = 1; i < rows.Count; i++) + { + rows[i].PrevHash.Should().Be(rows[i - 1].Hash); + rows[i].ChainSequence.Should().Be(i + 1); + } + rows.Should().OnlyContain(r => r.OccurredOn.Ticks % TimeSpan.TicksPerMillisecond == 0, "stored at the precision it is hashed at"); + ProtectedWorkflowDisclosureChain.Verify(rows).Should().BeNull(); + } + + [Test] + public void a_chain_read_back_from_a_database_still_verifies() + { + // Unspecified kind (as Dapper hands back a datetime2) must hash identically. + var rows = Chain(3).Select(ProtectedWorkflowHarness.Clone).ToList(); + foreach (var row in rows) + row.OccurredOn = DateTime.SpecifyKind(row.OccurredOn, DateTimeKind.Unspecified); + + ProtectedWorkflowDisclosureChain.Verify(rows).Should().BeNull(); + } + + private static readonly (string Name, Action Tamper)[] Tampers = + { + ("outcome", r => r.Outcome = ProtectedWorkflowDisclosureOutcomes.FailedHttp), + ("http status", r => r.HttpStatus = 200), + ("field ids", r => r.FieldIds = "[\"calls.completednotes\",\"calls.notes\"]"), + ("host", r => r.DestinationHost = "evil.example"), + ("payload hash", r => r.PayloadSha256 = new string('b', 64)), + ("bytes", r => r.PayloadBytes = 1), + ("entity", r => r.EntityId = "9999"), + ("time", r => r.OccurredOn = r.OccurredOn.AddSeconds(1)), + ("test flag", r => r.IsTest = true), + ("actor", r => r.ActorUserId = "someone"), + ("detail", r => r.Detail = "x"), + ("prev hash", r => r.PrevHash = ProtectedWorkflowDisclosureChain.GenesisHash) + }; + + private static string[] TamperNames() => Tampers.Select(t => t.Name).ToArray(); + + [TestCaseSource(nameof(TamperNames))] + public void tampering_with_any_row_breaks_verification_at_that_row(string field) + { + var rows = Chain(5); + Tampers.Single(t => t.Name == field).Tamper(rows[2]); + + ProtectedWorkflowDisclosureChain.Verify(rows).Should().Be(3, field); + } + + [Test] + public void removing_or_reordering_rows_breaks_verification() + { + var removed = Chain(5); + removed.RemoveAt(2); + ProtectedWorkflowDisclosureChain.Verify(removed).Should().Be(4); + + var truncatedHead = Chain(3); + truncatedHead.RemoveAt(0); + ProtectedWorkflowDisclosureChain.Verify(truncatedHead).Should().Be(2); + } + + // ── Validator ─────────────────────────────────────────────────────────────────────────────── + + private static readonly IReadOnlyDictionary Credentials = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["cred-1"] = (int)WorkflowCredentialType.HttpBearer, + ["basic"] = (int)WorkflowCredentialType.HttpBasic, + ["oauth"] = (int)WorkflowCredentialType.OAuth2ClientCredentials, + ["smtp"] = (int)WorkflowCredentialType.Smtp + }; + + private static IEnumerable Errors(List steps, bool allowBasic = false, int trigger = (int)WorkflowTriggerEventType.CallClosed) => + ProtectedWorkflowValidator.Validate(trigger, steps, Credentials, allowBasic).Errors.Select(e => e.Code); + + [Test] + public void a_valid_workflow_pins_one_host_and_one_credential() + { + var result = ProtectedWorkflowValidator.Validate((int)WorkflowTriggerEventType.CallClosed, Steps(), Credentials, false); + + result.IsValid.Should().BeTrue(); + result.DestinationHost.Should().Be("org.crm.dynamics.com"); + result.WorkflowCredentialId.Should().Be("cred-1"); + } + + [Test] + public void only_api_post_or_put_steps_are_allowed_even_when_disabled() + { + var steps = Steps(); + steps.Add(new WorkflowStep { WorkflowStepId = "mail", StepOrder = 2, IsEnabled = false, ActionType = (int)WorkflowActionType.SendEmail }); + + Errors(steps).Should().Contain(ProtectedWorkflowValidator.ActionNotAllowed); + Errors(Mutate(s => s.ActionType = (int)WorkflowActionType.CallApiGet)).Should().Contain(ProtectedWorkflowValidator.ActionNotAllowed); + } + + [Test] + public void destinations_must_be_one_literal_https_host() + { + Errors(Mutate(s => s.ActionConfig = "{\"Url\":\"http://org.crm.dynamics.com/api\"}")).Should().Contain(ProtectedWorkflowValidator.SchemeNotHttps); + Errors(Mutate(s => s.ActionConfig = "{\"Url\":\"https://{{ call.external_id }}.crm.dynamics.com/api\"}")).Should().Contain(ProtectedWorkflowValidator.HostNotLiteral); + Errors(Mutate(s => s.ActionConfig = "{\"Url\":\"https://user@org.crm.dynamics.com/api\"}")).Should().Contain(ProtectedWorkflowValidator.HostNotLiteral); + Errors(Mutate(s => s.ActionConfig = "{}")).Should().Contain(ProtectedWorkflowValidator.UrlRequired); + + var twoHosts = Steps(); + twoHosts.Add(new WorkflowStep { WorkflowStepId = "s2", StepOrder = 2, IsEnabled = true, ActionType = (int)WorkflowActionType.CallApiPost, WorkflowCredentialId = "cred-1", ActionConfig = "{\"Url\":\"https://other.example.com/x\"}" }); + Errors(twoHosts).Should().Contain(ProtectedWorkflowValidator.HostMismatch); + } + + [Test] + public void credentials_must_be_present_shared_and_of_an_allowed_type() + { + Errors(Mutate(s => s.WorkflowCredentialId = null)).Should().Contain(ProtectedWorkflowValidator.CredentialRequired); + Errors(Mutate(s => s.WorkflowCredentialId = "smtp")).Should().Contain(ProtectedWorkflowValidator.CredentialNotAllowed); + Errors(Mutate(s => s.WorkflowCredentialId = "missing")).Should().Contain(ProtectedWorkflowValidator.CredentialMissing); + Errors(Mutate(s => s.WorkflowCredentialId = "basic")).Should().Contain(ProtectedWorkflowValidator.CredentialNotAllowed, "Basic is off unless configured"); + Errors(Mutate(s => s.WorkflowCredentialId = "basic"), allowBasic: true).Should().BeEmpty(); + Errors(Mutate(s => s.WorkflowCredentialId = "oauth")).Should().BeEmpty(); + + var mixed = Steps(); + mixed.Add(new WorkflowStep { WorkflowStepId = "s2", StepOrder = 2, IsEnabled = true, ActionType = (int)WorkflowActionType.CallApiPost, WorkflowCredentialId = "oauth", ActionConfig = "{\"Url\":\"https://org.crm.dynamics.com/x\"}" }); + Errors(mixed).Should().Contain(ProtectedWorkflowValidator.CredentialMismatch); + } + + [Test] + public void protected_values_are_refused_in_conditions_urls_and_headers() + { + Errors(Mutate(s => s.ConditionExpression = "{{ protected.call.notes != '' }}")).Should().Contain(ProtectedWorkflowValidator.ProtectedInCondition); + Errors(Mutate(s => s.ActionConfig = "{\"Url\":\"https://org.crm.dynamics.com/{{ protected.call.external_id }}\"}")).Should().Contain(ProtectedWorkflowValidator.ProtectedInActionConfig); + Errors(Mutate(s => s.ActionConfig = "{\"Url\":\"https://org.crm.dynamics.com/x\",\"Headers\":{\"X\":\"{{ protected[\\\"call\\\"] }}\"}}")).Should().Contain(ProtectedWorkflowValidator.ProtectedInActionConfig); + } + + [Test] + public void the_word_protected_in_plain_text_is_not_a_template_reference() + { + ProtectedWorkflowValidator.ReferencesProtectedNamespace("{\"note\":\"This field is protected.\"}").Should().BeFalse(); + ProtectedWorkflowValidator.ReferencesProtectedNamespace("{{ call.is_protected }}").Should().BeFalse(); + ProtectedWorkflowValidator.ReferencesProtectedNamespace("{{ protected.call.notes }}").Should().BeTrue(); + } + + [Test] + public void only_call_triggers_are_supported_in_v1() + { + Errors(Steps(), trigger: (int)WorkflowTriggerEventType.UnitStatusChanged).Should().Contain(ProtectedWorkflowValidator.TriggerNotSupported); + ProtectedWorkflowFieldCatalog.FieldsFor((int)WorkflowTriggerEventType.UnitStatusChanged).Should().BeEmpty(); + } + + // ── Catalog ───────────────────────────────────────────────────────────────────────────────── + + [Test] + public void the_release_catalog_matches_the_adp_call_catalog_and_the_call_template_names() + { + ProtectedWorkflowFieldCatalog.CallFieldIds.Should().BeEquivalentTo(ProtectedReadService.CallFieldAccessors.Keys, + "a field the ADP catalog protects must be releasable, and nothing else"); + + var call = new Call { Name = "n", Type = "t", NatureOfCall = "na", Notes = "no", CompletedNotes = "cn", Address = "a", GeoLocationData = "g", W3W = "w", + ContactName = "cna", ContactNumber = "cnu", SourceIdentifier = "si", IncidentNumber = "in", ExternalIdentifier = "ei", ReferenceNumber = "rn", + CallFormData = "fd", DeletedReason = "dr" }; + foreach (var field in ProtectedWorkflowFieldCatalog.FieldsFor((int)WorkflowTriggerEventType.CallAdded)) + field.GetCallValue(call).Should().Be(ProtectedReadService.CallFieldAccessors[field.FieldId].Get(call), field.FieldId); + + ProtectedWorkflowFieldCatalog.Find((int)WorkflowTriggerEventType.CallClosed, "calls.completednotes").TemplateName.Should().Be("completed_notes"); + ProtectedWorkflowFieldCatalog.Find((int)WorkflowTriggerEventType.CallClosed, "calls.callformdata").TemplateName.Should().Be("form_data"); + } + + [Test] + public void call_form_data_is_read_from_the_form_builder_field_array() + { + var fields = Newtonsoft.Json.Linq.JArray.Parse( + "[{\"type\":\"text\",\"name\":\"outcome\",\"userData\":[\"Referred\"]}," + + "{\"type\":\"checkbox-group\",\"name\":\"services\",\"userData\":[\"a\",\"b\"]}," + + "{\"type\":\"text\",\"name\":\"blank\"}," + + "{\"type\":\"header\",\"label\":\"No name\"}]"); + + var values = ProtectedWorkflowRuntime.FormValues(fields); + + values["outcome"].Should().Be("Referred"); + ((Scriban.Runtime.ScriptArray)values["services"]).Cast().Should().Equal("a", "b"); + values["blank"].Should().Be(string.Empty); + values.Count.Should().Be(3, "a field without a name has nothing to address it by"); + } + + [Test] + public void the_broker_accepts_the_protected_workflow_purpose() + { + Resgrid.Config.DataProtectionConfig.BrokerWorkloadPurposes.Split(',').Should().Contain(ProtectedWorkflowDefaults.WorkloadPurpose); + } + + [Test] + public void log_text_carries_a_code_and_a_type_never_a_message_unless_asked_and_scrubbed() + { + var ex = new InvalidOperationException("secret body rgdp:1:1:QUJDRA=="); + + ProtectedWorkflowLogText.Error("code", ex).Should().Be("code: System.InvalidOperationException"); + ProtectedWorkflowLogText.Error("code", ex, includeMessage: true).Should().NotContain("rgdp:1:1:QUJDRA=="); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowRuntimeTests.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowRuntimeTests.cs new file mode 100644 index 000000000..d2a44393c --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowRuntimeTests.cs @@ -0,0 +1,606 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Tests.Services.ProtectedWorkflows +{ + /// + /// The unattended Protected Workflows path end to end through the real WorkflowService: what reaches a template, + /// when a send is refused, what the broker is asked for, and — above all — that plaintext never lands in anything + /// the platform writes. + /// + [TestFixture] + public class ProtectedWorkflowRuntimeTests + { + private ProtectedWorkflowHarness _h; + + [SetUp] + public void SetUp() => _h = new ProtectedWorkflowHarness(); + + // ── Template context ──────────────────────────────────────────────────────────────────────── + + [Test] + public async Task without_a_release_protected_namespace_is_empty_and_call_values_are_redacted() + { + _h.Steps[0].OutputTemplate = "{{ call.completed_notes }}|{{ protected.call.completed_notes }}|{{ call.notes }}|{{ protected.call.form.outcome }}"; + + var run = await _h.RunAsync(); + + run.Status.Should().Be((int)WorkflowRunStatus.Completed, string.Join("; ", _h.Logs.Select(l => l.ErrorMessage))); + _h.Capturing.Calls.Should().ContainSingle(); + _h.Capturing.Calls[0].RenderedContent.Should().Be("REDACTED||REDACTED|", "protected.* renders as an empty string, however deep the reference"); + _h.Capturing.Calls[0].ProtectedMode.Should().BeFalse("a department that never used the feature sees no behaviour change"); + _h.Broker.Calls.Should().BeEmpty(); + _h.Disclosures.Should().BeEmpty(); + } + + [Test] + public async Task with_an_active_release_only_allowlisted_fields_appear_under_protected() + { + _h.Steps[0].OutputTemplate = + "{{ call.completed_notes }}|{{ protected.call.completed_notes }}|{{ call.notes }}|{{ protected.call.notes }}|{{ protected.call.form.outcome }}|{{ call.nature }}"; + _h.UsePlainText(); + _h.ActivateRelease("calls.completednotes", "calls.callformdata"); + + var run = await _h.RunAsync(); + + run.Status.Should().Be((int)WorkflowRunStatus.Completed); + var sent = _h.Capturing.Calls.Single(); + sent.ProtectedMode.Should().BeTrue(); + sent.PinnedHost.Should().Be(ProtectedWorkflowHarness.Host); + sent.RenderedContent.Should().Be( + $"REDACTED|{ProtectedWorkflowHarness.SentinelCompletedNotes}|REDACTED||{ProtectedWorkflowHarness.SentinelFormOutcome}|REDACTED", + "call.* stays REDACTED, and a protected field that is not allow-listed is simply absent"); + + var brokerCall = _h.Broker.Calls.Single(); + brokerCall.Purpose.Should().Be("protected-workflow"); + brokerCall.Items.Select(i => i.FieldId).Should().BeEquivalentTo(new[] { "calls.completednotes", "calls.callformdata" }, + "minimum necessary: only the ticked fields are ever sent to the broker"); + brokerCall.Items.Should().OnlyContain(i => i.RowKey == ProtectedWorkflowHarness.CallId.ToString() && i.CatalogVersion == 29); + } + + [Test] + public async Task condition_expressions_cannot_see_plaintext() + { + _h.Steps[0].ConditionExpression = "{{ protected.call.completed_notes == \"" + ProtectedWorkflowHarness.SentinelCompletedNotes + "\" }}"; + _h.ActivateRelease(); + + await _h.RunAsync(); + + _h.Capturing.Calls.Should().BeEmpty("the condition evaluates against the ordinary context, where protected.* does not exist"); + _h.Logs.Single().Status.Should().Be((int)WorkflowRunStatus.Skipped); + _h.Broker.Calls.Should().BeEmpty("a skipped step decrypts nothing"); + } + + [Test] + public async Task a_step_cannot_leave_plaintext_behind_for_the_next_step() + { + _h.Steps[0].OutputTemplate = "{{ call.notes = protected.call.completed_notes }}{{ leaked = protected.call.completed_notes }}ok"; + _h.UsePlainText(); + _h.Steps.Add(new WorkflowStep + { + WorkflowStepId = "step-2", + WorkflowId = _h.Workflow.WorkflowId, + ActionType = (int)WorkflowActionType.CallApiPost, + StepOrder = 2, + IsEnabled = true, + WorkflowCredentialId = ProtectedWorkflowHarness.CredentialId, + ActionConfig = JsonConvert.SerializeObject(new { Url = ProtectedWorkflowHarness.Url + "?n={{ call.notes }}{{ leaked }}", ContentType = "text/plain" }), + OutputTemplate = "{{ call.notes }}|{{ leaked }}", + CreatedByUserId = ProtectedWorkflowHarness.AdminA + }); + _h.ActivateRelease(); + + await _h.RunAsync(); + + _h.Capturing.Calls.Should().HaveCount(2); + _h.Capturing.Calls[1].RenderedContent.Should().Be("REDACTED|"); + _h.Capturing.Calls[1].ActionConfigJson.Should().NotContain(ProtectedWorkflowHarness.SentinelCompletedNotes); + } + + // ── Run gate ──────────────────────────────────────────────────────────────────────────────── + + [TestCase(ProtectedReleaseState.Draft, "protected_release_draft")] + [TestCase(ProtectedReleaseState.PendingApproval, "protected_release_pending_approval")] + [TestCase(ProtectedReleaseState.Suspended, "protected_release_suspended")] + [TestCase(ProtectedReleaseState.Expired, "protected_release_expired")] + [TestCase(ProtectedReleaseState.Revoked, "protected_release_revoked")] + public async Task a_release_that_is_not_active_skips_the_run_and_never_runs_it_unprotected(ProtectedReleaseState state, string reason) + { + var release = _h.ActivateRelease(); + _h.StoredRelease(release.WorkflowProtectedReleaseId).State = (int)state; + + var run = await _h.RunAsync(); + + run.Status.Should().Be((int)WorkflowRunStatus.Skipped); + run.SkipReason.Should().Be(reason); + _h.Capturing.Calls.Should().BeEmpty(); + _h.Broker.Calls.Should().BeEmpty(); + } + + // ── Preconditions ─────────────────────────────────────────────────────────────────────────── + + [TestCase(DepartmentDataProtectionState.Failed)] + [TestCase(DepartmentDataProtectionState.Encrypting)] + public async Task adp_not_enabled_blocks_the_send(DepartmentDataProtectionState state) + { + _h.ActivateRelease(); + _h.Policy.State = (int)state; + + await _h.RunAsync(); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.BlockedDepartment, ProtectedWorkflowErrorCodes.AdpNotEnabled); + } + + [Test] + public async Task department_toggle_off_blocks_the_send() + { + _h.ActivateRelease(); + _h.Egress.ProtectedWorkflowsEnabled = false; + + await _h.RunAsync(); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.BlockedDepartment, ProtectedWorkflowErrorCodes.DepartmentDisabled); + } + + [Test] + public async Task an_expired_release_blocks_the_send_and_is_marked_expired() + { + var release = _h.ActivateRelease(); + _h.StoredRelease(release.WorkflowProtectedReleaseId).ExpiresOn = DateTime.UtcNow.AddMinutes(-1); + + await _h.RunAsync(); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.ReleaseExpired); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Expired); + _h.AdminEvents.Should().Contain(e => e.EventType == ProtectedWorkflowAdminEventTypes.ReleaseExpired); + } + + [Test] + public async Task a_config_change_that_bypassed_the_save_hook_is_caught_at_send_time() + { + _h.ActivateRelease(); + _h.Steps[0].OutputTemplate += " "; + + await _h.RunAsync(); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.ConfigChanged); + _h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.PendingApproval); + _h.StoredRelease().SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.ConfigChanged); + } + + [Test] + public async Task a_non_api_step_blocks_the_send() + { + var release = _h.ActivateRelease(); + _h.Steps[0].ActionType = (int)WorkflowActionType.SendEmail; + _h.Refingerprint(release); + + await _h.RunAsync(); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.ActionNotAllowed); + } + + [Test] + public async Task a_credential_other_than_the_pinned_one_blocks_the_send() + { + var release = _h.ActivateRelease(); + _h.Credentials.Add(new WorkflowCredential { WorkflowCredentialId = "cred-2", DepartmentId = ProtectedWorkflowHarness.DepartmentId, CredentialType = (int)WorkflowCredentialType.HttpBearer, EncryptedData = "enc:{}" }); + _h.Steps[0].WorkflowCredentialId = "cred-2"; + _h.Refingerprint(release); + + await _h.RunAsync(); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.BlockedRelease, ProtectedWorkflowErrorCodes.CredentialNotAllowed); + } + + // ── Host pinning ──────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task a_host_other_than_the_pinned_one_blocks_the_send() + { + var release = _h.ActivateRelease(); + _h.Steps[0].ActionConfig = JsonConvert.SerializeObject(new { Url = "https://attacker.example.com/collect" }); + _h.Refingerprint(release); + + await _h.RunAsync(); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.HostMismatch); + } + + [Test] + public async Task an_http_url_blocks_the_send() + { + var release = _h.ActivateRelease(); + _h.Steps[0].ActionConfig = JsonConvert.SerializeObject(new { Url = "http://" + ProtectedWorkflowHarness.Host + "/api" }); + _h.Refingerprint(release); + + await _h.RunAsync(); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.SchemeNotHttps); + } + + [Test] + public async Task a_templated_url_that_renders_to_a_different_host_blocks_the_send() + { + var release = _h.ActivateRelease(); + // The literal part is the pinned host; the rendered URL is not. + _h.Steps[0].ActionConfig = JsonConvert.SerializeObject(new { Url = "https://" + ProtectedWorkflowHarness.Host + "{{ '.attacker.example' }}/api" }); + _h.Refingerprint(release); + + await _h.RunAsync(); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.BlockedHost, ProtectedWorkflowErrorCodes.HostMismatch); + } + + [Test] + public async Task a_redirect_reported_by_the_executor_is_recorded_as_blocked_host() + { + _h.ActivateRelease(); + _h.Capturing.Respond = ctx => new WorkflowActionResult + { + Success = false, + ResultMessage = "HTTP 302 Found", + ErrorDetail = ProtectedWorkflowErrorCodes.Redirected, + HttpStatus = 302, + ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.BlockedHost + }; + + await _h.RunAsync(); + + var disclosure = _h.DisclosureRecords.Single(); + disclosure.Outcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.BlockedHost); + disclosure.HttpStatus.Should().Be(302); + } + + // ── Broker ────────────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task broker_purpose_denial_fails_the_step_without_sending() + { + _h.ActivateRelease(); + _h.Broker.FailWith = "workload_purpose_denied"; + + await _h.RunAsync(); + + AssertFailedBroker(); + } + + [Test] + public async Task a_kms_failure_fails_the_step_without_sending() + { + _h.ActivateRelease(); + _h.Broker.FailWith = "kms_unavailable"; + + await _h.RunAsync(); + + AssertFailedBroker(); + } + + [Test] + public async Task a_broker_outage_fails_the_step_without_sending() + { + _h.ActivateRelease(); + _h.Broker.Throw = true; + + await _h.RunAsync(); + + AssertFailedBroker(); + } + + [Test] + public async Task one_undecryptable_field_fails_the_whole_send_never_a_partial_payload() + { + _h.ActivateRelease(); + _h.Broker.FailFields.Add("calls.callformdata"); + + await _h.RunAsync(); + + AssertFailedBroker(); + } + + // ── Outbound guard ────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task an_envelope_in_the_rendered_output_is_caught_and_the_step_fails() + { + _h.ActivateRelease(); + // A value that is itself still ciphertext (double-enveloped, or a template quoting one). + _h.Broker.Plaintext[ProtectedWorkflowHarness.EnvelopeCompletedNotes] = "rgdp:1:2:U1RJTExFTkNSWVBURUQ="; + + await _h.RunAsync(); + + _h.Capturing.Calls.Should().BeEmpty(); + var disclosure = _h.DisclosureRecords.Single(); + disclosure.Outcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.BlockedGuard); + disclosure.Detail.Should().Be(ProtectedWorkflowErrorCodes.EnvelopeInPayload); + _h.Logs.Single().Status.Should().Be((int)WorkflowRunStatus.Failed); + } + + // ── Disclosure record ─────────────────────────────────────────────────────────────────────── + + [Test] + public async Task a_successful_send_writes_one_value_free_disclosure() + { + _h.ActivateRelease(); + + await _h.RunAsync(); + + var disclosure = _h.DisclosureRecords.Single(); + var sent = _h.Capturing.Calls.Single(); + disclosure.Outcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.Sent); + disclosure.EntityType.Should().Be("call"); + disclosure.EntityId.Should().Be(ProtectedWorkflowHarness.CallId.ToString()); + WorkflowProtectedRelease.ParseFieldIds(disclosure.FieldIds).Should().BeEquivalentTo(new[] { "calls.callformdata", "calls.completednotes" }); + disclosure.DestinationHost.Should().Be(ProtectedWorkflowHarness.Host); + disclosure.HttpStatus.Should().Be(204); + disclosure.PayloadBytes.Should().Be(System.Text.Encoding.UTF8.GetByteCount(sent.RenderedContent)); + disclosure.PayloadSha256.Should().Be(ProtectedWorkflowDisclosureChain.Sha256Hex(System.Text.Encoding.UTF8.GetBytes(sent.RenderedContent))); + disclosure.BrokerRequestId.Should().Be(_h.Broker.Calls.Single().RequestId); + disclosure.IsTest.Should().BeFalse(); + } + + [Test] + public async Task the_attempt_is_chained_before_the_request_leaves() + { + _h.ActivateRelease(); + var attemptsAtSend = -1; + _h.Capturing.Respond = ctx => + { + attemptsAtSend = _h.AttemptRecords.Count(); + return new WorkflowActionResult { Success = true, ResultMessage = "HTTP 204 No Content", HttpStatus = 204 }; + }; + + await _h.RunAsync(); + + attemptsAtSend.Should().Be(1, "the audit record exists before the request does"); + var attempt = _h.AttemptRecords.Single(); + var outcome = _h.DisclosureRecords.Single(); + attempt.ChainSequence.Should().BeLessThan(outcome.ChainSequence); + attempt.WorkflowRunId.Should().Be(outcome.WorkflowRunId); + attempt.PayloadSha256.Should().Be(ProtectedWorkflowDisclosureChain.Sha256Hex(System.Text.Encoding.UTF8.GetBytes(_h.Capturing.Calls.Single().RenderedContent))); + attempt.FieldIds.Should().Be(outcome.FieldIds); + attempt.HttpStatus.Should().BeNull(); + } + + [Test] + public async Task when_the_attempt_cannot_be_chained_nothing_is_sent() + { + _h.ActivateRelease(); + _h.FailDisclosureAppends = true; + + var run = await _h.RunAsync(); + + _h.Capturing.Calls.Should().BeEmpty("an unrecordable disclosure is never made"); + _h.Disclosures.Should().BeEmpty(); + _h.Logs.Single().ErrorMessage.Should().Be(ProtectedWorkflowErrorCodes.DisclosureUnavailable); + run.Status.Should().Be((int)WorkflowRunStatus.Retrying, "the chain being briefly unavailable is worth another attempt"); + } + + // ── Values come from the stored call, never from the queued event ─────────────────────────── + + [Test] + public async Task the_queued_event_is_redacted_and_the_released_values_come_from_the_stored_call() + { + _h.ActivateRelease(); + var payload = _h.ClosedPayload(); + payload.Should().Contain(ProtectedDataEnvelope.RedactionValue) + .And.NotContain(ProtectedWorkflowHarness.EnvelopeCompletedNotes, "the workflow queue only ever carries the safe projection"); + + await _h.RunAsync(payload); + + _h.Capturing.Calls.Single().RenderedContent.Should().Contain(ProtectedWorkflowHarness.SentinelCompletedNotes) + .And.Contain(ProtectedWorkflowHarness.SentinelFormOutcome).And.NotContain("\"closure\":\"REDACTED\""); + _h.Broker.Calls.Single().Items.Select(i => i.Value).Should().BeEquivalentTo( + new[] { ProtectedWorkflowHarness.EnvelopeCompletedNotes, ProtectedWorkflowHarness.EnvelopeForm }, + "the broker decrypts the stored envelopes, not the placeholders in the event"); + } + + [Test] + public async Task a_stored_value_that_is_only_a_placeholder_fails_closed() + { + _h.ActivateRelease(); + _h.Calls[ProtectedWorkflowHarness.CallId].CompletedNotes = ProtectedDataEnvelope.RedactionValue; + + await _h.RunAsync(); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.FailedBroker, ProtectedWorkflowErrorCodes.EntityUnavailable); + } + + [Test] + public async Task a_call_that_no_longer_exists_or_belongs_to_another_department_is_never_decrypted() + { + _h.ActivateRelease(); + var payload = _h.ClosedPayload(); + _h.Calls[ProtectedWorkflowHarness.CallId].DepartmentId = 99; + + await _h.RunAsync(payload); + + AssertBlocked(ProtectedWorkflowDisclosureOutcomes.FailedBroker, ProtectedWorkflowErrorCodes.EntityUnavailable); + + var gone = new ProtectedWorkflowHarness(); + gone.ActivateRelease(); + var goneRun = gone.ClosedPayload(); + gone.Calls.Clear(); + await gone.RunAsync(goneRun); + gone.Capturing.Calls.Should().BeEmpty(); + gone.Broker.Calls.Should().BeEmpty(); + } + + // ── Nothing persisted contains plaintext ──────────────────────────────────────────────────── + + [Test] + public async Task no_persisted_field_or_log_line_contains_a_decrypted_value_on_success() + { + _h.ActivateRelease("calls.completednotes", "calls.callformdata", "calls.notes"); + _h.Steps[0].OutputTemplate = _h.Steps[0].OutputTemplate.Replace("\"number\":", "\"notes\":\"{{ protected.call.notes }}\",\"number\":"); + _h.Refingerprint(_h.StoredRelease()); + + var captured = await CaptureConsoleAsync(() => _h.RunAsync()); + + _h.Capturing.Calls.Single().RenderedContent.Should().Contain(ProtectedWorkflowHarness.SentinelCompletedNotes, "the destination does receive the plaintext"); + AssertNoSentinel(_h.EverythingPersisted() + captured); + _h.Logs.Single().RenderedOutput.Should().StartWith("[protected payload] sha256="); + } + + [Test] + public async Task no_persisted_field_or_log_line_contains_a_decrypted_value_when_the_send_throws() + { + _h.ActivateRelease(); + // An exception whose message quotes the request content must not carry it into the run log or the logs. + _h.Capturing.Throw = new InvalidOperationException("could not post body: " + ProtectedWorkflowHarness.SentinelCompletedNotes); + + var captured = await CaptureConsoleAsync(() => _h.RunAsync()); + + _h.Logs.Single().Status.Should().Be((int)WorkflowRunStatus.Failed); + _h.Logs.Single().ErrorMessage.Should().StartWith(ProtectedWorkflowErrorCodes.StepError); + AssertNoSentinel(_h.EverythingPersisted() + captured); + } + + [Test] + public async Task no_persisted_field_contains_a_decrypted_value_when_the_endpoint_echoes_it_back() + { + _h.ActivateRelease(); + _h.Capturing.Respond = ctx => new WorkflowActionResult + { + Success = false, + ResultMessage = "HTTP 400 Bad Request", + ErrorDetail = "http_failed: HTTP 400", + HttpStatus = 400, + ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.FailedHttp + }; + + var captured = await CaptureConsoleAsync(() => _h.RunAsync()); + + _h.DisclosureRecords.Single().Outcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedHttp); + AssertNoSentinel(_h.EverythingPersisted() + captured); + } + + // ── Retries ───────────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task each_retry_re_decrypts_with_a_fresh_request_id_and_nothing_plaintext_is_kept_for_it() + { + _h.ActivateRelease(); + _h.Capturing.Respond = ctx => new WorkflowActionResult { Success = false, ResultMessage = "HTTP 503 Service Unavailable", HttpStatus = 503, ErrorDetail = "http_failed: HTTP 503", ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.FailedHttp }; + + var first = await _h.RunAsync(); + first.Status.Should().Be((int)WorkflowRunStatus.Retrying); + var second = await _h.RunAsync(first.InputPayload, attempt: 2, runId: first.WorkflowRunId); + + second.Status.Should().Be((int)WorkflowRunStatus.Retrying); + _h.Broker.Calls.Should().HaveCount(2); + _h.Broker.Calls.Select(c => c.RequestId).Distinct().Should().HaveCount(2, "a reused request id would be refused as a replay"); + _h.Capturing.Calls.Should().HaveCount(2); + _h.DisclosureRecords.Should().HaveCount(2, "one disclosure per send attempt"); + first.InputPayload.Should().NotContain(ProtectedWorkflowHarness.SentinelCompletedNotes, "the retry carries the event as it arrived: ids and envelopes, never plaintext"); + } + + [Test] + public async Task a_suspension_between_attempts_blocks_the_retry() + { + var release = _h.ActivateRelease(); + _h.Capturing.Respond = ctx => new WorkflowActionResult { Success = false, ResultMessage = "HTTP 503 Service Unavailable", HttpStatus = 503, ErrorDetail = "http_failed: HTTP 503", ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.FailedHttp }; + var first = await _h.RunAsync(); + + var suspended = await _h.Service.SuspendAsync(ProtectedWorkflowHarness.DepartmentId, release.WorkflowProtectedReleaseId, + new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminB }); + suspended.Success.Should().BeTrue(); + + var retry = await _h.RunAsync(first.InputPayload, attempt: 2, runId: first.WorkflowRunId); + + retry.Status.Should().Be((int)WorkflowRunStatus.Skipped); + retry.SkipReason.Should().Be("protected_release_suspended"); + _h.Capturing.Calls.Should().HaveCount(1, "only the first attempt ever sent"); + _h.Broker.Calls.Should().HaveCount(1); + } + + [Test] + public async Task a_final_failure_notifies_administrators_with_a_generic_message() + { + _h.Workflow.MaxRetryCount = 1; + _h.ActivateRelease(); + _h.Broker.FailWith = "kms_unavailable"; + + var run = await _h.RunAsync(); + + run.Status.Should().Be((int)WorkflowRunStatus.Failed); + _h.Notifications.Should().HaveCount(2); + _h.Notifications.Should().OnlyContain(n => n.Contains(run.WorkflowRunId) && n.Contains(ProtectedWorkflowErrorCodes.BrokerFailed)); + AssertNoSentinel(string.Join("\n", _h.Notifications)); + } + + // ── Test send ─────────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task a_test_send_uses_synthetic_values_never_decrypts_and_is_labelled_test() + { + _h.ActivateRelease(); + + var result = await _h.WorkflowService.SendProtectedTestAsync(ProtectedWorkflowHarness.DepartmentId, ProtectedWorkflowHarness.DepartmentCode, _h.Workflow.WorkflowId); + + result.Success.Should().BeTrue(); + _h.Broker.Calls.Should().BeEmpty(); + _h.Capturing.Calls.Single().RenderedContent.Should().Contain("synthetic test data").And.NotContain(ProtectedWorkflowHarness.SentinelCompletedNotes); + _h.Capturing.Calls.Single().ProtectedMode.Should().BeTrue(); + _h.DisclosureRecords.Single().IsTest.Should().BeTrue(); + } + + // ── Helpers ───────────────────────────────────────────────────────────────────────────────── + + private void AssertBlocked(string outcome, string code) + { + _h.Capturing.Calls.Should().BeEmpty("a failed precondition never reaches the executor"); + _h.Broker.Calls.Should().BeEmpty("a failed precondition never decrypts"); + var disclosure = _h.DisclosureRecords.Single(); + disclosure.Outcome.Should().Be(outcome); + disclosure.Detail.Should().Be(code); + disclosure.PayloadSha256.Should().BeNull(); + _h.Logs.Single().Status.Should().Be((int)WorkflowRunStatus.Failed); + _h.Logs.Single().ErrorMessage.Should().Be(code); + } + + private void AssertFailedBroker() + { + _h.Capturing.Calls.Should().BeEmpty("a broker failure never sends, not even a redacted or partial payload"); + var disclosure = _h.DisclosureRecords.Single(); + disclosure.Outcome.Should().Be(ProtectedWorkflowDisclosureOutcomes.FailedBroker); + disclosure.Detail.Should().StartWith(ProtectedWorkflowErrorCodes.BrokerFailed); + disclosure.BrokerRequestId.Should().NotBeNullOrEmpty(); + _h.Logs.Single().Status.Should().Be((int)WorkflowRunStatus.Failed); + } + + private static void AssertNoSentinel(string text) + { + text.Should().NotContain(ProtectedWorkflowHarness.SentinelCompletedNotes); + text.Should().NotContain(ProtectedWorkflowHarness.SentinelFormOutcome); + text.Should().NotContain(ProtectedWorkflowHarness.SentinelNotes); + } + + private static async Task CaptureConsoleAsync(Func action) + { + var originalOut = Console.Out; + var originalError = Console.Error; + using var writer = new StringWriter(); + Console.SetOut(writer); + Console.SetError(writer); + try + { + await action(); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } + + return writer.ToString(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowJwtKeysTests.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowJwtKeysTests.cs new file mode 100644 index 000000000..ffb568f9e --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowJwtKeysTests.cs @@ -0,0 +1,233 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services.ProtectedWorkflows +{ + /// + /// private_key_jwt credentials: generated keys, the published JWKS and its overlap window, rotation (a new kid, a + /// credential_rotated event, no re-approval), and the private key never leaving the encrypted credential. Also the + /// write side of subject identifiers: a protected department's API write envelopes them through the workload lane. + /// + [TestFixture] + public class WorkflowJwtKeysTests + { + [Test] + public void the_jwks_publishes_rotated_keys_only_for_the_overlap_window() + { + var now = new DateTime(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + var (_, old) = WorkflowJwtKeys.Generate(WorkflowJwtKeys.Rs384, now.AddDays(-30)); + var (_, current) = WorkflowJwtKeys.Generate(WorkflowJwtKeys.Es384, now); + old.RetiredOn = now; + var column = WorkflowJwtKeys.WritePublicKeys(new[] { old, current }); + + KeyIds(WorkflowJwtKeys.BuildJwks(column, now.AddDays(6), 7)).Should().BeEquivalentTo(new[] { old.Kid, current.Kid }, "both during the overlap"); + KeyIds(WorkflowJwtKeys.BuildJwks(column, now.AddDays(7).AddMinutes(1), 7)).Should().Equal(current.Kid); + + var jwks = JObject.Parse(WorkflowJwtKeys.BuildJwks(column, now, 7)); + foreach (var key in jwks["keys"]) + { + key.Value("use").Should().Be("sig"); + ((JObject)key).Properties().Select(p => p.Name).Should().NotContain(new[] { "d", "p", "q", "dp", "dq", "qi", "privateKey" }); + } + } + + private static string[] KeyIds(string jwks) => JObject.Parse(jwks)["keys"].Select(k => k.Value("kid")).ToArray(); + + [Test] + public void the_kid_is_the_rfc7638_thumbprint_and_the_public_half_is_reproducible() + { + var (signing, published) = WorkflowJwtKeys.Generate(WorkflowJwtKeys.Rs384, DateTime.UtcNow); + + WorkflowJwtKeys.PublicFor(signing).Jwk.ToString(Formatting.None).Should().Be(published.Jwk.ToString(Formatting.None)); + signing.Kid.Should().Be(published.Kid).And.MatchRegex("^[A-Za-z0-9_-]{43}$"); + } + + [Test] + public void an_assertion_never_lives_longer_than_five_minutes() + { + var (signing, _) = WorkflowJwtKeys.Generate(WorkflowJwtKeys.Es384, DateTime.UtcNow); + + var assertion = WorkflowJwtKeys.CreateAssertion(signing, "client", "https://ehr.example.org/token", DateTime.UtcNow, TimeSpan.FromHours(1)); + var claims = JObject.Parse(System.Text.Encoding.UTF8.GetString(WorkflowJwtKeys.FromBase64Url(assertion.Split('.')[1]))); + + (claims.Value("exp") - claims.Value("iat")).Should().Be(300); + WorkflowJwtKeys.CreateAssertion(signing, "client", "https://x", DateTime.UtcNow).Split('.')[1] + .Should().NotBe(assertion.Split('.')[1], "every assertion carries a fresh jti"); + } + + // ── Through WorkflowService ───────────────────────────────────────────────────────────────── + + private static WorkflowCredential JwtCredential(string alg = WorkflowJwtKeys.Rs384) => new WorkflowCredential + { + WorkflowCredentialId = ProtectedWorkflowHarness.CredentialId, + DepartmentId = ProtectedWorkflowHarness.DepartmentId, + Name = "EHR SMART backend", + CredentialType = (int)WorkflowCredentialType.OAuth2ClientCredentials, + EncryptedData = JsonConvert.SerializeObject(new + { + tokenUrl = "https://org.crm.dynamics.com/oauth2/token", + clientId = "resgrid-client", + clientSecret = "should-be-dropped", + authMethod = WorkflowJwtKeys.PrivateKeyJwt, + signingAlg = alg + }), + UpdatedByUserId = ProtectedWorkflowHarness.AdminA + }; + + [Test] + public async Task saving_a_private_key_jwt_credential_generates_a_key_and_publishes_only_its_public_half() + { + var h = new ProtectedWorkflowHarness(); + + await h.WorkflowService.SaveCredentialAsync(JwtCredential(), ProtectedWorkflowHarness.DepartmentCode); + + var stored = h.Credentials.Single(c => c.WorkflowCredentialId == ProtectedWorkflowHarness.CredentialId); + var secret = JObject.Parse(stored.EncryptedData.Substring(4)); + var keys = secret["signingKeys"].ToObject(); + keys.Should().ContainSingle().Which.PrivateKey.Should().NotBeNullOrWhiteSpace(); + secret.Value("clientSecret").Should().BeNull("a private_key_jwt credential keeps no secret"); + + var published = WorkflowJwtKeys.ReadPublicKeys(stored.PublicJwks); + published.Should().ContainSingle().Which.Kid.Should().Be(keys[0].Kid); + stored.PublicJwks.Should().NotContain(keys[0].PrivateKey); + stored.PublicJwks.Should().NotContain("\"d\""); + } + + [Test] + public async Task editing_a_private_key_jwt_credential_keeps_its_key() + { + var h = new ProtectedWorkflowHarness(); + await h.WorkflowService.SaveCredentialAsync(JwtCredential(), ProtectedWorkflowHarness.DepartmentCode); + var kid = WorkflowJwtKeys.ReadPublicKeys(h.Credentials.Single().PublicJwks).Single().Kid; + + var edit = JwtCredential(); + edit.Name = "Renamed"; + await h.WorkflowService.SaveCredentialAsync(edit, ProtectedWorkflowHarness.DepartmentCode); + + WorkflowJwtKeys.ReadPublicKeys(h.Credentials.Single().PublicJwks).Single().Kid.Should().Be(kid, "the editor never posts keys, and none are lost"); + h.AdminEvents.Should().BeEmpty("nothing about the authentication changed"); + } + + [Test] + public async Task rotation_adds_a_new_key_keeps_the_old_one_published_records_the_event_and_keeps_the_release_active() + { + var h = new ProtectedWorkflowHarness(); + await h.WorkflowService.SaveCredentialAsync(JwtCredential(), ProtectedWorkflowHarness.DepartmentCode); + var release = h.ActivateRelease(); + h.StoredRelease().TokenHost = "org.crm.dynamics.com"; + h.StoredRelease().AuthMethod = WorkflowJwtKeys.PrivateKeyJwt; + var firstKid = WorkflowJwtKeys.ReadPublicKeys(h.Credentials.Single().PublicJwks).Single().Kid; + + var rotated = await h.WorkflowService.RotateCredentialSigningKeyAsync(ProtectedWorkflowHarness.CredentialId, ProtectedWorkflowHarness.DepartmentId, + ProtectedWorkflowHarness.DepartmentCode, ProtectedWorkflowHarness.AdminB); + + rotated.Should().NotBeNull(); + var published = WorkflowJwtKeys.ReadPublicKeys(h.Credentials.Single().PublicJwks); + published.Should().HaveCount(2); + var newKid = published.Single(k => !k.RetiredOn.HasValue).Kid; + newKid.Should().NotBe(firstKid); + published.Single(k => k.Kid == firstKid).RetiredOn.Should().NotBeNull(); + KeyIds(WorkflowJwtKeys.BuildJwks(h.Credentials.Single().PublicJwks, DateTime.UtcNow, DataProtectionConfig.WorkflowJwksOverlapDays)) + .Should().BeEquivalentTo(new[] { firstKid, newKid }); + + var signing = JObject.Parse(h.Credentials.Single().EncryptedData.Substring(4))["signingKeys"].ToObject(); + WorkflowJwtKeys.Current(signing).Kid.Should().Be(newKid, "the new key signs from now on"); + + h.AdminEvents.Should().ContainSingle(e => e.EventType == ProtectedWorkflowAdminEventTypes.CredentialRotated && + e.WorkflowProtectedReleaseId == release.WorkflowProtectedReleaseId && e.Detail.Contains("kid=" + newKid) && e.ActorUserId == ProtectedWorkflowHarness.AdminB); + h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Active, "the credential id is pinned, the key is not"); + } + + [Test] + public async Task switching_the_client_authentication_suspends_a_pinned_release() + { + var h = new ProtectedWorkflowHarness(); + await h.WorkflowService.SaveCredentialAsync(new WorkflowCredential + { + WorkflowCredentialId = ProtectedWorkflowHarness.CredentialId, + DepartmentId = ProtectedWorkflowHarness.DepartmentId, + Name = "EHR", + CredentialType = (int)WorkflowCredentialType.OAuth2ClientCredentials, + EncryptedData = JsonConvert.SerializeObject(new { tokenUrl = "https://org.crm.dynamics.com/oauth2/token", clientId = "c", clientSecret = "s" }) + }, ProtectedWorkflowHarness.DepartmentCode); + h.ActivateRelease(); + + await h.WorkflowService.SaveCredentialAsync(JwtCredential(), ProtectedWorkflowHarness.DepartmentCode); + + h.StoredRelease().ReleaseState.Should().Be(ProtectedReleaseState.Suspended); + h.StoredRelease().SuspendedReason.Should().Be(ProtectedWorkflowSuspendReasons.CredentialChanged); + } + + [Test] + public async Task rotation_is_refused_for_anything_but_a_private_key_jwt_credential_of_the_department() + { + var h = new ProtectedWorkflowHarness(); + + (await h.WorkflowService.RotateCredentialSigningKeyAsync(ProtectedWorkflowHarness.CredentialId, ProtectedWorkflowHarness.DepartmentId, + ProtectedWorkflowHarness.DepartmentCode, ProtectedWorkflowHarness.AdminA)).Should().BeNull("the harness credential is a bearer token"); + + await h.WorkflowService.SaveCredentialAsync(JwtCredential(), ProtectedWorkflowHarness.DepartmentCode); + (await h.WorkflowService.RotateCredentialSigningKeyAsync(ProtectedWorkflowHarness.CredentialId, 999, + ProtectedWorkflowHarness.DepartmentCode, ProtectedWorkflowHarness.AdminA)).Should().BeNull("another department"); + } + + [Test] + public async Task a_release_pins_the_client_authentication_of_its_credential() + { + var h = new ProtectedWorkflowHarness(); + await h.WorkflowService.SaveCredentialAsync(JwtCredential(), ProtectedWorkflowHarness.DepartmentCode); + h.Credentials.Single().CredentialType.Should().Be((int)WorkflowCredentialType.OAuth2ClientCredentials); + + await h.Service.SaveDraftAsync(ProtectedWorkflowHarness.DepartmentId, h.Workflow.WorkflowId, new ProtectedReleaseDraft + { + FieldIds = new[] { "calls.completednotes" }, + RecipientType = (int)ProtectedReleaseRecipientType.CoveredEntity, + RecipientName = "EHR", + Purpose = "Encounter documentation" + }, new ProtectedWorkflowActor { UserId = ProtectedWorkflowHarness.AdminA }); + var result = await h.Service.RequestApprovalAsync(ProtectedWorkflowHarness.DepartmentId, h.Workflow.WorkflowId, true, + ProtectedWorkflowDefaults.WarningTextVersion, h.StepsFingerprint("org.crm.dynamics.com", WorkflowJwtKeys.PrivateKeyJwt), + ProtectedWorkflowHarness.SteppedUp(ProtectedWorkflowHarness.AdminA)); + + result.Success.Should().BeTrue(result.ErrorCode); + h.StoredRelease().AuthMethod.Should().Be(WorkflowJwtKeys.PrivateKeyJwt); + h.StoredRelease().TokenHost.Should().Be("org.crm.dynamics.com"); + } + + // ── Subject identifiers on the write path ─────────────────────────────────────────────────── + + [TestCase(29, true)] + [TestCase(28, false)] + public async Task an_api_write_envelopes_subject_identifiers_for_a_department_on_catalog_29(int catalogVersion, bool encrypted) + { + var broker = new FakeBroker(); + var dataProtection = new Mock(); + dataProtection.Setup(d => d.ShouldEncryptNewWritesAsync(It.IsAny())).ReturnsAsync(true); + dataProtection.Setup(d => d.GetPolicyByDepartmentIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = 42, State = (int)DepartmentDataProtectionState.Enabled, CatalogVersion = catalogVersion }); + var writes = new ProtectedReadService(dataProtection.Object, Mock.Of(), broker, new ProtectedFieldCatalog()); + var call = new Call { CallId = 7, DepartmentId = 42, SubjectIdentifiers = "{\"ehr_client_id\":\"123456\"}" }; + + var result = await writes.PrepareCallWriteAsync(42, call, null, null, "system_integration", workloadCaller: true); + + result.Success.Should().BeTrue(); + ProtectedDataEnvelope.HasEnvelopePrefix(call.SubjectIdentifiers).Should().Be(encrypted, + "a department pinned below 29 keeps the column plaintext until the catalog upgrade reaches it"); + if (encrypted) + { + broker.Encrypts.Single().Items.Single().FieldId.Should().Be("calls.subjectidentifiers"); + broker.Plaintext[call.SubjectIdentifiers].Should().Be("{\"ehr_client_id\":\"123456\"}"); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowTemplateFunctionsTests.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowTemplateFunctionsTests.cs new file mode 100644 index 000000000..ea3e95621 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowTemplateFunctionsTests.cs @@ -0,0 +1,201 @@ +using System; +using System.Linq; +using System.Xml; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; +using Scriban; +using Scriban.Runtime; + +namespace Resgrid.Tests.Services.ProtectedWorkflows +{ + /// + /// The escaping and date helpers every workflow template can use, and the pre-send payload validation of protected + /// steps. Adversarial input must round-trip through the structure it is placed in and never break out of it. + /// + [TestFixture] + public class WorkflowTemplateFunctionsTests + { + private const string Adversarial = "He said \"stop\" ("note").Should().Be(Adversarial); + rendered.Should().NotContain("", "HTML-significant characters are escaped too"); + } + + [Test] + public void xml_escape_round_trips_text_and_attribute_content() + { + var text = Adversarial.Replace("\U0001F680", "rocket"); // keep the check to BMP + a valid pair below + var rendered = Render("{{ v | xml_escape }}", text); + + var document = new XmlDocument { XmlResolver = null }; + document.LoadXml(rendered); + document.DocumentElement.InnerText.Should().Be(text); + document.DocumentElement.GetAttribute("a").Should().Be(text); + } + + [Test] + public void xml_escape_drops_characters_xml_cannot_carry_and_keeps_surrogate_pairs() + { + WorkflowTemplateFunctions.XmlEscape("a\u0001b\u0000c\uFFFEd\U0001F680").Should().Be("abcd\U0001F680"); + WorkflowTemplateFunctions.XmlEscape("x\uD800y").Should().Be("xy", "an unpaired surrogate is not XML"); + } + + [Test] + public void hl7_escape_neutralizes_every_delimiter_and_line_break() + { + var escaped = WorkflowTemplateFunctions.Hl7Escape("a|b^c~d\\e&f\r\ng\nh\ri"); + + escaped.Should().Be("a\\F\\b\\S\\c\\R\\d\\E\\e\\T\\f\\X0D\\\\X0A\\g\\X0A\\h\\X0D\\i"); + escaped.IndexOfAny(new[] { '|', '^', '~', '&', '\r', '\n' }).Should().Be(-1); + } + + [Test] + public void hl7_escape_output_can_never_start_a_new_segment() + { + var rendered = "MSH|^~\\&|A|B|C|D|20260101000000+0000||ORU^R01|1|P|2.5.1\rOBX|1|TX|NOTE||" + + Render("{{ v | hl7_escape }}", Adversarial) + "||||||F"; + + ProtectedPayloadValidator.Validate(ProtectedPayloadValidator.Hl7MediaType, rendered).Ok.Should().BeTrue(); + rendered.Split('\r').Should().HaveCount(2, "the injected MSH line stays inside OBX-5"); + } + + [Test] + public void dates_format_in_utc_for_fhir_and_hl7() + { + var instant = new DateTime(2026, 9, 24, 14, 5, 9, DateTimeKind.Utc); + + WorkflowTemplateFunctions.FhirDateTime(instant).Should().Be("2026-09-24T14:05:09Z"); + WorkflowTemplateFunctions.Hl7Timestamp(instant).Should().Be("20260924140509+0000"); + WorkflowTemplateFunctions.FhirDateTime(new DateTimeOffset(2026, 9, 24, 10, 5, 9, TimeSpan.FromHours(-4))).Should().Be("2026-09-24T14:05:09Z"); + WorkflowTemplateFunctions.FhirDateTime("2026-09-24T10:05:09-04:00").Should().Be("2026-09-24T14:05:09Z"); + WorkflowTemplateFunctions.FhirDateTime(DateTime.SpecifyKind(instant, DateTimeKind.Unspecified)).Should().Be("2026-09-24T14:05:09Z", + "the platform stores UTC"); + WorkflowTemplateFunctions.FhirDateTime(null).Should().BeEmpty(); + WorkflowTemplateFunctions.Hl7Timestamp("not a date").Should().BeEmpty(); + } + + [Test] + public void the_helpers_are_available_in_every_workflow_context() + { + foreach (WorkflowTriggerEventType trigger in Enum.GetValues(typeof(WorkflowTriggerEventType))) + { + var sample = (ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData(trigger); + foreach (var helper in new[] { "json_escape", "xml_escape", "hl7_escape", "fhir_datetime", "hl7_ts" }) + sample.ContainsKey(helper).Should().BeTrue($"{helper} on {trigger}"); + } + + ProtectedWorkflowValidator.EscapeHelpers.Should().BeEquivalentTo(WorkflowTemplateFunctions.EscapeHelperNames); + } + + [TestCase("{{ protected.call.notes }}", true)] + [TestCase("{{ protected.call.notes | json_escape }}", false)] + [TestCase("{{ protected.call.subject_ids.ehr_client_id | hl7_escape }}", false)] + [TestCase("{{ xml_escape protected.call.notes }}", false)] + [TestCase("{{ call.notes }} protected.call.notes", false)] + [TestCase("{{ for f in protected.call.udf }}{{ f.value | json_escape }}{{ end }}", false)] + [TestCase("{{ for f in protected.call.udf }}{{ f.value }}{{ end }}", false)] + [TestCase("{{ for f in protected.call.udf }}{{ protected.call.notes }}{{ end }}", true)] + public void a_protected_value_without_an_escape_helper_is_flagged(string template, bool flagged) + { + ProtectedWorkflowValidator.HasUnescapedProtectedReference(template).Should().Be(flagged); + } + + // ── Pre-send validation ───────────────────────────────────────────────────────────────────── + + [TestCase("application/json", "{\"a\":1}", true, null)] + [TestCase("application/json", "{\"a\":1", false, ProtectedPayloadValidator.RuleJsonParse)] + [TestCase("application/json", "{\"a\":1} {\"b\":2}", false, ProtectedPayloadValidator.RuleJsonParse)] + [TestCase("application/fhir+json", "{\"resourceType\":\"Bundle\"}", true, null)] + [TestCase("application/fhir+json", "{\"type\":\"transaction\"}", false, ProtectedPayloadValidator.RuleFhirResourceType)] + [TestCase("application/fhir+json", "[]", false, ProtectedPayloadValidator.RuleFhirResourceType)] + [TestCase("application/xml", "", true, null)] + [TestCase("text/xml", "", false, ProtectedPayloadValidator.RuleXmlWellFormed)] + [TestCase("application/soap+xml", "]>&x;", false, ProtectedPayloadValidator.RuleXmlWellFormed)] + [TestCase("x-application/hl7-v2+er7", "MSH|^~\\&|A\rPID|1", true, null)] + [TestCase("x-application/hl7-v2+er7", "PID|1\rMSH|^~\\&|A", false, ProtectedPayloadValidator.RuleHl7Header)] + [TestCase("x-application/hl7-v2+er7", "MSH|^~\\&|A\rpid|1", false, ProtectedPayloadValidator.RuleHl7Segment)] + [TestCase("x-application/hl7-v2+er7", "MSH|^~\\&|A\r\rPID|1", false, ProtectedPayloadValidator.RuleHl7Segment)] + [TestCase("text/plain", "anything at all", true, null)] + public void payloads_are_validated_for_their_content_type(string mediaType, string body, bool ok, string rule) + { + var check = ProtectedPayloadValidator.Validate(mediaType, body); + + check.Ok.Should().Be(ok); + check.Rule.Should().Be(rule); + if (!ok) + check.Describe().Should().StartWith("payload_invalid: rule=" + rule); + } + + [Test] + public void validation_detail_names_the_rule_and_position_never_the_content() + { + var check = ProtectedPayloadValidator.Validate("application/json", "{\"note\":\"SECRET-VALUE\" x}"); + + check.Describe().Should().MatchRegex(@"^payload_invalid: rule=json_parse line=\d+ position=\d+$"); + check.Describe().Should().NotContain("SECRET"); + } + + [Test] + public void hl7_line_breaks_between_segments_become_carriage_returns() + { + ProtectedPayloadValidator.NormalizeBody(ProtectedPayloadValidator.Hl7MediaType, "MSH|^~\\&|A\r\nPID|1\nPV1|1\n\n") + .Should().Be("MSH|^~\\&|A\rPID|1\rPV1|1"); + ProtectedPayloadValidator.NormalizeBody("application/json", "{\n}").Should().Be("{\n}"); + } + + [TestCase("application/json", true)] + [TestCase("application/fhir+json; charset=utf-8", true)] + [TestCase("x-application/hl7-v2+er7", true)] + [TestCase("text/html", false)] + [TestCase("application/x-www-form-urlencoded", false)] + [TestCase("application/json{{ call.notes }}", false)] + public void only_the_allowed_content_types_are_accepted(string contentType, bool allowed) + { + ProtectedStepOptions.IsAllowedContentType(contentType).Should().Be(allowed); + } + + [Test] + public void step_options_are_read_and_validated_from_the_action_config() + { + var options = ProtectedStepOptions.Read( + "{\"url\":\"https://x.example\",\"contentType\":\"application/fhir+json\",\"successRule\":{\"type\":\"json_path\",\"path\":\"$.status\",\"expected\":\"ok\"}," + + "\"responseCapture\":[{\"source\":\"fhir_location_id\",\"expression\":\"Encounter\",\"key\":\"ehr_encounter_id\"}]," + + "\"idempotencyHeader\":\"Idempotency-Key\",\"ifNoneExist\":\"identifier=https://resgrid.com/call|{{ call.id }}\"}", out var errors); + + errors.Should().BeEmpty(); + options.MediaType.Should().Be("application/fhir+json"); + options.SuccessRule.Type.Should().Be("json_path"); + options.ResponseCapture.Single().Key.Should().Be("ehr_encounter_id"); + options.IdempotencyHeader.Should().Be("Idempotency-Key"); + options.NeedsResponseBody.Should().BeTrue(); + + ProtectedStepOptions.Read("{\"contentType\":\"text/html\"}", out errors); + errors.Should().Contain(ProtectedStepOptions.ContentTypeNotAllowed); + ProtectedStepOptions.Read("{\"successRule\":{\"type\":\"json_path\"}}", out errors); + errors.Should().Contain(ProtectedStepOptions.SuccessRuleInvalid); + ProtectedStepOptions.Read("{\"responseCapture\":[{\"source\":\"header\",\"expression\":\"Location\",\"key\":\"Bad-Key\"}]}", out errors); + errors.Should().Contain(ProtectedStepOptions.CaptureInvalid); + ProtectedStepOptions.Read("{\"responseCapture\":[" + string.Join(",", Enumerable.Range(1, 6).Select(i => $"{{\"source\":\"header\",\"expression\":\"X-{i}\",\"key\":\"k{i}\"}}")) + "]}", out errors); + errors.Should().Contain(ProtectedStepOptions.CaptureTooMany); + ProtectedStepOptions.Read("{\"idempotencyHeader\":\"Authorization\"}", out errors); + errors.Should().Contain(ProtectedStepOptions.IdempotencyHeaderInvalid); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ShiftManagementScopeAuthorizationTests.cs b/Tests/Resgrid.Tests/Services/ShiftManagementScopeAuthorizationTests.cs new file mode 100644 index 000000000..bf79fcb9a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ShiftManagementScopeAuthorizationTests.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Who supervises which shift groups: the department sees everything, a contracted provider's group admin only + /// their own teams (and the teams beneath them). + /// + [TestFixture] + public class ShiftManagementScopeAuthorizationTests + { + private const int DepartmentId = 4; + private const int ProviderGroup = 10; + private const int ProviderTeam = 11; + private const int OtherGroup = 20; + private const int SupervisorRole = 7; + + private Mock _departmentsService; + private Mock _departmentGroupsService; + private Mock _personnelRolesService; + private Mock _permissionsService; + private Department _department; + private AuthorizationService _service; + + [SetUp] + public void SetUp() + { + _departmentsService = new Mock(); + _departmentGroupsService = new Mock(); + _personnelRolesService = new Mock(); + _permissionsService = new Mock(); + + _department = new Department { DepartmentId = DepartmentId, ManagingUserId = "owner", AdminUsers = new List { "dmh-admin" } }; + + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, It.IsAny())).ReturnsAsync(_department); + _departmentsService.Setup(x => x.GetDepartmentMemberAsync(It.IsAny(), DepartmentId, It.IsAny())) + .ReturnsAsync((string userId, int _, bool __) => userId == "stranger" ? null : new DepartmentMember { UserId = userId, DepartmentId = DepartmentId }); + _departmentGroupsService.Setup(x => x.GetAllGroupsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List + { + new DepartmentGroup + { + DepartmentGroupId = ProviderGroup, + Members = new List { new DepartmentGroupMember { UserId = "provider-lead", IsAdmin = true } } + }, + new DepartmentGroup { DepartmentGroupId = ProviderTeam, ParentDepartmentGroupId = ProviderGroup, Members = new List() }, + new DepartmentGroup + { + DepartmentGroupId = OtherGroup, + Members = new List { new DepartmentGroupMember { UserId = "provider-lead", IsAdmin = false } } + } + }); + _personnelRolesService.Setup(x => x.GetRolesForUserAsync(It.IsAny(), DepartmentId)).ReturnsAsync(new List()); + + _service = new AuthorizationService(_departmentsService.Object, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, + _departmentGroupsService.Object, _personnelRolesService.Object, new Mock().Object, _permissionsService.Object, + new Mock().Object, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object); + } + + [Test] + public async Task Department_admin_supervises_every_group() + { + var scope = await _service.GetShiftManagementScopeAsync("dmh-admin", DepartmentId); + + scope.AllGroups.Should().BeTrue(); + } + + [Test] + public async Task Group_admin_supervises_their_group_and_its_child_teams_only() + { + var scope = await _service.GetShiftManagementScopeAsync("provider-lead", DepartmentId); + + scope.AllGroups.Should().BeFalse(); + scope.GroupIds.Should().BeEquivalentTo(new[] { ProviderGroup, ProviderTeam }); + scope.CanManageGroup(OtherGroup).Should().BeFalse(); + } + + [Test] + public async Task Select_role_shift_managers_supervise_every_group() + { + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CreateShift)) + .ReturnsAsync(new Permission { Action = (int)PermissionActions.DepartmentAdminsAndSelectRoles, Data = $"3, {SupervisorRole}" }); + _personnelRolesService.Setup(x => x.GetRolesForUserAsync("scheduler", DepartmentId)) + .ReturnsAsync(new List { new PersonnelRole { PersonnelRoleId = SupervisorRole } }); + + (await _service.GetShiftManagementScopeAsync("scheduler", DepartmentId)).AllGroups.Should().BeTrue(); + (await _service.GetShiftManagementScopeAsync("responder", DepartmentId)).IsSupervisor.Should().BeFalse(); + } + + [Test] + public async Task Everyone_permission_makes_everyone_a_shift_manager() + { + _permissionsService.Setup(x => x.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.CreateShift)) + .ReturnsAsync(new Permission { Action = (int)PermissionActions.Everyone }); + + (await _service.GetShiftManagementScopeAsync("responder", DepartmentId)).AllGroups.Should().BeTrue(); + } + + [Test] + public async Task Non_members_get_no_scope() + { + var scope = await _service.GetShiftManagementScopeAsync("stranger", DepartmentId); + + scope.IsSupervisor.Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ShiftRosterBuilderTests.cs b/Tests/Resgrid.Tests/Services/ShiftRosterBuilderTests.cs new file mode 100644 index 000000000..ea490001a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ShiftRosterBuilderTests.cs @@ -0,0 +1,389 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ShiftRosterBuilderTests + { + private static readonly DateTime Day1 = new DateTime(2026, 10, 5); + private static readonly DateTime Day2 = new DateTime(2026, 10, 6); + + private const int ShiftId = 10; + private const int CrisisTeam = 100; + private const int PeerTeam = 200; + private const int Clinician = 1; + private const int PeerSpecialist = 2; + + private static Shift MakeShift(params ShiftPerson[] personnel) + { + return new Shift + { + ShiftId = ShiftId, + Personnel = personnel.ToList(), + Groups = new List + { + new ShiftGroup + { + DepartmentGroupId = CrisisTeam, + Roles = new List + { + new ShiftGroupRole { PersonnelRoleId = Clinician, Required = 1 }, + new ShiftGroupRole { PersonnelRoleId = PeerSpecialist, Required = 1 } + } + }, + new ShiftGroup { DepartmentGroupId = PeerTeam, Roles = new List() } + } + }; + } + + private static ShiftSignup Signup(int id, string userId, DateTime day, int? groupId = CrisisTeam) + { + return new ShiftSignup { ShiftSignupId = id, ShiftId = ShiftId, UserId = userId, ShiftDay = day, DepartmentGroupId = groupId }; + } + + private static Dictionary> Roles(params (string UserId, int[] RoleIds)[] users) + { + return users.ToDictionary(x => x.UserId, x => x.RoleIds.Select(r => new PersonnelRole { PersonnelRoleId = r }).ToList()); + } + + [Test] + public void Build_puts_standing_roster_on_every_day() + { + var shift = MakeShift(new ShiftPerson { UserId = "alice", GroupId = CrisisTeam }); + + var day1 = ShiftRosterBuilder.Build(shift, Day1, null, null); + var day2 = ShiftRosterBuilder.Build(shift, Day2, null, null); + + day1.Should().ContainSingle(x => x.UserId == "alice" && x.Source == ShiftRosterSources.Assigned && x.DepartmentGroupId == CrisisTeam); + day2.Should().ContainSingle(x => x.UserId == "alice"); + } + + [Test] + public void Build_lists_pending_signups_but_leaves_out_denied_ones() + { + var shift = MakeShift(); + var pending = Signup(1, "bob", Day1); + pending.ApprovalPending = true; + var denied = Signup(2, "carol", Day1); + denied.Denied = true; + + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { pending, denied, Signup(3, "dave", Day1) }, null); + + roster.Should().ContainSingle(x => x.UserId == "bob" && x.ApprovalPending && !x.IsOnDuty()); + roster.Should().ContainSingle(x => x.UserId == "dave" && x.Source == ShiftRosterSources.Signup && x.IsOnDuty()); + roster.Should().NotContain(x => x.UserId == "carol"); + } + + [Test] + public void Build_ignores_signups_for_other_days_and_shifts() + { + var shift = MakeShift(); + var otherShift = Signup(1, "bob", Day1); + otherShift.ShiftId = 99; + + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { otherShift, Signup(2, "carol", Day2) }, null); + + roster.Should().BeEmpty(); + } + + [Test] + public void Build_marks_supervisor_added_people() + { + var shift = MakeShift(); + var added = Signup(1, "erin", Day1, PeerTeam); + added.AssignedByUserId = "supervisor"; + + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { added }, null); + + roster.Should().ContainSingle(x => x.UserId == "erin" && x.Source == ShiftRosterSources.SupervisorAssigned && x.DepartmentGroupId == PeerTeam); + } + + [Test] + public void Build_takes_a_standing_roster_person_off_only_the_day_they_were_removed() + { + var shift = MakeShift(new ShiftPerson { UserId = "alice", GroupId = CrisisTeam }); + var removed = Signup(1, "alice", Day1); + removed.Denied = true; + + ShiftRosterBuilder.Build(shift, Day1, new[] { removed }, null).Should().BeEmpty(); + ShiftRosterBuilder.Build(shift, Day2, new[] { removed }, null).Should().ContainSingle(x => x.UserId == "alice"); + } + + [Test] + public void Build_shows_a_standing_roster_persons_own_day_slot_once_as_assigned() + { + var shift = MakeShift(new ShiftPerson { UserId = "alice", GroupId = CrisisTeam }); + + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { Signup(5, "alice", Day1) }, null); + + roster.Should().ContainSingle(); + roster[0].Source.Should().Be(ShiftRosterSources.Assigned); + roster[0].ShiftSignupId.Should().Be(5); + } + + [Test] + public void Build_gives_the_slot_to_the_taker_of_a_completed_give_away_trade() + { + var shift = MakeShift(new ShiftPerson { UserId = "alice", GroupId = CrisisTeam }); + var slot = Signup(5, "alice", Day1); + var trade = new ShiftSignupTrade { ShiftSignupTradeId = 50, SourceShiftSignupId = 5, SourceShiftSignup = slot, UserId = "bob" }; + + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { slot }, new[] { trade }); + + roster.Should().ContainSingle(); + roster[0].UserId.Should().Be("bob"); + roster[0].Source.Should().Be(ShiftRosterSources.Trade); + roster[0].TradedFromUserId.Should().Be("alice"); + roster[0].ShiftSignupTradeId.Should().Be(50); + roster[0].DepartmentGroupId.Should().Be(CrisisTeam); + } + + [Test] + public void Build_swaps_both_days_of_a_completed_swap_back_trade() + { + var shift = MakeShift(); + var aliceDay1 = Signup(5, "alice", Day1); + var bobDay2 = Signup(6, "bob", Day2); + var trade = new ShiftSignupTrade + { + ShiftSignupTradeId = 50, + SourceShiftSignupId = 5, + SourceShiftSignup = aliceDay1, + TargetShiftSignupId = 6, + TargetShiftSignup = bobDay2 + }; + + var day1 = ShiftRosterBuilder.Build(shift, Day1, new[] { aliceDay1, bobDay2 }, new[] { trade }); + var day2 = ShiftRosterBuilder.Build(shift, Day2, new[] { aliceDay1, bobDay2 }, new[] { trade }); + + day1.Should().ContainSingle(x => x.UserId == "bob" && x.TradedFromUserId == "alice"); + day2.Should().ContainSingle(x => x.UserId == "alice" && x.TradedFromUserId == "bob"); + } + + [Test] + public void Build_ignores_trades_waiting_for_approval_or_denied() + { + var shift = MakeShift(); + var slot = Signup(5, "alice", Day1); + var pending = new ShiftSignupTrade { SourceShiftSignupId = 5, SourceShiftSignup = slot, UserId = "bob", ApprovalPending = true }; + var denied = new ShiftSignupTrade { SourceShiftSignupId = 5, SourceShiftSignup = slot, UserId = "carol", Denied = true }; + + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { slot }, new[] { pending, denied }); + + roster.Should().ContainSingle(x => x.UserId == "alice" && x.Source == ShiftRosterSources.Signup); + } + + [Test] + public void Build_leaves_a_removed_traded_slot_open() + { + var shift = MakeShift(new ShiftPerson { UserId = "alice", GroupId = CrisisTeam }); + var slot = Signup(5, "alice", Day1); + slot.Denied = true; + var trade = new ShiftSignupTrade { SourceShiftSignupId = 5, SourceShiftSignup = slot, UserId = "bob" }; + + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { slot }, new[] { trade }); + + roster.Should().BeEmpty(); + } + + [Test] + public void CalculateNeeds_counts_each_person_against_one_role_only() + { + var shift = MakeShift(); + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { Signup(1, "both", Day1) }, null); + var roles = Roles(("both", new[] { Clinician, PeerSpecialist })); + + var needs = ShiftRosterBuilder.CalculateNeeds(shift, roster, roles); + + // One person holding both roles covers one of the two slots, not both. + needs[CrisisTeam].Values.Sum().Should().Be(1); + } + + [Test] + public void CalculateNeeds_places_single_role_people_first() + { + var shift = MakeShift(); + var signups = new[] { Signup(1, "both", Day1), Signup(2, "clinicianOnly", Day1) }; + var roster = ShiftRosterBuilder.Build(shift, Day1, signups, null); + var roles = Roles(("both", new[] { Clinician, PeerSpecialist }), ("clinicianOnly", new[] { Clinician })); + + var needs = ShiftRosterBuilder.CalculateNeeds(shift, roster, roles); + + needs[CrisisTeam][Clinician].Should().Be(0); + needs[CrisisTeam][PeerSpecialist].Should().Be(0); + } + + [Test] + public void CalculateNeeds_does_not_count_pending_people_or_other_groups() + { + var shift = MakeShift(); + var pending = Signup(1, "clinician", Day1); + pending.ApprovalPending = true; + var otherGroup = Signup(2, "peer", Day1, PeerTeam); + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { pending, otherGroup }, null); + var roles = Roles(("clinician", new[] { Clinician }), ("peer", new[] { PeerSpecialist })); + + var needs = ShiftRosterBuilder.CalculateNeeds(shift, roster, roles); + + needs[CrisisTeam][Clinician].Should().Be(1); + needs[CrisisTeam][PeerSpecialist].Should().Be(1); + } + + [Test] + public void CalculateNeeds_counts_assigned_staff_and_never_goes_below_zero() + { + var shift = MakeShift( + new ShiftPerson { UserId = "c1", GroupId = CrisisTeam }, + new ShiftPerson { UserId = "c2", GroupId = CrisisTeam }); + var roster = ShiftRosterBuilder.Build(shift, Day1, null, null); + var roles = Roles(("C1", new[] { Clinician }), ("c2", new[] { Clinician })); + + var needs = ShiftRosterBuilder.CalculateNeeds(shift, roster, roles); + + needs[CrisisTeam][Clinician].Should().Be(0); + needs[CrisisTeam][PeerSpecialist].Should().Be(1); + } + + [Test] + public void CalculateNeeds_includes_groups_without_role_requirements() + { + var shift = MakeShift(); + + var needs = ShiftRosterBuilder.CalculateNeeds(shift, new List(), null); + + needs.Should().ContainKey(PeerTeam); + needs[PeerTeam].Should().BeEmpty(); + } + + [Test] + public void Schedule_reports_filled_and_open_slots() + { + var schedule = new ShiftDaySchedule + { + Needs = new Dictionary> + { + { CrisisTeam, new Dictionary { { Clinician, 0 }, { PeerSpecialist, 2 } } }, + { PeerTeam, new Dictionary() } + } + }; + + schedule.IsFilled().Should().BeFalse(); + schedule.OpenSlots().Should().Be(2); + + schedule.Needs[CrisisTeam][PeerSpecialist] = 0; + schedule.IsFilled().Should().BeTrue(); + } + } + + [TestFixture] + public class ShiftManagementScopeTests + { + private static Shift ShiftWithGroups(params int[] groupIds) + { + return new Shift { Groups = groupIds.Select(x => new ShiftGroup { DepartmentGroupId = x }).ToList() }; + } + + [Test] + public void Department_wide_scope_manages_everything() + { + var scope = new ShiftManagementScope { AllGroups = true }; + + scope.IsSupervisor.Should().BeTrue(); + scope.CanManageShift(ShiftWithGroups()).Should().BeTrue(); + scope.CanManageShiftGroup(ShiftWithGroups(1), null).Should().BeTrue(); + } + + [Test] + public void Group_admin_manages_only_their_groups() + { + var scope = new ShiftManagementScope { GroupIds = new HashSet { 1 } }; + + scope.CanManageGroup(1).Should().BeTrue(); + scope.CanManageGroup(2).Should().BeFalse(); + scope.CanSuperviseShift(ShiftWithGroups(1, 2)).Should().BeTrue(); + scope.CanManageShift(ShiftWithGroups(1, 2)).Should().BeFalse(); + scope.CanManageShift(ShiftWithGroups(1)).Should().BeTrue(); + } + + [Test] + public void A_slot_with_no_group_needs_the_whole_shift() + { + var scope = new ShiftManagementScope { GroupIds = new HashSet { 1 } }; + + scope.CanManageShiftGroup(ShiftWithGroups(1, 2), null).Should().BeFalse(); + scope.CanManageShiftGroup(ShiftWithGroups(1), null).Should().BeTrue(); + scope.CanManageShift(ShiftWithGroups()).Should().BeFalse(); + } + + [Test] + public void No_scope_is_not_a_supervisor() + { + var scope = ShiftManagementScope.None(); + + scope.IsSupervisor.Should().BeFalse(); + scope.CanSuperviseShift(ShiftWithGroups(1)).Should().BeFalse(); + } + } +} + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ShiftSignupTradeStateTests + { + private static ShiftSignupTrade Trade() + { + return new ShiftSignupTrade + { + Users = new List + { + new ShiftSignupTradeUser { UserId = "AAAA-1111", Offered = true }, + new ShiftSignupTradeUser { UserId = "bbbb-2222", Declined = true } + } + }; + } + + [Test] + public void A_pick_waiting_for_a_supervisor_is_pending_for_the_taker_and_not_complete() + { + var trade = Trade(); + trade.UserId = "AAAA-1111"; + trade.ApprovalPending = true; + + trade.HasSelection().Should().BeTrue(); + trade.IsTradeComplete().Should().BeFalse(); + trade.GetState("aaaa-1111").Should().Be(ShiftSignupTradeStates.PendingApproval); + } + + [Test] + public void A_denied_trade_never_completes() + { + var trade = Trade(); + trade.UserId = "AAAA-1111"; + trade.Denied = true; + + trade.IsTradeComplete().Should().BeFalse(); + trade.GetState("aaaa-1111").Should().Be(ShiftSignupTradeStates.Denied); + } + + [Test] + public void States_ignore_user_id_case() + { + var trade = Trade(); + + trade.GetState("aaaa-1111").Should().Be(ShiftSignupTradeStates.Proposed); + trade.GetState("BBBB-2222").Should().Be(ShiftSignupTradeStates.Declined); + + trade.UserId = "AAAA-1111"; + trade.IsTradeComplete().Should().BeTrue(); + trade.GetState("aaaa-1111").Should().Be(ShiftSignupTradeStates.Accepted); + trade.GetState("cccc-3333").Should().Be(ShiftSignupTradeStates.Filled); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ShiftTimeWindowTests.cs b/Tests/Resgrid.Tests/Services/ShiftTimeWindowTests.cs new file mode 100644 index 000000000..d11cbfb97 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ShiftTimeWindowTests.cs @@ -0,0 +1,85 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model.Helpers; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ShiftTimeWindowTests + { + private static readonly DateTime Day = new DateTime(2026, 9, 24); + + [TestCase("7:00 AM", 7, 0)] + [TestCase("07:00 am", 7, 0)] + [TestCase("12:00 AM", 0, 0)] + [TestCase("12:30 PM", 12, 30)] + [TestCase("7:15PM", 19, 15)] + [TestCase("19:00", 19, 0)] + [TestCase("0700", 7, 0)] + public void parses_twelve_and_twenty_four_hour_times(string value, int hour, int minute) + { + ShiftTimeWindow.TryParseTimeOfDay(value).Should().Be(new TimeSpan(hour, minute, 0)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("not a time")] + [TestCase("25:00")] + public void unreadable_times_are_null(string value) + { + ShiftTimeWindow.TryParseTimeOfDay(value).Should().BeNull(); + } + + [Test] + public void a_day_shift_runs_from_start_to_end_on_the_same_day() + { + var window = ShiftTimeWindow.GetWindow(Day, "7:00 AM", "7:00 PM", null); + + window.Start.Should().Be(Day.AddHours(7)); + window.End.Should().Be(Day.AddHours(19)); + } + + [Test] + public void an_overnight_shift_ends_the_next_morning() + { + var window = ShiftTimeWindow.GetWindow(Day, "19:00", "07:00", null); + + window.Start.Should().Be(Day.AddHours(19)); + window.End.Should().Be(Day.AddDays(1).AddHours(7)); + } + + [Test] + public void hours_are_used_when_there_is_no_end_time() + { + var window = ShiftTimeWindow.GetWindow(Day, "08:00", null, 10); + + window.End.Should().Be(Day.AddHours(18)); + } + + [Test] + public void no_end_and_no_hours_runs_a_full_day_from_midnight_when_start_is_missing() + { + var window = ShiftTimeWindow.GetWindow(Day, null, null, null); + + window.Start.Should().Be(Day); + window.End.Should().Be(Day.AddDays(1)); + } + + [Test] + public void yesterdays_overnight_shift_is_still_active_after_midnight() + { + var yesterday = Day.AddDays(-1); + + ShiftTimeWindow.IsActive(Day.AddHours(3), yesterday, "7:00 PM", "7:00 AM", null).Should().BeTrue(); + ShiftTimeWindow.IsActive(Day.AddHours(7), yesterday, "7:00 PM", "7:00 AM", null).Should().BeFalse("the end is exclusive"); + } + + [Test] + public void a_shift_is_not_active_before_it_starts() + { + ShiftTimeWindow.IsActive(Day.AddHours(6).AddMinutes(59), Day, "7:00 AM", "7:00 PM", null).Should().BeFalse(); + ShiftTimeWindow.IsActive(Day.AddHours(7), Day, "7:00 AM", "7:00 PM", null).Should().BeTrue(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ShiftsServiceSchedulingTests.cs b/Tests/Resgrid.Tests/Services/ShiftsServiceSchedulingTests.cs new file mode 100644 index 000000000..39e9c4260 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ShiftsServiceSchedulingTests.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Queue; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ShiftsServiceSchedulingTests + { + private const int DepartmentId = 1; + private const int ShiftId = 10; + private const int ShiftDayId = 55; + private const int CrisisTeam = 100; + private const int PeerTeam = 200; + private const int Clinician = 1; + + private Mock _shiftsRepository; + private Mock _shiftPersonRepository; + private Mock _shiftDaysRepository; + private Mock _shiftGroupsRepository; + private Mock _shiftSignupRepository; + private Mock _shiftSignupTradeRepository; + private Mock _personnelRolesService; + private Mock _shiftSignupTradeUserRepository; + private Mock _shiftSignupTradeUserShiftsRepository; + private Mock _departmentsService; + private Mock _departmentGroupsService; + private Mock _shiftGroupRolesRepository; + private Mock _eventAggregator; + private Mock _departmentSettingsService; + + private Department _department; + private Shift _shift; + private ShiftDay _day; + private List _personnel; + private List _signups; + private List _trades; + private List _saved; + private ShiftsService _service; + + [SetUp] + public void SetUp() + { + _shiftsRepository = new Mock(); + _shiftPersonRepository = new Mock(); + _shiftDaysRepository = new Mock(); + _shiftGroupsRepository = new Mock(); + _shiftSignupRepository = new Mock(); + _shiftSignupTradeRepository = new Mock(); + _personnelRolesService = new Mock(); + _shiftSignupTradeUserRepository = new Mock(); + _shiftSignupTradeUserShiftsRepository = new Mock(); + _departmentsService = new Mock(); + _departmentGroupsService = new Mock(); + _shiftGroupRolesRepository = new Mock(); + _eventAggregator = new Mock(); + _departmentSettingsService = new Mock(); + + _department = new Department { DepartmentId = DepartmentId, TimeZone = "UTC" }; + _personnel = new List(); + _signups = new List(); + _trades = new List(); + _saved = new List(); + + _day = new ShiftDay { ShiftDayId = ShiftDayId, ShiftId = ShiftId, Day = DateTime.UtcNow.Date.AddDays(3) }; + _shift = new Shift + { + ShiftId = ShiftId, + DepartmentId = DepartmentId, + Name = "MCOT Day", + StartTime = "07:00", + EndTime = "19:00", + AssignmentType = (int)ShiftAssignmentTypes.Signup, + Days = new List { _day }, + // The department-wide load reads personnel straight off the shift (the JSON projection carries it). + Personnel = _personnel + }; + + var groups = new List + { + new ShiftGroup { ShiftGroupId = 1, ShiftId = ShiftId, DepartmentGroupId = CrisisTeam }, + new ShiftGroup { ShiftGroupId = 2, ShiftId = ShiftId, DepartmentGroupId = PeerTeam } + }; + + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, It.IsAny())).ReturnsAsync(_department); + _departmentsService.Setup(x => x.GetDepartmentMemberAsync(It.IsAny(), DepartmentId, It.IsAny())) + .ReturnsAsync((string userId, int _, bool __) => new DepartmentMember { UserId = userId, DepartmentId = DepartmentId }); + _shiftsRepository.Setup(x => x.GetShiftAndDaysByShiftIdAsync(ShiftId)).ReturnsAsync(() => _shift); + _shiftsRepository.Setup(x => x.GetByIdAsync(ShiftId)).ReturnsAsync(() => _shift); + _shiftsRepository.Setup(x => x.GetShiftAndDaysByDepartmentIdAsync(DepartmentId)).ReturnsAsync(() => new List { _shift }); + _shiftPersonRepository.Setup(x => x.GetAllShiftPersonsByShiftIdAsync(ShiftId)).ReturnsAsync(() => _personnel); + _shiftGroupsRepository.Setup(x => x.GetShiftGroupsByShiftIdAsync(ShiftId)).ReturnsAsync(groups); + _shiftGroupRolesRepository.Setup(x => x.GetShiftGroupRolesByGroupIdAsync(1)) + .ReturnsAsync(new List { new ShiftGroupRole { ShiftGroupId = 1, PersonnelRoleId = Clinician, Required = 1 } }); + _shiftDaysRepository.Setup(x => x.GetShiftDayByIdAsync(ShiftDayId)).ReturnsAsync(() => _day); + _shiftSignupRepository.Setup(x => x.GetAllShiftSignupsByShiftIdAndDateAsync(ShiftId, It.IsAny())) + .ReturnsAsync(() => _signups.ToList()); + _shiftSignupRepository.Setup(x => x.GetShiftSignupsByDepartmentIdAndDateRangeAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(() => _signups.ToList()); + _shiftSignupRepository.Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync((object id) => _signups.FirstOrDefault(x => x.ShiftSignupId == (int)id)); + _shiftSignupRepository.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ShiftSignup signup, CancellationToken _, bool __) => + { + if (signup.ShiftSignupId == 0) + signup.ShiftSignupId = 900 + _saved.Count; + + _saved.Add(signup); + return signup; + }); + _shiftSignupTradeRepository.Setup(x => x.GetShiftSignupTradesByDepartmentIdAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(() => _trades.ToList()); + _personnelRolesService.Setup(x => x.GetAllRolesForUsersInDepartmentAsync(DepartmentId)) + .ReturnsAsync(new Dictionary> + { + { "clinician", new List { new PersonnelRole { PersonnelRoleId = Clinician, Name = "Clinician" } } } + }); + _departmentSettingsService.Setup(x => x.GetTextToCallNumberForDepartmentAsync(DepartmentId)).ReturnsAsync(""); + + _service = new ShiftsService(_shiftsRepository.Object, _shiftPersonRepository.Object, _shiftDaysRepository.Object, _shiftGroupsRepository.Object, + _shiftSignupRepository.Object, _shiftSignupTradeRepository.Object, _personnelRolesService.Object, _shiftSignupTradeUserRepository.Object, + _shiftSignupTradeUserShiftsRepository.Object, new Mock().Object, new Mock().Object, + _departmentsService.Object, _departmentGroupsService.Object, new Mock().Object, _shiftGroupRolesRepository.Object, + _eventAggregator.Object, _departmentSettingsService.Object); + } + + [Test] + public async Task Signup_on_a_shift_that_requires_approval_waits_for_a_supervisor() + { + _shift.RequireApproval = true; + + var result = await _service.SignupUserForShiftDayAsync(ShiftDayId, CrisisTeam, "clinician"); + + result.Success.Should().BeTrue(); + result.Item.ApprovalPending.Should().BeTrue(); + result.Item.DepartmentGroupId.Should().Be(CrisisTeam); + _eventAggregator.Verify(x => x.SendMessage(It.Is(e => + e.ChangeType == ShiftQueueTypes.SignupPendingApproval && e.ShiftSignupId == result.Item.ShiftSignupId)), Times.Once); + } + + [Test] + public async Task Signup_without_approval_goes_straight_on_the_roster() + { + var result = await _service.SignupUserForShiftDayAsync(ShiftDayId, PeerTeam, "someone"); + + result.Success.Should().BeTrue(); + result.Item.ApprovalPending.Should().BeFalse(); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + + [Test] + public async Task Signup_must_pick_a_group_that_is_on_the_shift() + { + (await _service.SignupUserForShiftDayAsync(ShiftDayId, 999, "someone")).Error.Should().Be(ShiftActionErrors.InvalidGroup); + (await _service.SignupUserForShiftDayAsync(ShiftDayId, null, "someone")).Error.Should().Be(ShiftActionErrors.InvalidGroup); + (await _service.SignupUserForShiftDayAsync(ShiftDayId, 0, "someone")).Error.Should().Be(ShiftActionErrors.InvalidGroup); + } + + [Test] + public async Task Signup_is_refused_for_someone_already_on_the_day() + { + _personnel.Add(new ShiftPerson { ShiftId = ShiftId, UserId = "someone", GroupId = CrisisTeam }); + + var result = await _service.SignupUserForShiftDayAsync(ShiftDayId, PeerTeam, "someone"); + + result.Error.Should().Be(ShiftActionErrors.AlreadySignedUp); + } + + [Test] + public async Task Signup_is_refused_once_the_day_is_over() + { + _day.Day = DateTime.UtcNow.Date.AddDays(-3); + + var result = await _service.SignupUserForShiftDayAsync(ShiftDayId, CrisisTeam, "someone"); + + result.Error.Should().Be(ShiftActionErrors.DayInPast); + } + + [Test] + public async Task Removing_a_standing_roster_person_takes_them_off_that_day_only() + { + _personnel.Add(new ShiftPerson { ShiftId = ShiftId, UserId = "alice", GroupId = CrisisTeam }); + + var result = await _service.RemoveUserFromShiftDayAsync(ShiftDayId, "alice", "supervisor", "sick"); + + result.Success.Should().BeTrue(); + var marker = _saved.Single(); + marker.UserId.Should().Be("alice"); + marker.Denied.Should().BeTrue(); + marker.ShiftDay.Should().Be(_day.Day); + marker.ReviewNote.Should().Be("sick"); + _eventAggregator.Verify(x => x.SendMessage(It.Is(e => e.ChangeType == ShiftQueueTypes.DayRemoved)), Times.Once); + + // The same data now resolves to an empty day, while the standing roster itself is untouched. + _signups.Add(marker); + var schedule = await _service.GetShiftDayScheduleAsync(ShiftDayId); + schedule.Roster.Should().BeEmpty(); + _personnel.Should().ContainSingle(); + } + + [Test] + public async Task Removing_someone_who_is_not_on_the_day_fails() + { + var result = await _service.RemoveUserFromShiftDayAsync(ShiftDayId, "nobody", "supervisor", null); + + result.Error.Should().Be(ShiftActionErrors.NotOnShift); + } + + [Test] + public async Task Assigning_brings_back_a_person_who_was_removed_for_the_day() + { + var removed = new ShiftSignup { ShiftSignupId = 7, ShiftId = ShiftId, UserId = "alice", ShiftDay = _day.Day, DepartmentGroupId = CrisisTeam, Denied = true }; + _signups.Add(removed); + + var result = await _service.AssignUserToShiftDayAsync(ShiftDayId, "alice", PeerTeam, "supervisor"); + + result.Success.Should().BeTrue(); + result.Item.ShiftSignupId.Should().Be(7); + result.Item.Denied.Should().BeFalse(); + result.Item.DepartmentGroupId.Should().Be(PeerTeam); + result.Item.AssignedByUserId.Should().Be("supervisor"); + } + + [Test] + public async Task Assigning_someone_already_on_duty_in_that_group_fails() + { + _personnel.Add(new ShiftPerson { ShiftId = ShiftId, UserId = "alice", GroupId = CrisisTeam }); + + var result = await _service.AssignUserToShiftDayAsync(ShiftDayId, "alice", CrisisTeam, "supervisor"); + + result.Error.Should().Be(ShiftActionErrors.AlreadyOnRoster); + } + + [Test] + public async Task Reviewing_a_signup_that_is_not_pending_fails() + { + _signups.Add(new ShiftSignup { ShiftSignupId = 8, ShiftId = ShiftId, UserId = "bob", ShiftDay = _day.Day }); + + var result = await _service.ReviewShiftSignupAsync(8, true, "supervisor", null); + + result.Error.Should().Be(ShiftActionErrors.NotPending); + } + + [Test] + public async Task Denying_a_pending_signup_records_the_review() + { + _signups.Add(new ShiftSignup { ShiftSignupId = 8, ShiftId = ShiftId, UserId = "bob", ShiftDay = _day.Day, ApprovalPending = true }); + + var result = await _service.ReviewShiftSignupAsync(8, false, "supervisor", "full"); + + result.Success.Should().BeTrue(); + result.Item.ApprovalPending.Should().BeFalse(); + result.Item.Denied.Should().BeTrue(); + result.Item.ReviewedByUserId.Should().Be("supervisor"); + result.Item.ReviewNote.Should().Be("full"); + _eventAggregator.Verify(x => x.SendMessage(It.Is(e => e.ChangeType == ShiftQueueTypes.SignupReviewed)), Times.Once); + } + + [Test] + public async Task Needs_count_the_resolved_roster_for_assigned_shifts_too() + { + _shift.AssignmentType = (int)ShiftAssignmentTypes.Assigned; + _personnel.Add(new ShiftPerson { ShiftId = ShiftId, UserId = "clinician", GroupId = CrisisTeam }); + + var needs = await _service.GetShiftDayNeedsAsync(ShiftDayId); + + needs.Should().NotBeNull(); + needs[CrisisTeam][Clinician].Should().Be(0); + (await _service.IsShiftDayFilledAsync(ShiftDayId)).Should().BeTrue(); + } + + [Test] + public async Task On_duty_follows_a_night_shift_past_midnight_and_skips_pending_people() + { + _shift.StartTime = "19:00"; + _shift.EndTime = "07:00"; + _day.Day = new DateTime(2026, 10, 5); + _personnel.Add(new ShiftPerson { ShiftId = ShiftId, UserId = "alice", GroupId = CrisisTeam }); + _signups.Add(new ShiftSignup { ShiftSignupId = 3, ShiftId = ShiftId, UserId = "bob", ShiftDay = _day.Day, DepartmentGroupId = CrisisTeam, ApprovalPending = true }); + + var afterMidnight = new DateTime(2026, 10, 6, 2, 0, 0, DateTimeKind.Utc); + var nextMorning = new DateTime(2026, 10, 6, 8, 0, 0, DateTimeKind.Utc); + + (await _service.GetOnDutyUserIdsForGroupAsync(DepartmentId, CrisisTeam, afterMidnight)).Should().BeEquivalentTo(new[] { "alice" }); + (await _service.GetOnDutyUserIdsForGroupAsync(DepartmentId, CrisisTeam, nextMorning)).Should().BeEmpty(); + } + + [Test] + public async Task On_duty_includes_ungrouped_standing_roster_members_of_the_group() + { + _day.Day = new DateTime(2026, 10, 5); + _personnel.Add(new ShiftPerson { ShiftId = ShiftId, UserId = "member" }); + _personnel.Add(new ShiftPerson { ShiftId = ShiftId, UserId = "outsider" }); + _departmentGroupsService.Setup(x => x.GetAllMembersForGroupAsync(PeerTeam)) + .ReturnsAsync(new List { new DepartmentGroupMember { UserId = "member" } }); + + var onDuty = await _service.GetOnDutyUserIdsForGroupsAsync(DepartmentId, new[] { PeerTeam }, new DateTime(2026, 10, 5, 12, 0, 0, DateTimeKind.Utc)); + + onDuty[PeerTeam].Should().BeEquivalentTo(new[] { "member" }); + } + + [Test] + public async Task Finishing_a_trade_on_an_approval_shift_waits_for_a_supervisor() + { + _shift.RequireApproval = true; + var source = new ShiftSignup { ShiftSignupId = 5, ShiftId = ShiftId, UserId = "alice", ShiftDay = _day.Day, DepartmentGroupId = CrisisTeam }; + _signups.Add(source); + var trade = new ShiftSignupTrade { ShiftSignupTradeId = 50, SourceShiftSignupId = 5 }; + SetupTrade(trade, new ShiftSignupTradeUser { ShiftSignupTradeId = 50, UserId = "bob", Offered = true }); + + var result = await _service.FinishTradeAsync(50, "alice", "bob", null); + + result.Success.Should().BeTrue(); + trade.UserId.Should().Be("bob"); + trade.ApprovalPending.Should().BeTrue(); + trade.IsTradeComplete().Should().BeFalse(); + _eventAggregator.Verify(x => x.SendMessage(It.Is(e => e.ChangeType == ShiftQueueTypes.TradePendingApproval)), Times.Once); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + + [Test] + public async Task Finishing_a_trade_only_accepts_someone_who_offered() + { + _signups.Add(new ShiftSignup { ShiftSignupId = 5, ShiftId = ShiftId, UserId = "alice", ShiftDay = _day.Day }); + SetupTrade(new ShiftSignupTrade { ShiftSignupTradeId = 50, SourceShiftSignupId = 5 }, + new ShiftSignupTradeUser { ShiftSignupTradeId = 50, UserId = "bob", Offered = false }, + new ShiftSignupTradeUser { ShiftSignupTradeId = 50, UserId = "carol", Offered = true, Declined = true }); + + (await _service.FinishTradeAsync(50, "alice", "bob", null)).Error.Should().Be(ShiftActionErrors.InvalidOffer); + (await _service.FinishTradeAsync(50, "alice", "carol", null)).Error.Should().Be(ShiftActionErrors.InvalidOffer); + (await _service.FinishTradeAsync(50, "mallory", "bob", null)).Error.Should().Be(ShiftActionErrors.NotAllowed); + } + + [Test] + public async Task Approving_a_pending_trade_makes_it_take_effect() + { + _signups.Add(new ShiftSignup { ShiftSignupId = 5, ShiftId = ShiftId, UserId = "alice", ShiftDay = _day.Day }); + var trade = new ShiftSignupTrade { ShiftSignupTradeId = 50, SourceShiftSignupId = 5, UserId = "bob", ApprovalPending = true }; + SetupTrade(trade); + + var result = await _service.ReviewTradeAsync(50, true, "supervisor", null); + + result.Success.Should().BeTrue(); + trade.ApprovalPending.Should().BeFalse(); + trade.IsTradeComplete().Should().BeTrue(); + trade.ReviewedByUserId.Should().Be("supervisor"); + } + + [Test] + public async Task Requesting_a_trade_as_a_standing_roster_person_creates_their_day_slot() + { + _personnel.Add(new ShiftPerson { ShiftId = ShiftId, UserId = "alice", GroupId = CrisisTeam }); + _departmentsService.Setup(x => x.GetAllMembersForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List { new DepartmentMember { UserId = "alice" }, new DepartmentMember { UserId = "bob" } }); + _shiftSignupTradeRepository.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ShiftSignupTrade t, CancellationToken _, bool __) => { t.ShiftSignupTradeId = 77; return t; }); + _shiftSignupRepository.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ShiftSignup s, CancellationToken _, bool __) => { s.ShiftSignupId = 901; _signups.Add(s); return s; }); + + var result = await _service.RequestTradeAsync(ShiftDayId, "alice", new List { "bob", "alice", "" }, "family"); + + result.Success.Should().BeTrue(); + var slot = _signups.Single(); + slot.UserId.Should().Be("alice"); + slot.DepartmentGroupId.Should().Be(CrisisTeam); + result.Item.SourceShiftSignupId.Should().Be(901); + result.Item.Users.Select(x => x.UserId).Should().BeEquivalentTo(new[] { "bob" }); + _eventAggregator.Verify(x => x.SendMessage(It.Is(e => e.ShiftSignupTradeId == 77)), Times.Once); + } + + [Test] + public async Task Requesting_a_trade_with_nobody_to_ask_fails() + { + _personnel.Add(new ShiftPerson { ShiftId = ShiftId, UserId = "alice", GroupId = CrisisTeam }); + + var result = await _service.RequestTradeAsync(ShiftDayId, "alice", new List { "alice" }, null); + + result.Error.Should().Be(ShiftActionErrors.NoUsers); + _signups.Should().BeEmpty(); + } + + private void SetupTrade(ShiftSignupTrade trade, params ShiftSignupTradeUser[] users) + { + _shiftSignupTradeRepository.Setup(x => x.GetByIdAsync(trade.ShiftSignupTradeId)).ReturnsAsync(trade); + _shiftSignupTradeRepository.Setup(x => x.SaveOrUpdateAsync(trade, It.IsAny(), It.IsAny())).ReturnsAsync(trade); + _shiftSignupTradeUserRepository.Setup(x => x.GetShiftSignupTradeUsersByTradeIdAsync(trade.ShiftSignupTradeId)).ReturnsAsync(users.ToList()); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/UdfRenderingServiceTests.cs b/Tests/Resgrid.Tests/Services/UdfRenderingServiceTests.cs index 260ad1f13..f1fb3b125 100644 --- a/Tests/Resgrid.Tests/Services/UdfRenderingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/UdfRenderingServiceTests.cs @@ -80,6 +80,111 @@ public void should_render_select_for_dropdown_with_options() html.Should().Contain("Option 2"); } + [TestCase(UdfFieldDataType.Dropdown)] + [TestCase(UdfFieldDataType.MultiSelect)] + public void should_keep_an_unrevealed_protected_selection_posting_the_sentinel(UdfFieldDataType type) + { + var rules = JsonConvert.SerializeObject(new UdfValidationRules + { + Options = new List { new UdfDropdownOption { Key = "opt1", Label = "Option 1" } } + }); + + var fields = new List + { + new UdfField { UdfFieldId = "f1", Name = "outcome", Label = "Outcome", FieldDataType = (int)type, ValidationRules = rules, IsEnabled = true, IsVisibleOnMobile = true, IsVisibleOnReports = true } + }; + + // An ADP envelope renders as the REDACTED placeholder, which matches no option. + var values = new List + { + new UdfFieldValue { UdfFieldId = "f1", Value = ProtectedDataEnvelope.Prefix + "ciphertext" } + }; + + var html = _service.GenerateHtmlFormFields(MakeDefinition(), fields, values); + + html.Should().Contain($"