From 9e292410050056064418508ec976902700a68574 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 7 Sep 2026 22:59:11 -0700 Subject: [PATCH] RG-T55 RMS RMS hardening scope and prevention and investigations --- Core/Resgrid.Config/RecordsConnectorConfig.cs | 17 +- .../Areas/User/Contacts/Contacts.ar.resx | 6 + .../Areas/User/Contacts/Contacts.de.resx | 6 + .../Areas/User/Contacts/Contacts.el.resx | 6 + .../Areas/User/Contacts/Contacts.en.resx | 251 ++++- .../Areas/User/Contacts/Contacts.es.resx | 6 + .../Areas/User/Contacts/Contacts.fr.resx | 6 + .../Areas/User/Contacts/Contacts.it.resx | 6 + .../Areas/User/Contacts/Contacts.pl.resx | 6 + .../Areas/User/Contacts/Contacts.sv.resx | 6 + .../Areas/User/Contacts/Contacts.uk.resx | 6 + .../Areas/User/Dispatch/Call.en.resx | 21 + .../Areas/User/Records/Records.ar.resx | 415 +++++++++ .../Areas/User/Records/Records.de.resx | 415 +++++++++ .../Areas/User/Records/Records.el.resx | 415 +++++++++ .../Areas/User/Records/Records.en.resx | 415 +++++++++ .../Areas/User/Records/Records.es.resx | 415 +++++++++ .../Areas/User/Records/Records.fr.resx | 415 +++++++++ .../Areas/User/Records/Records.it.resx | 415 +++++++++ .../Areas/User/Records/Records.pl.resx | 415 +++++++++ .../Areas/User/Records/Records.sv.resx | 415 +++++++++ .../Areas/User/Records/Records.uk.resx | 415 +++++++++ .../Areas/User/Security/Security.ar.resx | 2 + .../Areas/User/Security/Security.de.resx | 2 + .../Areas/User/Security/Security.el.resx | 2 + .../Areas/User/Security/Security.en.resx | 2 + .../Areas/User/Security/Security.es.resx | 2 + .../Areas/User/Security/Security.fr.resx | 2 + .../Areas/User/Security/Security.it.resx | 2 + .../Areas/User/Security/Security.pl.resx | 2 + .../Areas/User/Security/Security.sv.resx | 2 + .../Areas/User/Security/Security.uk.resx | 2 + Core/Resgrid.Model/AuditLogTypes.cs | 8 +- Core/Resgrid.Model/CallSiteInfo.cs | 44 + Core/Resgrid.Model/ContactAttachment.cs | 72 ++ Core/Resgrid.Model/ContactAttachmentTypes.cs | 13 + Core/Resgrid.Model/ContactPreplan.cs | 114 +++ Core/Resgrid.Model/ContactPreplanEnums.cs | 80 ++ Core/Resgrid.Model/ContactPreplanHazard.cs | 70 ++ Core/Resgrid.Model/FeatureFlagKeys.cs | 21 + Core/Resgrid.Model/PermissionTypes.cs | 10 +- .../Records/ExternalOrderFeed.cs | 15 +- .../Records/FieldRecordsContracts.cs | 8 +- .../Records/OccupancyDispatchProjectionV1.cs | 166 ++++ .../Records/RecordCardinalityException.cs | 27 + .../Records/RecordPermissionCatalog.cs | 6 +- .../Records/RecordsPreventionContracts.cs | 168 ++++ .../Records/RecordsReleaseTelemetry.cs | 73 ++ .../Records/RmsInvestigations.cs | 282 ++++++ Core/Resgrid.Model/Records/RmsOccupancy.cs | 324 +++++++ .../Records/RmsOperationalRecord.cs | 9 + Core/Resgrid.Model/Records/RmsPrevention.cs | 628 +++++++++++++ .../Records/RmsProtectedFields.cs | 93 ++ .../Resgrid.Model/Records/RmsQualityReview.cs | 116 +++ .../Records/RmsRecordDefinitions.cs | 9 + .../Resgrid.Model/Records/RmsTemplatePacks.cs | 2 + .../Repositories/IContactPreplanRepository.cs | 42 + .../IRmsDefinitionRepositories.cs | 6 + .../IRmsFieldRolloutRepository.cs | 2 +- .../IRmsPreventionRepositories.cs | 259 ++++++ .../Repositories/IRmsRepositories.cs | 10 + Core/Resgrid.Model/Services/ICallsService.cs | 5 +- .../Services/IContactsService.cs | 50 + .../Services/IProtectedReadService.cs | 16 + .../Services/IProtectedWriteService.cs | 17 + .../Services/IRecordsInvestigationsService.cs | 93 ++ .../Services/IRecordsOccupancyService.cs | 76 ++ .../Services/IRecordsPreventionServices.cs | 151 +++ .../Services/IRecordsProtectionService.cs | 30 + .../WorkflowTemplateVariableCatalog.cs | 77 ++ .../Resgrid.Model/WorkflowTriggerEventType.cs | 13 +- Core/Resgrid.Services/AdpTableBindings.cs | 104 ++- Core/Resgrid.Services/CallsService.cs | 12 +- Core/Resgrid.Services/ContactsService.cs | 589 +++++++++++- .../Resgrid.Services/ProtectedFieldCatalog.cs | 135 +++ Core/Resgrid.Services/ProtectedReadService.cs | 269 ++++++ .../Connectors/ExternalFeedDestination.cs | 124 +++ .../Connectors/ExternalOrderFeedProviders.cs | 14 +- .../RecordDeploymentConnectorsService.cs | 41 +- .../Records/FieldRecordsService.cs | 10 +- .../Records/RecordDefinitionsService.cs | 15 +- .../Records/RecordDeploymentsService.cs | 14 +- .../RecordTemplateCatalog.IncidentSupport.cs | 26 + .../Records/RecordsCrrService.cs | 87 ++ .../Records/RecordsFieldRolloutService.cs | 5 +- .../Records/RecordsHydrantsService.cs | 244 +++++ .../Records/RecordsInspectionsService.cs | 598 ++++++++++++ .../Records/RecordsInvestigationsService.cs | 497 ++++++++++ .../Records/RecordsOccupancyService.cs | 873 ++++++++++++++++++ .../Records/RecordsPermitsService.cs | 293 ++++++ .../RecordsPreventionAttachmentsService.cs | 131 +++ .../Records/RecordsPreventionGate.cs | 128 +++ .../Records/RecordsProtectionService.cs | 119 +++ .../Records/RecordsQualityReviewService.cs | 229 +++++ .../Records/RecordsReleaseTelemetryService.cs | 262 ++++++ .../Records/RecordsService.cs | 95 +- Core/Resgrid.Services/ServicesModule.cs | 12 + .../WorkflowSampleDataGenerator.cs | 65 ++ .../WorkflowTemplateContextBuilder.cs | 5 +- .../Resgrid.Providers.Claims/ClaimsLogic.cs | 2 + .../ResgridClaimTypes.cs | 2 + .../ResgridResources.cs | 1 + .../M0182_AddRmsRecordCardinality.cs | 41 + .../Migrations/M0183_AddContactPreplans.cs | 103 +++ .../Migrations/M0184_AddContactAttachments.cs | 44 + .../Migrations/M0185_RmsReleaseReadiness.cs | 124 +++ ...M0186_AddRmsPreventionAndInvestigations.cs | 783 ++++++++++++++++ .../M0182_AddRmsRecordCardinalityPg.cs | 40 + .../Migrations/M0183_AddContactPreplansPg.cs | 99 ++ .../M0184_AddContactAttachmentsPg.cs | 43 + .../Migrations/M0185_RmsReleaseReadinessPg.cs | 124 +++ ...186_AddRmsPreventionAndInvestigationsPg.cs | 783 ++++++++++++++++ .../ContactPreplanRepositories.cs | 119 +++ .../Modules/DataModule.cs | 35 +- .../Modules/TestingDataModule.cs | 36 + .../RmsDefinitionRepositories.cs | 4 + .../RmsFieldRepositories.cs | 4 +- .../RmsPreventionRepositories.cs | 669 ++++++++++++++ .../RmsRepositories.cs | 27 +- .../Allocations/trigger-baseline.json | 5 +- .../TranslationCompletenessTests.cs | 18 + .../Rms/BackOfficeExtensionTests.cs | 116 +++ .../ClaimsAuthorizationHelperParityTests.cs | 5 +- .../Rms/FakeRmsDefinitionStore.cs | 4 + .../Rms/FieldRecordCatalogTests.cs | 16 + .../Rms/PassthroughRecordsProtection.cs | 28 + Tests/Resgrid.Tests/Rms/RecordClaimsTests.cs | 3 +- .../RecordDeploymentConnectorsServiceTests.cs | 22 + .../Rms/RecordsCutoverServiceTests.cs | 2 +- .../Rms/RecordsFieldRolloutServiceTests.cs | 22 +- .../RecordsHydrantsAndPermitsServiceTests.cs | 177 ++++ .../Rms/RecordsInspectionsServiceTests.cs | 177 ++++ .../Rms/RecordsInvestigationsServiceTests.cs | 184 ++++ .../Rms/RecordsOccupancyServiceTests.cs | 246 +++++ .../Rms/RecordsPermissionRowsTests.cs | 1 + .../Rms/RecordsQualityAndTelemetryTests.cs | 178 ++++ .../Rms/RecordsRms5WorkflowTriggerTests.cs | 56 ++ .../Rms/RmsContainerCompositionTests.cs | 13 + .../Rms/RmsIdentifierPinTests.cs | 19 +- Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs | 397 ++++++++ .../Rms/RmsProtectedFieldsCatalogTests.cs | 2 +- .../Services/AdpSizingServiceTests.cs | 6 +- .../Services/ContactsServicePreplanTests.cs | 614 ++++++++++++ .../Services/ProtectedReadServiceTests.cs | 82 +- .../RemainingCandidateProtectionTests.cs | 27 +- .../Web/Services/CallsControllerTests.cs | 3 +- .../User/ProtectedRevealAuthorizationTests.cs | 5 + .../Controllers/v4/CallsController.cs | 255 ++++- .../Controllers/v4/ContactFilesController.cs | 360 ++++++++ .../Controllers/v4/ContactsController.cs | 485 +++++++++- .../Controllers/v4/FieldRecordsController.cs | 2 +- .../RecordDeploymentConnectorsController.cs | 5 + .../v4/RecordHydrantsController.cs | 142 +++ .../v4/RecordInspectionsController.cs | 238 +++++ .../v4/RecordInvestigationsController.cs | 231 +++++ .../v4/RecordOccupanciesController.cs | 241 +++++ .../Controllers/v4/RecordPermitsController.cs | 185 ++++ .../Controllers/v4/RecordQualityController.cs | 205 ++++ .../v4/RecordsPreventionApiControllerBase.cs | 63 ++ .../Helpers/ClaimsAuthorizationHelper.cs | 5 + .../Helpers/RecordsRms1bApiMapper.cs | 7 +- .../Models/v4/Calls/CallResult.cs | 7 + .../Models/v4/Calls/CallSiteInfoResult.cs | 102 ++ .../Models/v4/Calls/EditCallInput.cs | 12 + .../Models/v4/Calls/NewCallInput.cs | 12 + .../v4/ContactFiles/ContactFileResult.cs | 111 +++ .../v4/Contacts/ContactHazardsResult.cs | 104 +++ .../v4/Contacts/ContactPreplanResult.cs | 171 ++++ .../v4/Records/RecordsRms1bApiModels.cs | 3 + .../Models/v4/Records/RecordsRms5ApiModels.cs | 593 ++++++++++++ .../Resgrid.Web.Services.xml | 525 +++++++++++ Web/Resgrid.Web.Services/Startup.cs | 1 + .../User/Controllers/ContactsController.cs | 551 ++++++++++- .../User/Controllers/DispatchController.cs | 65 ++ .../Controllers/RecordHydrantsController.cs | 164 ++++ .../RecordInspectionsController.cs | 291 ++++++ .../RecordInvestigationsController.cs | 287 ++++++ .../RecordOccupanciesController.cs | 287 ++++++ .../Controllers/RecordPermitsController.cs | 283 ++++++ .../User/Controllers/RecordsController.cs | 7 +- .../RecordsPreventionMvcControllerBase.cs | 87 ++ .../Controllers/RecordsQualityController.cs | 252 +++++ .../Areas/User/Models/Calls/ViewCallView.cs | 3 + .../Models/Contacts/ContactPreplanViews.cs | 103 +++ .../User/Models/Contacts/ContactsIndexView.cs | 3 + .../User/Models/Contacts/ViewContactView.cs | 5 + .../Records/RecordDefinitionsViewModels.cs | 7 +- .../Models/Records/RecordsRms5ViewModels.cs | 425 +++++++++ .../User/Models/Records/RecordsViewModels.cs | 2 + .../User/Views/Contacts/Attachments.cshtml | 137 +++ .../Areas/User/Views/Contacts/Index.cshtml | 5 +- .../Areas/User/Views/Contacts/Preplan.cshtml | 485 ++++++++++ .../Areas/User/Views/Contacts/View.cshtml | 116 +++ .../Areas/User/Views/Dispatch/ViewCall.cshtml | 70 ++ .../Areas/User/Views/RecordCrr/Edit.cshtml | 51 + .../Areas/User/Views/RecordCrr/Index.cshtml | 75 ++ .../User/Views/RecordDefinitions/Edit.cshtml | 1 + .../RecordDeploymentConnectors/Edit.cshtml | 2 +- .../User/Views/RecordHydrants/Details.cshtml | 123 +++ .../User/Views/RecordHydrants/Edit.cshtml | 42 + .../User/Views/RecordHydrants/Index.cshtml | 104 +++ .../Views/RecordInspections/CodeSets.cshtml | 97 ++ .../Views/RecordInspections/Details.cshtml | 154 +++ .../User/Views/RecordInspections/Index.cshtml | 97 ++ .../Views/RecordInspections/Programs.cshtml | 55 ++ .../Views/RecordInvestigations/Custody.cshtml | 63 ++ .../Views/RecordInvestigations/Details.cshtml | 190 ++++ .../Views/RecordInvestigations/Index.cshtml | 54 ++ .../Views/RecordInvestigations/Open.cshtml | 35 + .../Views/RecordOccupancies/Crosswalk.cshtml | 116 +++ .../Views/RecordOccupancies/Details.cshtml | 232 +++++ .../User/Views/RecordOccupancies/Edit.cshtml | 88 ++ .../User/Views/RecordOccupancies/Index.cshtml | 82 ++ .../User/Views/RecordPermits/Details.cshtml | 100 ++ .../User/Views/RecordPermits/Edit.cshtml | 41 + .../User/Views/RecordPermits/Index.cshtml | 67 ++ .../User/Views/RecordPermits/Types.cshtml | 54 ++ .../Areas/User/Views/Records/Index.cshtml | 5 + .../Views/Records/_DefinitionFields.cshtml | 4 +- .../User/Views/RecordsHealth/Index.cshtml | 85 ++ .../User/Views/RecordsQuality/Index.cshtml | 70 ++ .../User/Views/RecordsQuality/Review.cshtml | 62 ++ .../User/Views/RecordsQuality/Rubric.cshtml | 37 + .../User/Views/RecordsQuality/Trends.cshtml | 45 + .../Shared/_ContactPreplanSummary.cshtml | 98 ++ .../User/Views/Shared/_Navigation.cshtml | 33 + .../Shared/_PreventionAttachments.cshtml | 67 ++ .../Helpers/ClaimsAuthorizationHelper.cs | 5 + Web/Resgrid.Web/Startup.cs | 1 + .../Logic/RmsDueStateEvaluationLogic.cs | 11 + 230 files changed, 28285 insertions(+), 86 deletions(-) create mode 100644 Core/Resgrid.Model/CallSiteInfo.cs create mode 100644 Core/Resgrid.Model/ContactAttachment.cs create mode 100644 Core/Resgrid.Model/ContactAttachmentTypes.cs create mode 100644 Core/Resgrid.Model/ContactPreplan.cs create mode 100644 Core/Resgrid.Model/ContactPreplanEnums.cs create mode 100644 Core/Resgrid.Model/ContactPreplanHazard.cs create mode 100644 Core/Resgrid.Model/Records/OccupancyDispatchProjectionV1.cs create mode 100644 Core/Resgrid.Model/Records/RecordCardinalityException.cs create mode 100644 Core/Resgrid.Model/Records/RecordsPreventionContracts.cs create mode 100644 Core/Resgrid.Model/Records/RecordsReleaseTelemetry.cs create mode 100644 Core/Resgrid.Model/Records/RmsInvestigations.cs create mode 100644 Core/Resgrid.Model/Records/RmsOccupancy.cs create mode 100644 Core/Resgrid.Model/Records/RmsPrevention.cs create mode 100644 Core/Resgrid.Model/Records/RmsQualityReview.cs create mode 100644 Core/Resgrid.Model/Repositories/IContactPreplanRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsInvestigationsService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsOccupancyService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsPreventionServices.cs create mode 100644 Core/Resgrid.Services/Records/Connectors/ExternalFeedDestination.cs create mode 100644 Core/Resgrid.Services/Records/RecordsCrrService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsHydrantsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsInspectionsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsInvestigationsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsOccupancyService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsPermitsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsPreventionAttachmentsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsPreventionGate.cs create mode 100644 Core/Resgrid.Services/Records/RecordsQualityReviewService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsReleaseTelemetryService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0182_AddRmsRecordCardinality.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0183_AddContactPreplans.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0184_AddContactAttachments.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0185_RmsReleaseReadiness.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0186_AddRmsPreventionAndInvestigations.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0182_AddRmsRecordCardinalityPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0183_AddContactPreplansPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0184_AddContactAttachmentsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0185_RmsReleaseReadinessPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0186_AddRmsPreventionAndInvestigationsPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/ContactPreplanRepositories.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsHydrantsAndPermitsServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsOccupancyServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsQualityAndTelemetryTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsRms5WorkflowTriggerTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs create mode 100644 Tests/Resgrid.Tests/Services/ContactsServicePreplanTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/ContactFilesController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordHydrantsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordInspectionsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordInvestigationsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordOccupanciesController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordPermitsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordQualityController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordsPreventionApiControllerBase.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Calls/CallSiteInfoResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/ContactFiles/ContactFileResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Contacts/ContactHazardsResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Contacts/ContactPreplanResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms5ApiModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordHydrantsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordOccupanciesController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Contacts/ContactPreplanViews.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Contacts/Attachments.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Contacts/Preplan.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordCrr/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordCrr/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordInspections/CodeSets.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordInspections/Programs.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Open.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordPermits/Details.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordPermits/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordPermits/Types.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsHealth/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Review.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Rubric.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsQuality/Trends.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_ContactPreplanSummary.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_PreventionAttachments.cshtml diff --git a/Core/Resgrid.Config/RecordsConnectorConfig.cs b/Core/Resgrid.Config/RecordsConnectorConfig.cs index fef6e166f..2c1997c98 100644 --- a/Core/Resgrid.Config/RecordsConnectorConfig.cs +++ b/Core/Resgrid.Config/RecordsConnectorConfig.cs @@ -5,7 +5,8 @@ namespace Resgrid.Config /// switch is on and a credential passphrase is set: a connector holds an outbound credential encrypted under /// the passphrase, so without one no connector can be created. Environment keys: /// RESGRID:RecordsConnectorConfig:Enabled, :CredentialPassphrase, :TimeoutSeconds, :MaxFeedBytes, - /// :MinPollIntervalMinutes, :DefaultMaxRequestsPerHour, :AllowHttp. + /// :MinPollIntervalMinutes, :DefaultMaxRequestsPerHour, :AllowHttp, :AllowedFeedHosts, + /// :AllowPrivateNetworkFeeds. /// public static class RecordsConnectorConfig { @@ -28,6 +29,20 @@ public static class RecordsConnectorConfig /// http feed roots are refused unless this is on (local development only). public static bool AllowHttp = false; + /// + /// Hosts a connector may be pointed at, comma separated. A leading dot matches the domain and its + /// subdomains ("example.org, .ciffc.ca"). Empty allows any public host: the address checks below still + /// apply, so an empty list is not the same as no restriction. + /// + public static string AllowedFeedHosts = ""; + + /// + /// Whether a connector may address a loopback, link-local, private or otherwise internal address. Off in + /// every deployment: without it a department administrator can aim a connector at services the worker can + /// reach and read the answer back. On only for local development against a feed on the same machine. + /// + public static bool AllowPrivateNetworkFeeds = false; + /// Consecutive failures after which a connector is switched off until an administrator looks. public static int DisableAfterConsecutiveFailures = 10; diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.ar.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.ar.resx index c146dabc1..8cd690bbd 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.ar.resx @@ -483,4 +483,10 @@ تعديل جهة الاتصال + + أصبحت الخطط المسبقة لهذه الإدارة تُدار الآن كمبانٍ في السجلات. هذه الصفحة للقراءة فقط؛ عدّل المبنى من السجلات. + + + فتح المبنى + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.de.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.de.resx index 916b1c8c9..2db0a8ec2 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.de.resx @@ -434,4 +434,10 @@ Edit Contact + + Einsatzpläne dieser Organisation werden jetzt als Objekte in den Berichten gepflegt. Diese Seite ist schreibgeschützt; bearbeiten Sie das Objekt unter Berichte. + + + Objekt öffnen + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.el.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.el.resx index 06d2a7ddb..3bc6831cf 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.el.resx @@ -504,4 +504,10 @@ Προαιρετική + + Τα προσχέδια αυτής της υπηρεσίας διατηρούνται πλέον ως κτίρια στα Αρχεία. Η σελίδα είναι μόνο για ανάγνωση· επεξεργαστείτε το κτίριο στα Αρχεία. + + + Άνοιγμα κτιρίου + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.en.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.en.resx index 275fe2ddb..578104c4b 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.en.resx @@ -504,4 +504,253 @@ Optional - \ No newline at end of file + + Pre-Incident Plan + + + Pre-Plan + + + Site Files + + + Manage Files + + + Construction & Occupancy + + + Construction Type + + + Roof Type + + + Occupancy Type + + + Hours of Occupancy + + + e.g. Mon-Fri 08:00-17:00, staffed overnight + + + Occupant Load + + + Occupants Needing Assistance + + + Occupants on site may need evacuation assistance + + + Who, where and what assistance is needed + + + Occupancy Notes + + + Utility Shutoffs + + + Gas Shutoff + + + Electric Shutoff + + + Water Shutoff + + + Utility Notes + + + Water Supply + + + Nearest Hydrant + + + Required Fire Flow (GPM) + + + Water Supply Notes + + + Access + + + Knox Box Location + + + Gate Code + + + Stored encrypted. Shown to members who can view this contact and to responders on linked calls. + + + Alarm Panel Location + + + Alarm Company + + + Phone + + + Access Notes + + + On-Site Contacts + + + Emergency Contact + + + Secondary Contact + + + Hazards & Tactics + + + Hazmat On Site + + + Hazardous materials are stored or used on site + + + General Hazard Notes + + + Tactical Summary + + + Review Cycle + + + Last Reviewed + + + Never reviewed + + + Mark this pre-plan as reviewed now + + + Next Review Due + + + Save Pre-Plan + + + Premise Hazards + + + Typed hazards with a severity. Hazards marked as alerts are shown to dispatchers when this contact is added to a call. + + + Add Hazard + + + Edit Hazard + + + Save Hazard + + + Remove this hazard? + + + The hazard could not be saved. + + + A hazard needs a title. + + + Alert dispatchers when this contact is added to a call + + + Severity + + + Title + + + Location + + + Alert + + + No premise hazards recorded. + + + GPS Coordinates + + + The pre-plan review date has passed. + + + Pre-Plan Review Due + + + Pre-plans for this department are now maintained as Records occupancies. This page is read-only; edit the occupancy under Records. + + + Open occupancy + + + Create Pre-Plan + + + Edit Pre-Plan + + + No pre-incident plan has been recorded for this contact. + + + No files have been attached to this contact. + + + Upload File + + + Images, PDF, Office documents, text, CSV, DWG or DXF up to 30MB. + + + File Type + + + Title + + + Optional title (defaults to the file name) + + + File Name + + + Upload + + + Remove this file? + + + Download + + + Size + + + Added On + + + No + + + Calls + + + Notes + + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.es.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.es.resx index fed0f1197..55cbff582 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.es.resx @@ -434,4 +434,10 @@ Editar contacto + + Los planes previos de este departamento ahora se mantienen como ocupaciones en Registros. Esta página es de solo lectura; edite la ocupación en Registros. + + + Abrir ocupación + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.fr.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.fr.resx index b47498b53..f1e30310b 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.fr.resx @@ -434,4 +434,10 @@ Edit Contact + + Les plans d'intervention de ce service sont désormais gérés comme des occupations dans les Dossiers. Cette page est en lecture seule ; modifiez l'occupation dans les Dossiers. + + + Ouvrir l'occupation + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.it.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.it.resx index ecb748ab8..3cc366e7a 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.it.resx @@ -434,4 +434,10 @@ Edit Contact + + I piani preventivi di questo dipartimento sono ora gestiti come occupazioni nei Registri. Questa pagina è di sola lettura; modifica l'occupazione nei Registri. + + + Apri occupazione + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.pl.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.pl.resx index 280181684..620ba6858 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.pl.resx @@ -434,4 +434,10 @@ Edit Contact + + Plany operacyjne tej jednostki są teraz prowadzone jako obiekty w Rejestrach. Ta strona jest tylko do odczytu; edytuj obiekt w Rejestrach. + + + Otwórz obiekt + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.sv.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.sv.resx index 91151d994..56c406716 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.sv.resx @@ -434,4 +434,10 @@ Edit Contact + + Insatsplaner för den här organisationen underhålls nu som objekt i Register. Sidan är skrivskyddad; redigera objektet under Register. + + + Öppna objekt + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.uk.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.uk.resx index 7c2f346d0..8683492fc 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.uk.resx @@ -434,4 +434,10 @@ Edit Contact + + Плани реагування цього підрозділу тепер ведуться як об'єкти в Записах. Ця сторінка лише для читання; редагуйте об'єкт у Записах. + + + Відкрити об'єкт + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx index d37f729e6..f634ef8db 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx @@ -751,4 +751,25 @@ Incident report + + Site Info + + + No contacts with site information are linked to this call. + + + Premise Hazard + + + Premise Hazards + + + Alert Notes + + + No pre-incident plan has been recorded for this contact. + + + Site Files + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index c58f534f9..fc08b2091 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -1123,6 +1123,11 @@ تعيين الرقم تسلسل لكل محطة تسلسل لكل حادث + السجلات لكل نداء + عدة سجلات لكل نداء + سجل معتمد واحد لكل نداء + سجل واحد لكل موضوع لكل نداء + كم سجلاً من هذا التعريف يمكن أن يحمله النداء الواحد. يُطبَّق عند الإنشاء؛ والسجل المُلغى أو المتروك يحرّر مكانه. إعادة تعيين سنويًا التصنيف الاحتفاظ (سنوات) @@ -1418,4 +1423,414 @@ حالة المصدر متقدمة على التعيين المحلي حالة المصدر متأخرة عن التعيين المحلي يُدار بواسطة موصل + سجل الوصول + ملاحظات الوصول + إضافة دليل + إضافة خطر + إضافة عضو + إضافة ملاحظة + إضافة إحالة + إضافة مخالفة + الجهة + شركة الإنذار + لوحة الإنذار + جميع البرامج + التعديلات الموصى بها + مقدم الطلب + بريد مقدم الطلب + هاتف مقدم الطلب + تاريخ التقديم + تقديم الطلب + اعتماد النتائج + يجب أن يعتمد النتائج مراجع أو مسؤول غير كاتبها. + تمت إضافة المرفق. + اختر ملفًا لإرفاقه. + تمت إزالة المرفق. + الحضور + حالات رفض التفويض + متوسط الدرجة + حسب الكاتب + حسب المعيار + حسب التعريف + حسب الوحدة + إلغاء التفتيش + إلغاء هذا التفتيش؟ يبقى السجل في السجل التاريخي. + تم ربط المرشح. + تم رفض المرشح. + المرشحون + الوصول إلى هذه القضية مقصور على أعضائها وتُسجل كل قراءة. + تم إغلاق القضية. + أعضاء القضية + ملاحظات القضية + تم فتح القضية {0}. + أعيد فتح القضية. + تم حفظ القضية. + إصدار الفهرس + تصنيف السبب + تفاصيل السبب + قائمة التحقق + عنصر واحد في كل سطر: المفتاح | النص | إلزامي (y/n) | معرّف بند القانون. + بند التحقق + إغلاق القضية + إغلاق هذه القضية؟ تصبح الملاحظات للقراءة فقط. + إغلاق التفتيش + سبب الإغلاق + الإصدار + تم حفظ بند القانون. + نص البند + بنود القانون + تم حفظ مجموعة القوانين. + مجموعات القوانين + قوانين الحريق والبناء التي تفتش الإدارة وفقًا لها. تحمل البنود درجة خطورة ومهلة تصحيح افتراضيتين للمخالفات. + جُمع من + تاريخ الجمع + التعليقات + إكمال التفتيش + الشروط + قالب الشروط + معرّف جهة الاتصال + تم ربط جهة الاتصال. + تم فك ربط جهة الاتصال. + الإجراء التصحيحي + إنشاء مبنى جديد من هذا المصدر + المعايير + معيار واحد في كل سطر: المفتاح | النص | الوزن. + الدرجة (0-4) + الوزن + يعثر الجرد على الخطط المسبقة وجهات الاتصال ذات العناوين ونقاط الاهتمام، ويجمعها حسب العنوان والقرب، ويقترح المباني. احسم كل مرشح قبل نقل ملكية الكتابة إلى السجلات. + الحد من مخاطر المجتمع + تم حذف النشاط. + تم حفظ النشاط. + ملخص الفترة + الحافظ + سلسلة الحيازة + عمليات النقل تُضاف فقط؛ يذكر كل تسليم من سلّم ومن استلم والسبب والحالة الناتجة. + تم نقل الحيازة. + سبب القرار + أيام التصحيح + الخطورة الافتراضية + حذف النشاط + حذف هذا النشاط؟ + حذف الصنبور + حذف هذا الصنبور؟ ستُحذف معه اختبارات التدفق وسجل الصيانة. + حذف المبنى + حذف هذا المبنى؟ تحتفظ عمليات التفتيش والتصاريح والمخاطر بسجلها لكنها تفقد العنصر الأصل. + موعد الاستحقاق + تعديل الصنبور + تعديل المبنى + تعديل التصريح + ساري من + قاطع الكهرباء + جهة اتصال الطوارئ + تمت إضافة الدليل. + الأدلة + رقم الدليل + تاريخ الانتهاء + اتركه فارغًا لاستخدام مدة صلاحية نوع التصريح من تاريخ الإصدار. + ينتهي قريبًا + تصدير الحزمة + غير مطابق + موقع وصلة الإطفاء + الرسوم + للإشارة فقط؛ تُسجل الفاتورة هنا ولا تُحصّل. + تاريخ سداد الرسوم + تم تسجيل الرسوم. + الملف + النتائج + تم اعتماد النتائج. + معتمدة + بانتظار الاعتماد، سُجلت + تم تسجيل النتائج وإرسالها للاعتماد. + أُعيدت النتائج إلى كاتبها. + إنذار الحريق + فئة التدفق + التدفق (غالون/دقيقة) + تحدّث اختبارات التدفق المسجلة هذه القيمة وفئة NFPA 291 تلقائيًا. + يُحسب التدفق كـ 29.83 × c × d² × √pitot ويُقرّب إلى 10 غالون/دقيقة؛ الفئة وفق NFPA 291. + تم تسجيل اختبار التدفق. + اختبارات التدفق + التكرار (بالأشهر) + من + قاطع الغاز + رمز البوابة + ملاحظات عامة عن المخاطر + إنشاء عمليات التفتيش المستحقة + تم إنشاء {0} عملية تفتيش. + تمت إزالة الخطر. + تم حفظ الخطر. + المخاطر + مواد خطرة في الموقع + الساعات + {0} صنبورًا، {1} خارج الخدمة، {2} يستحق اختبار تدفق. + تم حذف الصنبور. + الخريطة + يتبع اللون فئة التدفق NFPA 291؛ الأسود يعني خارج الخدمة. + رقم الصنبور + المالك + تم حفظ الصنبور. + صنابير الإطفاء + استيراد الصنابير + ملف CSV بصف عناوين: number, latitude, longitude, type, address, main_size, flow_gpm, owner. تُحدّث الأرقام الموجودة. + صفوف لم تُستورد + تمت قراءة {0} صفًا، أُنشئ {1}، حُدّث {2}، رُفض {3}. + استيراد البنود + ملف CSV بصف عناوين: section, title, text, severity (1-4), correction_days. + في الخدمة + تم ربط الحادث؛ المراجعة الحالية مثبتة. + ملخص الحادث + تم فك ربط الحادث. + تضمين المغلقة + تم إلغاء التفتيش. + تم إغلاق التفتيش. + اكتمل التفتيش؛ أنشأت البنود غير المطابقة مخالفات. + الملاحظات + رقم التفتيش + برامج التفتيش + يربط البرنامج قائمة تحقق بتكرار وأنواع المباني التي ينطبق عليها؛ وتُنشأ منه عمليات التفتيش المستحقة. + النتيجة + تمت جدولة التفتيش. + بدأ التفتيش. + عمليات التفتيش + المفتش + تاريخ الجرد + تم فحص {0} مصدرًا، أُنشئ {1} مرشحًا، حُدّث {2}، {3} محسومة مسبقًا. + التحقيقات + تُعرض فقط القضايا التي أنت عضو فيها. تُسجل كل قراءة لقضية في سجل الوصول. + مرجع الفاتورة + إصدار إشعار + تاريخ الإصدار + ملاحظة + الاختصاص + صندوق المفاتيح + آخر تفتيش + آخر صيانة + آخر مراجعة + آخر اختبار + المحقق الرئيسي + محاولات الكتابة في النظام القديم + ربط + ربط جهة اتصال + ربط حادث + جهات الاتصال المرتبطة + مرتبط + الحوادث المرتبطة + يثبّت الربط مراجعة الحادث في تلك اللحظة؛ لا تغيّر النتائج تقرير الحادث أبدًا. + قطر الأنبوب (بوصة) + سجل الصيانة + تم تسجيل الصيانة. + وضع علامة تمت المراجعة + درجة ثقة المطابقة + تمت إضافة العضو. + تمت إزالة العضو. + دمج هذا المبنى في المبنى الهدف؟ تنتقل المخاطر والروابط والسجل؛ ويصبح هذا السجل مؤشرًا. + دمج في + معرّف المبنى الهدف + أقرب صنبور + مجموعة قوانين جديدة + نشاط جديد + صنبور جديد + مبنى جديد + تصريح جديد + نوع تصريح جديد + برنامج جديد + معيار تقييم جديد + لا تنبيهات أو تحذيرات في هذه الفترة. + لا مرفقات. + لا مرشحين بانتظار القرار. شغّل الجرد للعثور على خطط مسبقة وجهات اتصال للمطابقة. + لا قضايا. + لا يحتوي هذا البرنامج على بنود تحقق. + لا أنشطة في هذه الفترة. + لا اختبارات تدفق مسجلة. + لا مخاطر مسجلة. + لا صنابير بعد. + لا عمليات تفتيش. + لا جهات اتصال مرتبطة. + لا صيانة مسجلة. + لا مبانٍ مطابقة. + لا مراجعات بانتظار التقييم. + لا تصاريح مطابقة. + لا دورات مراجعة مخططات بعد. + لا مراجعات مقيّمة في هذه الفترة. + لا معايير تقييم بعد. ينشئ مدير الإدارة المعيار الأول. + لا مخالفات. + غير منطبق + نص الملاحظة + تم حفظ الملاحظة. + الموضوع + تم إصدار الإشعار. + مرجع الإشعار + المباني + الوصول + {0} مبنى + تم حذف المبنى. + ساعات الإشغال + الهوية والموقع + تم دمج المبنى. + تم دمج هذا المبنى في + رقم المبنى + الحماية من الحريق والمرافق + تم وضع علامة تمت المراجعة على المبنى. + تم حفظ المبنى. + ابحث بالرقم أو الاسم أو العنوان + الهيكل + المخاطر والتكتيكات + نوع المبنى + ينطبق على أنواع المباني + أرقام الأنواع مفصولة بفواصل؛ الفراغ يعني كل الأنواع. + الحمل الإشغالي + شاغلون يحتاجون إلى مساعدة + فتح قضية + ستصبح المحقق الرئيسي. أضف الأعضاء بعد الفتح؛ الأعضاء فقط يمكنهم قراءة القضية. + المخالفات المفتوحة + تاريخ الفتح + وصف نقطة الاندلاع + خارج الخدمة + سبب الخروج من الخدمة + أحداث فاشلة + صندوق الأحداث الصادرة + الأقدم قيد الانتظار + أحداث قيد الانتظار + النتيجة + المخرج (بوصة) + اسم المالك + لا تزال الخطط المسبقة تُكتب ضمن جهات الاتصال. طابق جدول الربط لنقل ملكية الكتابة إلى المباني. + مباني السجلات تملك الخطط المسبقة. تقرأ صفحات جهات الاتصال والتطبيقات الإسقاطات من هنا. + تم نقل ملكية الكتابة إلى مباني السجلات. + رقم القطعة + مطابق + مراجعات للتقييم + تاريخ التنفيذ + الرمز + رقم التصريح + تم حفظ التصريح. + تم تحديث حالة التصريح. + نوع التصريح + تم حفظ نوع التصريح. + أنواع التصاريح + التصاريح + مراجعة مثبتة + بيتو (psi) + نتيجة المراجعة + تم تسجيل مراجعة المخططات. + مراجعة المخططات + معرّف نقطة الاهتمام + مطابقة الخطط المسبقة + ملكية كتابة الخطط المسبقة + انقضى موعد مراجعة هذا المبنى. + الوقاية + تم حفظ البرنامج. + المحتوى المحمي مخفي؛ اكشفه قبل هذا الإجراء. + حماية البيانات مفعّلة + مراجعة الجودة + يختار الاعتيان السجلات المكتملة بشكل حتمي لكل معيار؛ التقييم مرجّح ويغذي الاتجاهات. + التوصية بتعديل + يوصي بتعديل تقرير الحادث + لا تعدّل النتائج الحادث أبدًا. تُمرر التوصية عبر مسار تعديل السجل. + تسجيل الرسوم + تسجيل النتائج + تسجيل اختبار التدفق + تسجيل الصيانة + تسجيل مراجعة المخططات + سجّله + السجلات المنشأة + السجلات المكتملة + صحة الإصدار + قياسات أساسية لتفعيل السجلات: محاولات الكتابة في النظام القديم، تأخر صندوق الصادر، تشغيلات Workflow، الفحوص وعدادات الوقاية. يتحول كل مصدر إلى تحذير بدلاً من إفشال الصفحة. + السجلات مفعّلة + سجلات متأخرة عن المراجعة + الرقم المرجعي + تمت إضافة الإحالة. + تم تحديث الإحالة. + الإحالات + إعادة تفتيش لـ + رفض + إزالة هذا المرفق؟ + إعادة فتح القضية + التدفق المطلوب (غالون/دقيقة) + يتطلب مراجعة المخططات + المتبقي (psi) + الحالة الناتجة + إعادة إلى الكاتب + أُعيد إلى الخدمة + المراجعات + الدورة + ملاحظة المراجع + ملاحظات المراجعة + مراجعة متأخرة + تم تقييم المراجعة. + تاريخ المراجعة + نوع السقف + تم حفظ معيار التقييم. + معايير التقييم + تشغيل الجرد + اعتيان + مكتملة + فاشلة + قيد التشغيل + متخطاة + تم اعتيان {0} سجلًا للمراجعة. + حجم العينة + عدد السجلات لكل عملية اعتيان؛ الاختيار حتمي للمعيار والفترة. + المعتانة + تاريخ الاعتيان + مرفقات بانتظار الفحص + مرفقات رفضها الفحص + الفحص + جدولة تفتيش + جدولة إعادة تفتيش + مجدولة + قيّم كل معيار من 0 إلى 4؛ يتحول المجموع المرجّح إلى نسبة مئوية. + تقييم المراجعة + المقيّمة + تاريخ التقييم + البند + تم استيراد {0} بندًا. + إعادة إلى الخدمة + إخراج من الخدمة + تنبيه الأطقم + اسم الموقّع + الاسم المكتوب للشخص الذي يشهد على التفتيش + كاشفات الدخان المركّبة + المرشات + قدم مربع + الطاقم + معرّفات المستخدمين مفصولة بفواصل للأعضاء المشاركين. + أنبوب قائم + بدء التفتيش + بدأت + الساكن (psi) + موقع التخزين + الطوابق + إرسالات قيد الانتظار + إرسالات فاشلة + تاريخ التقديم + المبنى المقترح + التبديل محظور حتى يُشغّل الجرد ويُحسم كل مرشح ولا تبقى خطة مسبقة غير مطابقة. + نقل ملكية الكتابة إلى السجلات + أفهم أن صفحات الخطط المسبقة في جهات الاتصال والتطبيقات ستصبح إسقاطات للقراءة فقط من المباني. + نقل ملكية الكتابة الآن؟ يتوقف تحرير الخطط المسبقة في جهات الاتصال فورًا. + بعد التبديل، تُحرر الخطط المسبقة كمبانٍ فقط. تستمر جهات الاتصال والتطبيقات في العمل من الإسقاطات؛ التراجع هو استعادة لقاعدة البيانات وليس مفتاحًا. + تاريخ التبديل + ملخص تكتيكي + النافذة الزمنية + اختبار مستحق + تاريخ الاختبار + نقل الحيازة + إلى + إلى جهة خارجية + تاريخ النقل + تغيير الحالة + الاتجاهات + منذ + فك الربط + خطط مسبقة غير مطابقة + تاريخ الرفع + ملاحظات المرافق + الصلاحية (أيام) + تم حفظ المخالفة. + تم تحديث المخالفة. + المخالفات + قاطع الماء + ملاحظات إمداد المياه + تشغيلات Workflow diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index 2083ab896..8c69e9f72 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -1123,6 +1123,11 @@ Nummer vergeben Sequenz pro Wache Sequenz pro Einsatz + Datensätze pro Einsatz + Mehrere pro Einsatz + Ein maßgeblicher pro Einsatz + Einer je Subjekt pro Einsatz + Wie viele Datensätze dieser Definition ein Einsatz haben darf. Beim Anlegen erzwungen; ein für ungültig erklärter oder abgebrochener Datensatz gibt seinen Platz frei. Jährlich zurücksetzen Klassifizierung Aufbewahrung (Jahre) @@ -1418,4 +1423,414 @@ Quellstatus ist der lokalen Besetzung voraus Quellstatus liegt hinter der lokalen Besetzung Von einem Konnektor gepflegt + Zugriffsprotokoll + Zugangshinweise + Beweismittel hinzufügen + Gefahr hinzufügen + Mitglied hinzufügen + Notiz hinzufügen + Weiterleitung hinzufügen + Verstoß hinzufügen + Behörde + Alarmfirma + Alarmzentrale + Alle Programme + Empfohlene Änderungen + Antragsteller + E-Mail des Antragstellers + Telefon des Antragstellers + Beantragt am + Antrag einreichen + Ergebnisse genehmigen + Ergebnisse müssen von einem Prüfer oder Leiter genehmigt werden, der nicht der Verfasser ist. + Anhang hinzugefügt. + Wählen Sie eine Datei zum Anhängen. + Anhang entfernt. + Teilnehmer + Abgelehnte Zugriffe + Durchschnittliche Punktzahl + Nach Verfasser + Nach Kriterium + Nach Definition + Nach Einheit + Prüfung abbrechen + Diese Prüfung abbrechen? Der Eintrag bleibt in der Historie. + Kandidat verknüpft. + Kandidat abgelehnt. + Kandidaten + Der Zugriff auf diesen Fall ist auf seine Mitglieder beschränkt; jeder Lesezugriff wird protokolliert. + Fall geschlossen. + Fallmitglieder + Fallnotizen + Fall {0} eröffnet. + Fall wieder eröffnet. + Fall gespeichert. + Katalogversion + Ursachenklassifikation + Ursachendetail + Checkliste + Ein Punkt pro Zeile: Schlüssel | Text | Pflicht (y/n) | ID des Vorschriftenabschnitts. + Prüfpunkt + Fall schließen + Diesen Fall schließen? Notizen werden schreibgeschützt. + Prüfung abschließen + Grund der Schließung + Ausgabe + Vorschriftenabschnitt gespeichert. + Abschnittstext + Vorschriftenabschnitte + Vorschriftensatz gespeichert. + Vorschriftensätze + Brandschutz- und Bauvorschriften, gegen die die Organisation prüft. Abschnitte tragen Standard-Schweregrad und Behebungsfrist für Verstöße. + Sichergestellt bei + Sichergestellt am + Kommentare + Prüfung abschließen und bewerten + Auflagen + Auflagenvorlage + Kontakt-ID + Kontakt verknüpft. + Kontaktverknüpfung entfernt. + Korrekturmaßnahme + Neues Objekt aus dieser Quelle anlegen + Kriterien + Ein Kriterium pro Zeile: Schlüssel | Text | Gewichtung. + Punkte (0-4) + Gewichtung + Die Bestandsaufnahme findet Einsatzpläne, Kontakte mit Adresse und POIs, gruppiert sie nach Adresse und Nähe und schlägt Objekte vor. Entscheiden Sie jeden Kandidaten, bevor Sie die Schreibhoheit an die Berichte übergeben. + Risikominderung in der Gemeinde + Aktivität gelöscht. + Aktivität gespeichert. + Zusammenfassung des Zeitraums + Verwahrer + Verwahrungskette + Übergaben sind nur anfügbar; jede Übergabe nennt Abgebenden, Empfänger, Grund und den resultierenden Zustand. + Verwahrung übergeben. + Entscheidungsgrund + Behebungsfrist (Tage) + Standard-Schweregrad + Aktivität löschen + Diese Aktivität löschen? + Hydrant löschen + Diesen Hydranten löschen? Durchflusstests und Wartungshistorie gehen mit. + Objekt löschen + Dieses Objekt löschen? Prüfungen, Genehmigungen und Gefahren behalten ihre Historie, verlieren aber das übergeordnete Objekt. + Fällig am + Hydrant bearbeiten + Objekt bearbeiten + Genehmigung bearbeiten + Gültig ab + Stromabschaltung + Notfallkontakt + Beweismittel hinzugefügt. + Beweismittel + Beweismittel-Nr. + Läuft ab am + Leer lassen, um die Gültigkeit des Genehmigungstyps ab Ausstellung zu verwenden. + Läuft bald ab + Fallpaket exportieren + Nicht bestanden + Standort Einspeisung (FDC) + Gebühr + Nur Referenz; die Rechnung wird hier erfasst, nicht eingezogen. + Gebühr bezahlt am + Gebühr erfasst. + Datei + Ergebnisse + Ergebnisse genehmigt. + Genehmigt + Warten auf Genehmigung, erfasst + Ergebnisse erfasst und zur Genehmigung gesendet. + Ergebnisse an den Verfasser zurückgegeben. + Brandmeldeanlage + Durchflussklasse + Durchfluss (gpm) + Erfasste Durchflusstests aktualisieren diesen Wert und die NFPA-291-Klasse automatisch. + Der Durchfluss wird als 29,83 × c × d² × √Pitot berechnet und auf 10 gpm gerundet; die Klasse folgt NFPA 291. + Durchflusstest erfasst. + Durchflusstests + Intervall (Monate) + Von + Gasabsperrung + Torcode + Allgemeine Gefahrenhinweise + Fällige Prüfungen erzeugen + {0} Prüfungen erzeugt. + Gefahr entfernt. + Gefahr gespeichert. + Gefahren + Gefahrstoffe vor Ort + Stunden + {0} Hydranten, {1} außer Betrieb, {2} mit fälligem Durchflusstest. + Hydrant gelöscht. + Karte + Die Farbe folgt der NFPA-291-Durchflussklasse; Schwarz bedeutet außer Betrieb. + Hydrant-Nr. + Eigentümer + Hydrant gespeichert. + Hydranten + Hydranten importieren + CSV mit Kopfzeile: number, latitude, longitude, type, address, main_size, flow_gpm, owner. Vorhandene Nummern werden aktualisiert. + Nicht importierte Zeilen + {0} Zeilen gelesen, {1} angelegt, {2} aktualisiert, {3} abgelehnt. + Abschnitte importieren + CSV mit Kopfzeile: section, title, text, severity (1-4), correction_days. + In Betrieb + Einsatz verknüpft; die aktuelle Revision ist fixiert. + Einsatzzusammenfassung + Einsatzverknüpfung entfernt. + Geschlossene einbeziehen + Prüfung abgebrochen. + Prüfung abgeschlossen. + Prüfung abgeschlossen; nicht bestandene Punkte wurden zu Verstößen. + Notizen + Prüfungs-Nr. + Prüfprogramme + Ein Programm verbindet eine Checkliste mit einem Intervall und den Objektarten, für die es gilt; fällige Prüfungen werden daraus erzeugt. + Ergebnis + Prüfung geplant. + Prüfung begonnen. + Prüfungen + Prüfer + Bestandsaufnahme am + {0} Quellen geprüft, {1} Kandidaten angelegt, {2} aktualisiert, {3} bereits entschieden. + Ermittlungen + Es werden nur Fälle angezeigt, in denen Sie Mitglied sind. Jeder Lesezugriff auf einen Fall wird protokolliert. + Rechnungsreferenz + Bescheid ausstellen + Ausgestellt am + Notiz + Zuständigkeitsbereich + Schlüsseldepot + Zuletzt geprüft + Zuletzt gewartet + Zuletzt überprüft + Zuletzt getestet + Leitender Ermittler + Schreibversuche auf Altsystem + Verknüpfen + Kontakt verknüpfen + Einsatz verknüpfen + Verknüpfte Kontakte + Verknüpft + Verknüpfte Einsätze + Die Verknüpfung fixiert die Einsatzrevision zu diesem Zeitpunkt; Ergebnisse ändern den Einsatzbericht nie. + Leitungsdurchmesser (Zoll) + Wartungsprotokoll + Wartung erfasst. + Als überprüft markieren + Übereinstimmung + Mitglied hinzugefügt. + Mitglied entfernt. + Dieses Objekt in das Ziel zusammenführen? Gefahren, Verknüpfungen und Historie werden übertragen; dieser Eintrag wird zum Verweis. + Zusammenführen in + ID des Zielobjekts + Nächster Hydrant + Neuer Vorschriftensatz + Neue Aktivität + Neuer Hydrant + Neues Objekt + Neue Genehmigung + Neuer Genehmigungstyp + Neues Programm + Neue Bewertungsvorlage + Keine Alarme oder Warnungen in diesem Zeitfenster. + Keine Anhänge. + Keine wartenden Kandidaten. Starten Sie die Bestandsaufnahme, um Einsatzpläne und Kontakte zum Abgleich zu finden. + Keine Fälle. + Dieses Programm hat keine Prüfpunkte. + Keine Aktivitäten in diesem Zeitraum. + Keine Durchflusstests erfasst. + Keine Gefahren erfasst. + Noch keine Hydranten. + Keine Prüfungen. + Keine verknüpften Kontakte. + Keine Wartung erfasst. + Keine passenden Objekte. + Keine Bewertungen ausstehend. + Keine passenden Genehmigungen. + Noch keine Planprüfungszyklen. + Keine bewerteten Prüfungen in diesem Zeitraum. + Noch keine Bewertungsvorlagen. Ein Administrator der Organisation legt die erste an. + Keine Verstöße. + k. A. + Notiztext + Notiz gespeichert. + Betreff + Bescheid ausgestellt. + Bescheid-Referenz + Objekte + Zugang + {0} Objekte + Objekt gelöscht. + Nutzungszeiten + Identität und Lage + Objekt zusammengeführt. + Dieses Objekt wurde zusammengeführt in + Objekt-Nr. + Brandschutz und Versorgung + Objekt als überprüft markiert. + Objekt gespeichert. + Nummer, Name oder Adresse suchen + Gebäude + Gefahren und Taktik + Nutzungsart + Gilt für Nutzungsarten + Typnummern durch Komma getrennt; leer bedeutet alle Typen. + Personenzahl + Hilfsbedürftige Personen + Fall eröffnen + Sie werden leitender Ermittler. Fügen Sie nach dem Eröffnen Mitglieder hinzu; nur Mitglieder können den Fall lesen. + Offene Verstöße + Eröffnet am + Beschreibung des Brandursprungs + Außer Betrieb + Grund der Außerbetriebnahme + Fehlgeschlagene Ereignisse + Ereignis-Postausgang + Ältestes ausstehendes + Ausstehende Ereignisse + Ergebnis + Auslass (Zoll) + Name des Eigentümers + Einsatzpläne werden weiterhin unter Kontakte gepflegt. Gleichen Sie den Abgleich ab, um die Schreibhoheit auf Objekte umzustellen. + Objekte in den Berichten besitzen die Einsatzpläne. Kontaktseiten und Apps lesen Projektionen von hier. + Schreibhoheit auf Objekte in den Berichten umgestellt. + Flurstücknummer + Bestanden + Zu bewertende Prüfungen + Durchgeführt am + Kürzel + Genehmigungs-Nr. + Genehmigung gespeichert. + Genehmigungsstatus aktualisiert. + Genehmigungstyp + Genehmigungstyp gespeichert. + Genehmigungstypen + Genehmigungen + fixierte Revision + Pitot (psi) + Ergebnis der Prüfung + Planprüfung erfasst. + Planprüfung + POI-ID + Einsatzplan-Abgleich + Schreibhoheit für Einsatzpläne + Das Überprüfungsdatum dieses Objekts ist überschritten. + Prävention + Programm gespeichert. + Geschützte Inhalte sind verborgen; machen Sie sie vor dieser Aktion sichtbar. + Datenschutz erzwungen + Qualitätsprüfung + Die Stichprobe wählt abgeschlossene Berichte je Vorlage deterministisch aus; die Bewertung ist gewichtet und fließt in die Trends ein. + Änderung empfehlen + Empfiehlt Änderung des Einsatzberichts + Ergebnisse ändern den Einsatz nie. Die Empfehlung läuft über den Änderungsprozess des Berichts. + Gebühr erfassen + Ergebnisse erfassen + Durchflusstest erfassen + Wartung erfassen + Planprüfung erfassen + Erfasst von + Angelegte Berichte + Abgeschlossene Berichte + Betriebszustand + Basistelemetrie für die Aktivierung der Berichte: Schreibversuche auf das Altsystem, Postausgangsverzögerung, Workflow-Läufe, Scans und Präventionszähler. Jede Quelle fällt auf eine Warnung zurück, statt die Seite scheitern zu lassen. + Berichte aktiviert + Überfällige Prüfungen + Referenznummer + Weiterleitung hinzugefügt. + Weiterleitung aktualisiert. + Weiterleitungen + Nachprüfung von + Ablehnen + Diesen Anhang entfernen? + Fall wieder eröffnen + Erforderlicher Löschwasserbedarf (gpm) + Erfordert Planprüfung + Restdruck (psi) + Resultierender Zustand + An Verfasser zurückgeben + Wieder in Betrieb + Bewertungen + Zyklus + Anmerkung des Prüfers + Prüfnotizen + Überprüfung überfällig + Bewertung gespeichert. + Geprüft am + Dachart + Bewertungsvorlage gespeichert. + Bewertungsvorlagen + Bestandsaufnahme starten + Stichprobe + Abgeschlossen + Fehlgeschlagen + Laufend + Übersprungen + {0} Berichte für die Bewertung ausgewählt. + Stichprobengröße + Berichte je Stichprobenlauf; die Auswahl ist je Vorlage und Zeitraum deterministisch. + Ausgewählt + Ausgewählt am + Anhänge mit ausstehendem Scan + Vom Scan abgelehnte Anhänge + Scan + Prüfung planen + Nachprüfung planen + Geplant + Bewerten Sie jedes Kriterium mit 0-4; die gewichtete Summe ergibt einen Prozentwert. + Bewertung vornehmen + Bewertet + Bewertet am + Abschnitt + {0} Abschnitte importiert. + Wieder in Betrieb nehmen + Außer Betrieb nehmen + Einsatzkräfte warnen + Name der Unterschrift + Eingegebener Name der Person, die die Prüfung bestätigt + Installierte Rauchmelder + Sprinkleranlage + Quadratfuß + Mitarbeitende + Durch Komma getrennte Benutzer-IDs der beteiligten Mitglieder. + Steigleitung + Prüfung beginnen + Begonnen + Statisch (psi) + Aufbewahrungsort + Stockwerke + Ausstehende Übermittlungen + Fehlgeschlagene Übermittlungen + Eingereicht am + Vorgeschlagenes Objekt + Die Umstellung ist blockiert, bis die Bestandsaufnahme gelaufen ist, jeder Kandidat entschieden wurde und kein Einsatzplan mehr unabgeglichen ist. + Schreibhoheit an Berichte übergeben + Ich verstehe, dass Einsatzplanseiten in Kontakte und die Apps zu schreibgeschützten Projektionen der Objekte werden. + Schreibhoheit jetzt umstellen? Die Bearbeitung von Einsatzplänen in Kontakte endet sofort. + Nach der Umstellung werden Einsatzpläne nur noch als Objekte bearbeitet. Kontakte und Apps arbeiten weiter mit Projektionen; ein Rollback ist eine Datenbankwiederherstellung, kein Schalter. + Umgestellt am + Taktische Zusammenfassung + Zeitfenster + Test fällig + Getestet am + Verwahrung übergeben + An + An externe Stelle + Übergeben am + Status ändern + Trends + Seit + Verknüpfung lösen + Unabgeglichene Einsatzpläne + Hochgeladen + Versorgungshinweise + Gültigkeit (Tage) + Verstoß gespeichert. + Verstoß aktualisiert. + Verstöße + Wasserabsperrung + Hinweise zur Löschwasserversorgung + Workflow-Läufe diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index ff4167655..686aca8c6 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -1123,6 +1123,11 @@ Ανάθεση αριθμού Ακολουθία ανά σταθμό Ακολουθία ανά συμβάν + Εγγραφές ανά κλήση + Πολλές ανά κλήση + Μία έγκυρη ανά κλήση + Μία ανά υποκείμενο ανά κλήση + Πόσες εγγραφές αυτού του ορισμού μπορεί να έχει μία κλήση. Επιβάλλεται κατά τη δημιουργία· μια ακυρωμένη ή εγκαταλελειμμένη εγγραφή απελευθερώνει τη θέση της. Επαναφορά ετησίως Ταξινόμηση Διατήρηση (έτη) @@ -1418,4 +1423,414 @@ Η κατάσταση της πηγής προηγείται της τοπικής κάλυψης Η κατάσταση της πηγής υστερεί της τοπικής κάλυψης Συντηρείται από σύνδεσμο + Έλεγχος πρόσβασης + Σημειώσεις πρόσβασης + Προσθήκη τεκμηρίου + Προσθήκη κινδύνου + Προσθήκη μέλους + Προσθήκη σημείωσης + Προσθήκη παραπομπής + Προσθήκη παράβασης + Υπηρεσία + Εταιρεία συναγερμού + Πίνακας συναγερμού + Όλα τα προγράμματα + Συνιστώμενες τροποποιήσεις + Αιτών + E-mail αιτούντος + Τηλέφωνο αιτούντος + Υποβλήθηκε + Υποβολή αίτησης + Έγκριση πορισμάτων + Τα πορίσματα πρέπει να εγκριθούν από ελεγκτή ή επικεφαλής διαφορετικό από τον συντάκτη τους. + Το συνημμένο προστέθηκε. + Επιλέξτε αρχείο για επισύναψη. + Το συνημμένο αφαιρέθηκε. + Κοινό + Απορρίψεις εξουσιοδότησης + Μέση βαθμολογία + Ανά συντάκτη + Ανά κριτήριο + Ανά ορισμό + Ανά μονάδα + Ακύρωση επιθεώρησης + Ακύρωση αυτής της επιθεώρησης; Η εγγραφή παραμένει στο ιστορικό. + Ο υποψήφιος συνδέθηκε. + Ο υποψήφιος απορρίφθηκε. + Υποψήφιοι + Η πρόσβαση στην υπόθεση περιορίζεται στα μέλη της και κάθε ανάγνωση καταγράφεται. + Η υπόθεση έκλεισε. + Μέλη υπόθεσης + Σημειώσεις υπόθεσης + Η υπόθεση {0} άνοιξε. + Η υπόθεση άνοιξε ξανά. + Η υπόθεση αποθηκεύτηκε. + Έκδοση καταλόγου + Ταξινόμηση αιτίας + Λεπτομέρεια αιτίας + Λίστα ελέγχου + Ένα στοιχείο ανά γραμμή: κλειδί | κείμενο | υποχρεωτικό (y/n) | id ενότητας κώδικα. + Στοιχείο ελέγχου + Κλείσιμο υπόθεσης + Κλείσιμο της υπόθεσης; Οι σημειώσεις γίνονται μόνο για ανάγνωση. + Κλείσιμο επιθεώρησης + Λόγος κλεισίματος + Έκδοση + Η ενότητα κώδικα αποθηκεύτηκε. + Κείμενο ενότητας + Ενότητες κώδικα + Το σύνολο κωδίκων αποθηκεύτηκε. + Σύνολα κωδίκων + Κώδικες πυρασφάλειας και δόμησης βάσει των οποίων επιθεωρεί η υπηρεσία. Οι ενότητες φέρουν προεπιλεγμένη σοβαρότητα και προθεσμία διόρθωσης. + Συλλέχθηκε από + Συλλέχθηκε στις + Σχόλια + Ολοκλήρωση επιθεώρησης + Όροι + Πρότυπο όρων + ID επαφής + Η επαφή συνδέθηκε. + Η επαφή αποσυνδέθηκε. + Διορθωτική ενέργεια + Δημιουργία νέου κτιρίου από αυτή την πηγή + Κριτήρια + Ένα κριτήριο ανά γραμμή: κλειδί | κείμενο | βάρος. + Βαθμός (0-4) + Βάρος + Η απογραφή εντοπίζει προσχέδια, επαφές με διεύθυνση και σημεία ενδιαφέροντος, τα ομαδοποιεί κατά διεύθυνση και εγγύτητα και προτείνει κτίρια. Αποφασίστε για κάθε υποψήφιο πριν μεταφέρετε την κυριότητα εγγραφής στα Αρχεία. + Μείωση κοινοτικού κινδύνου + Η δραστηριότητα διαγράφηκε. + Η δραστηριότητα αποθηκεύτηκε. + Σύνοψη περιόδου + Θεματοφύλακας + Αλυσίδα φύλαξης + Οι μεταβιβάσεις μόνο προστίθενται· κάθε παράδοση αναφέρει ποιος έδωσε, ποιος παρέλαβε, γιατί και την τελική κατάσταση. + Η φύλαξη μεταβιβάστηκε. + Αιτιολογία απόφασης + Ημέρες διόρθωσης + Προεπιλεγμένη σοβαρότητα + Διαγραφή δραστηριότητας + Διαγραφή αυτής της δραστηριότητας; + Διαγραφή κρουνού + Διαγραφή του κρουνού; Οι δοκιμές ροής και το ιστορικό συντήρησης χάνονται. + Διαγραφή κτιρίου + Διαγραφή του κτιρίου; Επιθεωρήσεις, άδειες και κίνδυνοι κρατούν το ιστορικό αλλά χάνουν τον γονέα. + Προθεσμία + Επεξεργασία κρουνού + Επεξεργασία κτιρίου + Επεξεργασία άδειας + Ισχύει από + Διακοπή ρεύματος + Επαφή έκτακτης ανάγκης + Το τεκμήριο προστέθηκε. + Τεκμήρια + Αρ. τεκμηρίου + Λήγει στις + Αφήστε κενό για να ισχύσει η διάρκεια του τύπου άδειας από την έκδοση. + Λήγει σύντομα + Εξαγωγή φακέλου + Απέτυχε + Θέση σύνδεσης πυροσβεστικής + Τέλος + Μόνο για αναφορά· η τιμολόγηση καταγράφεται εδώ, δεν εισπράττεται. + Τέλος πληρώθηκε στις + Το τέλος καταχωρήθηκε. + Αρχείο + Πορίσματα + Τα πορίσματα εγκρίθηκαν. + Εγκρίθηκαν + Αναμονή έγκρισης, καταχωρήθηκαν + Τα πορίσματα καταχωρήθηκαν και στάλθηκαν για έγκριση. + Τα πορίσματα επιστράφηκαν στον συντάκτη. + Συναγερμός πυρκαγιάς + Κλάση ροής + Ροή (gpm) + Οι καταχωρημένες δοκιμές ροής ενημερώνουν αυτόματα την τιμή και την κλάση NFPA 291. + Η ροή υπολογίζεται ως 29,83 × c × d² × √pitot και στρογγυλοποιείται στα 10 gpm· η κλάση ακολουθεί το NFPA 291. + Η δοκιμή ροής καταχωρήθηκε. + Δοκιμές ροής + Συχνότητα (μήνες) + Από + Διακοπή αερίου + Κωδικός πύλης + Γενικές σημειώσεις κινδύνων + Δημιουργία οφειλόμενων επιθεωρήσεων + Δημιουργήθηκαν {0} επιθεωρήσεις. + Ο κίνδυνος αφαιρέθηκε. + Ο κίνδυνος αποθηκεύτηκε. + Κίνδυνοι + Επικίνδυνα υλικά στον χώρο + Ώρες + {0} κρουνοί, {1} εκτός λειτουργίας, {2} με οφειλόμενη δοκιμή ροής. + Ο κρουνός διαγράφηκε. + Χάρτης + Το χρώμα ακολουθεί την κλάση ροής NFPA 291· το μαύρο σημαίνει εκτός λειτουργίας. + Αρ. κρουνού + Ιδιοκτήτης + Ο κρουνός αποθηκεύτηκε. + Πυροσβεστικοί κρουνοί + Εισαγωγή κρουνών + CSV με γραμμή κεφαλίδας: number, latitude, longitude, type, address, main_size, flow_gpm, owner. Οι υπάρχοντες αριθμοί ενημερώνονται. + Γραμμές που δεν εισήχθησαν + {0} γραμμές διαβάστηκαν, {1} δημιουργήθηκαν, {2} ενημερώθηκαν, {3} απορρίφθηκαν. + Εισαγωγή ενοτήτων + CSV με γραμμή κεφαλίδας: section, title, text, severity (1-4), correction_days. + Σε λειτουργία + Το συμβάν συνδέθηκε· η τρέχουσα αναθεώρηση καρφιτσώθηκε. + Σύνοψη συμβάντος + Το συμβάν αποσυνδέθηκε. + Συμπερίληψη κλειστών + Η επιθεώρηση ακυρώθηκε. + Η επιθεώρηση έκλεισε. + Η επιθεώρηση ολοκληρώθηκε· τα αποτυχημένα στοιχεία δημιούργησαν παραβάσεις. + Σημειώσεις + Αρ. επιθεώρησης + Προγράμματα επιθεώρησης + Ένα πρόγραμμα συνδυάζει λίστα ελέγχου με συχνότητα και τους τύπους κτιρίων που αφορά· από αυτό δημιουργούνται οι οφειλόμενες επιθεωρήσεις. + Αποτέλεσμα + Η επιθεώρηση προγραμματίστηκε. + Η επιθεώρηση ξεκίνησε. + Επιθεωρήσεις + Επιθεωρητής + Απογράφηκε στις + {0} πηγές σαρώθηκαν, {1} υποψήφιοι δημιουργήθηκαν, {2} ενημερώθηκαν, {3} ήδη αποφασισμένοι. + Έρευνες + Εμφανίζονται μόνο οι υποθέσεις στις οποίες είστε μέλος. Κάθε ανάγνωση υπόθεσης καταγράφεται στον έλεγχο πρόσβασης. + Αναφορά τιμολογίου + Έκδοση ειδοποίησης + Εκδόθηκε στις + Σημείωση + Δικαιοδοσία + Κλειδοθήκη + Τελευταία επιθεώρηση + Τελευταία συντήρηση + Τελευταία αναθεώρηση + Τελευταία δοκιμή + Επικεφαλής ερευνητής + Απόπειρες εγγραφής στο παλαιό σύστημα + Σύνδεση + Σύνδεση επαφής + Σύνδεση συμβάντος + Συνδεδεμένες επαφές + Συνδεδεμένα + Συνδεδεμένα συμβάντα + Η σύνδεση καρφιτσώνει την αναθεώρηση του συμβάντος εκείνη τη στιγμή· τα πορίσματα δεν αλλάζουν ποτέ την αναφορά. + Διάμετρος αγωγού (ίντσες) + Ημερολόγιο συντήρησης + Η συντήρηση καταχωρήθηκε. + Σήμανση ως αναθεωρημένο + Βεβαιότητα αντιστοίχισης + Το μέλος προστέθηκε. + Το μέλος αφαιρέθηκε. + Συγχώνευση του κτιρίου στον στόχο; Κίνδυνοι, σύνδεσμοι και ιστορικό μεταφέρονται· η εγγραφή γίνεται δείκτης. + Συγχώνευση σε + ID κτιρίου-στόχου + Πλησιέστερος κρουνός + Νέο σύνολο κωδίκων + Νέα δραστηριότητα + Νέος κρουνός + Νέο κτίριο + Νέα άδεια + Νέος τύπος άδειας + Νέο πρόγραμμα + Νέα κλίμακα + Καμία ειδοποίηση ή προειδοποίηση σε αυτό το διάστημα. + Χωρίς συνημμένα. + Δεν εκκρεμούν υποψήφιοι. Εκτελέστε την απογραφή για να βρείτε προσχέδια και επαφές προς συμφωνία. + Χωρίς υποθέσεις. + Το πρόγραμμα δεν έχει στοιχεία ελέγχου. + Καμία δραστηριότητα στην περίοδο. + Δεν έχουν καταχωρηθεί δοκιμές ροής. + Δεν έχουν καταχωρηθεί κίνδυνοι. + Δεν υπάρχουν κρουνοί ακόμη. + Χωρίς επιθεωρήσεις. + Χωρίς συνδεδεμένες επαφές. + Δεν έχει καταχωρηθεί συντήρηση. + Κανένα κτίριο δεν ταιριάζει. + Δεν εκκρεμούν αξιολογήσεις προς βαθμολόγηση. + Καμία άδεια δεν ταιριάζει. + Δεν υπάρχουν κύκλοι ελέγχου σχεδίων ακόμη. + Καμία βαθμολογημένη αξιολόγηση στην περίοδο. + Δεν υπάρχουν κλίμακες ακόμη. Ένας διαχειριστής της υπηρεσίας δημιουργεί την πρώτη. + Χωρίς παραβάσεις. + Δ/Ε + Κείμενο σημείωσης + Η σημείωση αποθηκεύτηκε. + Θέμα + Η ειδοποίηση εκδόθηκε. + Αναφορά ειδοποίησης + Κτίρια + Πρόσβαση + {0} κτίρια + Το κτίριο διαγράφηκε. + Ώρες χρήσης + Ταυτότητα και τοποθεσία + Το κτίριο συγχωνεύθηκε. + Το κτίριο συγχωνεύθηκε σε + Αρ. κτιρίου + Πυροπροστασία και δίκτυα + Το κτίριο σημάνθηκε ως αναθεωρημένο. + Το κτίριο αποθηκεύτηκε. + Αναζήτηση αριθμού, ονόματος ή διεύθυνσης + Κατασκευή + Κίνδυνοι και τακτική + Τύπος κτιρίου + Ισχύει για τύπους κτιρίων + Αριθμοί τύπων χωρισμένοι με κόμμα· κενό σημαίνει όλοι οι τύποι. + Χωρητικότητα + Ένοικοι που χρειάζονται βοήθεια + Άνοιγμα υπόθεσης + Γίνεστε επικεφαλής ερευνητής. Προσθέστε μέλη μετά το άνοιγμα· μόνο τα μέλη μπορούν να διαβάσουν την υπόθεση. + Ανοικτές παραβάσεις + Άνοιξε στις + Περιγραφή εστίας + Εκτός λειτουργίας + Λόγος εκτός λειτουργίας + Αποτυχημένα συμβάντα + Εξερχόμενα συμβάντα + Παλαιότερο σε εκκρεμότητα + Εκκρεμή συμβάντα + Έκβαση + Στόμιο (ίντσες) + Όνομα ιδιοκτήτη + Τα προσχέδια γράφονται ακόμη στις Επαφές. Συμφωνήστε την αντιστοίχιση για να μεταφέρετε την κυριότητα εγγραφής στα κτίρια. + Τα κτίρια των Αρχείων κατέχουν τα προσχέδια. Οι σελίδες Επαφών και οι εφαρμογές διαβάζουν προβολές από εδώ. + Η κυριότητα εγγραφής μεταφέρθηκε στα κτίρια των Αρχείων. + Αρ. οικοπέδου + Επιτυχία + Αξιολογήσεις προς βαθμολόγηση + Εκτελέστηκε στις + Κωδικός + Αρ. άδειας + Η άδεια αποθηκεύτηκε. + Η κατάσταση της άδειας ενημερώθηκε. + Τύπος άδειας + Ο τύπος άδειας αποθηκεύτηκε. + Τύποι αδειών + Άδειες + καρφιτσωμένη αναθεώρηση + Pitot (psi) + Αποτέλεσμα ελέγχου + Ο έλεγχος σχεδίων καταχωρήθηκε. + Έλεγχος σχεδίων + ID σημείου ενδιαφέροντος + Αντιστοίχιση προσχεδίων + Κυριότητα εγγραφής προσχεδίων + Η ημερομηνία αναθεώρησης του κτιρίου έχει παρέλθει. + Πρόληψη + Το πρόγραμμα αποθηκεύτηκε. + Το προστατευμένο περιεχόμενο είναι κρυφό· αποκαλύψτε το πριν από την ενέργεια. + Επιβολή προστασίας δεδομένων + Έλεγχος ποιότητας + Η δειγματοληψία επιλέγει ντετερμινιστικά οριστικοποιημένες εγγραφές ανά κλίμακα· η βαθμολόγηση είναι σταθμισμένη και τροφοδοτεί τις τάσεις. + Σύσταση τροποποίησης + Συνιστά τροποποίηση της αναφοράς συμβάντος + Τα πορίσματα δεν επεξεργάζονται ποτέ το συμβάν. Η σύσταση περνά από τη ροή τροποποίησης της εγγραφής. + Καταχώρηση τέλους + Καταχώρηση πορισμάτων + Καταχώρηση δοκιμής ροής + Καταχώρηση συντήρησης + Καταχώρηση ελέγχου σχεδίων + Καταχωρήθηκε από + Εγγραφές που δημιουργήθηκαν + Εγγραφές που οριστικοποιήθηκαν + Υγεία έκδοσης + Βασική τηλεμετρία για την ενεργοποίηση των Αρχείων: απόπειρες εγγραφής στο παλαιό σύστημα, καθυστέρηση εξερχομένων, εκτελέσεις Workflow, σαρώσεις και μετρητές πρόληψης. Κάθε πηγή υποβαθμίζεται σε προειδοποίηση αντί να αποτύχει η σελίδα. + Αρχεία ενεργοποιημένα + Εγγραφές με εκπρόθεσμη αναθεώρηση + Αριθμός αναφοράς + Η παραπομπή προστέθηκε. + Η παραπομπή ενημερώθηκε. + Παραπομπές + Επανεπιθεώρηση του + Απόρριψη + Αφαίρεση του συνημμένου; + Επανάνοιγμα υπόθεσης + Απαιτούμενη παροχή (gpm) + Απαιτεί έλεγχο σχεδίων + Υπολειπόμενη (psi) + Τελική κατάσταση + Επιστροφή στον συντάκτη + Επαναφορά σε λειτουργία + Αξιολογήσεις + Κύκλος + Σημείωση ελεγκτή + Σημειώσεις ελέγχου + Εκπρόθεσμη αναθεώρηση + Η αξιολόγηση βαθμολογήθηκε. + Ελέγχθηκε στις + Τύπος στέγης + Η κλίμακα αποθηκεύτηκε. + Κλίμακες + Εκτέλεση απογραφής + Δειγματοληψία + Ολοκληρώθηκαν + Απέτυχαν + Σε εξέλιξη + Παραλείφθηκαν + {0} εγγραφές επιλέχθηκαν για αξιολόγηση. + Μέγεθος δείγματος + Εγγραφές ανά εκτέλεση δειγματοληψίας· η επιλογή είναι ντετερμινιστική για την κλίμακα και την περίοδο. + Δειγματοληπτήθηκαν + Δειγματοληπτήθηκε στις + Συνημμένα σε αναμονή σάρωσης + Συνημμένα που απορρίφθηκαν από τη σάρωση + Σάρωση + Προγραμματισμός επιθεώρησης + Προγραμματισμός επανεπιθεώρησης + Προγραμματισμένη + Βαθμολογήστε κάθε κριτήριο 0-4· το σταθμισμένο σύνολο γίνεται ποσοστό. + Βαθμολόγηση αξιολόγησης + Βαθμολογήθηκαν + Βαθμολογήθηκε στις + Ενότητα + Εισήχθησαν {0} ενότητες. + Επαναφορά σε λειτουργία + Θέση εκτός λειτουργίας + Ειδοποίηση πληρωμάτων + Όνομα υπογραφής + Πληκτρολογημένο όνομα του ατόμου που βεβαιώνει την επιθεώρηση + Ανιχνευτές καπνού που εγκαταστάθηκαν + Καταιονιστήρες + Τετραγωνικά πόδια + Προσωπικό + Αναγνωριστικά χρηστών χωρισμένα με κόμμα για τα μέλη που συμμετείχαν. + Στήλη ανύψωσης + Έναρξη επιθεώρησης + Ξεκίνησε + Στατική (psi) + Θέση αποθήκευσης + Όροφοι + Υποβολές σε αναμονή + Αποτυχημένες υποβολές + Υποβλήθηκε στις + Προτεινόμενο κτίριο + Η αλλαγή είναι μπλοκαρισμένη μέχρι να εκτελεστεί η απογραφή, να αποφασιστεί κάθε υποψήφιος και να μη μείνει ασυμφώνητο προσχέδιο. + Μεταφορά κυριότητας εγγραφής στα Αρχεία + Κατανοώ ότι οι σελίδες προσχεδίων στις Επαφές και οι εφαρμογές γίνονται προβολές κτιρίων μόνο για ανάγνωση. + Μεταφορά κυριότητας εγγραφής τώρα; Η επεξεργασία προσχεδίων στις Επαφές σταματά αμέσως. + Μετά την αλλαγή, τα προσχέδια επεξεργάζονται μόνο ως κτίρια. Οι Επαφές και οι εφαρμογές συνεχίζουν με προβολές· η αναίρεση είναι επαναφορά βάσης δεδομένων, όχι διακόπτης. + Άλλαξε στις + Τακτική σύνοψη + Παράθυρο + οφειλόμενη δοκιμή + Δοκιμάστηκε στις + Μεταβίβαση φύλαξης + Προς + Σε εξωτερικό φορέα + Μεταβιβάστηκε στις + Αλλαγή κατάστασης + Τάσεις + Από + Αποσύνδεση + Ασυμφώνητα προσχέδια + Μεταφορτώθηκε + Σημειώσεις δικτύων + Ισχύς (ημέρες) + Η παράβαση αποθηκεύτηκε. + Η παράβαση ενημερώθηκε. + Παραβάσεις + Διακοπή νερού + Σημειώσεις υδροδότησης + Εκτελέσεις Workflow diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index c80b208e1..a2678b1ad 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -1123,6 +1123,11 @@ Assign number Sequence per station Sequence per incident + Records per call + Multiple per call + One authoritative per call + One per subject per call + How many records of this definition one call may have. Enforced on create; a voided or cancelled record releases its slot. Reset yearly Classification Retention (years) @@ -1418,4 +1423,414 @@ Source status is ahead of the local fill Source status is behind the local fill Maintained by a connector + Access audit + Access notes + Add evidence + Add hazard + Add member + Add note + Add referral + Add violation + Agency + Alarm company + Alarm panel + All programs + Amendments recommended + Applicant + Applicant e-mail + Applicant phone + Applied on + Submit application + Approve findings + Findings must be approved by a reviewer or lead other than their author. + Attachment added. + Choose a file to attach. + Attachment removed. + Audience + Authorization denials + Average score + By author + By criterion + By definition + By unit + Cancel inspection + Cancel this inspection? The record stays in history. + Candidate linked. + Candidate rejected. + Candidates + Access to this case is limited to its members and every read is logged. + Case closed. + Case members + Case notes + Case {0} opened. + Case reopened. + Case saved. + Catalog version + Cause classification + Cause detail + Checklist + One item per line: key | text | required (y/n) | code section id. + Checklist item + Close case + Close this case? Notes become read-only. + Close inspection + Closure reason + Edition + Code section saved. + Section text + Code sections + Code set saved. + Code sets + Fire and building codes the department inspects against. Sections carry a default severity and correction window for violations. + Collected from + Collected on + Comments + Complete inspection + Conditions + Conditions template + Contact ID + Contact linked. + Contact unlinked. + Corrective action + Create a new occupancy from this source + Criteria + One criterion per line: key | text | weight. + Score (0-4) + Weight + Inventory finds Contacts pre-plans, contacts with addresses and points of interest, groups them by address and proximity, and proposes occupancies. Decide every candidate before switching write ownership to Records. + Community risk reduction + Activity deleted. + Activity saved. + Summary for the period + Custodian + Chain of custody + Transfers are append-only; every hand-off names who gave, who received, why and the resulting state. + Custody transferred. + Decision reason + Correction days + Default severity + Delete activity + Delete this activity? + Delete hydrant + Delete this hydrant? Flow tests and maintenance history go with it. + Delete occupancy + Delete this occupancy? Inspections, permits and hazards keep their history but lose the parent. + Due on + Edit hydrant + Edit occupancy + Edit permit + Effective on + Electric shutoff + Emergency contact + Evidence added. + Evidence + Evidence # + Expires on + Leave blank to use the permit type validity from the issue date. + Expiring soon + Export packet + Failed + FDC location + Fee + Reference only; invoicing is recorded, not collected, here. + Fee paid on + Fee recorded. + File + Findings + Findings approved. + Approved + Awaiting approval, recorded + Findings recorded and sent for approval. + Findings returned to the author. + Fire alarm + Flow class + Flow (gpm) + Recorded flow tests update this value and the NFPA 291 class automatically. + Flow is computed as 29.83 × c × d² × √pitot and rounded to 10 gpm; the class follows NFPA 291. + Flow test recorded. + Flow tests + Frequency (months) + From + Gas shutoff + Gate code + General hazard notes + Generate due inspections + {0} inspections generated. + Hazard removed. + Hazard saved. + Hazards + Hazmat on site + Hours + {0} hydrants, {1} out of service, {2} due for a flow test. + Hydrant deleted. + Map + Colour follows the NFPA 291 flow class; black is out of service. + Hydrant # + Owner + Hydrant saved. + Hydrants + Import hydrants + CSV with a header row: number, latitude, longitude, type, address, main_size, flow_gpm, owner. Existing numbers are updated. + Rows not imported + {0} rows read, {1} created, {2} updated, {3} rejected. + Import sections + CSV with a header row: section, title, text, severity (1-4), correction_days. + In service + Incident linked; the current revision is pinned. + Incident summary + Incident unlinked. + Include closed + Inspection cancelled. + Inspection closed. + Inspection completed; failed items created violations. + Notes + Inspection # + Inspection programs + A program pairs a checklist with a frequency and the occupancy types it applies to; due inspections are generated from it. + Result + Inspection scheduled. + Inspection started. + Inspections + Inspector + Inventoried on + {0} sources scanned, {1} candidates created, {2} updated, {3} already decided. + Investigations + Only cases you are a member of are listed. Every read of a case is written to the access audit. + Invoice reference + Issue notice + Issued on + Note + Jurisdiction + Knox box + Last inspected + Last maintained + Last reviewed + Last tested + Lead investigator + Legacy write attempts + Link + Link contact + Link incident + Linked contacts + Linked + Linked incidents + Linking pins the incident revision at that moment; findings never change the incident report. + Main size (in) + Maintenance log + Maintenance recorded. + Mark reviewed + Match confidence + Member added. + Member removed. + Merge this occupancy into the target? Hazards, links and history move; this record becomes a pointer. + Merge into + Target occupancy ID + Nearest hydrant + New code set + New activity + New hydrant + New occupancy + New permit + New permit type + New program + New rubric + No alerts or warnings in this window. + No attachments. + No candidates waiting. Run the inventory to find pre-plans and contacts to reconcile. + No cases. + This program has no checklist items. + No activities in this period. + No flow tests recorded. + No hazards recorded. + No hydrants yet. + No inspections. + No linked contacts. + No maintenance recorded. + No occupancies match. + No reviews waiting to be scored. + No permits match. + No plan review cycles yet. + No scored reviews in this period. + No rubrics yet. A department administrator creates the first one. + No violations. + N/A + Note text + Note saved. + Subject + Notice issued. + Notice reference + Occupancies + Access + {0} occupancies + Occupancy deleted. + Occupancy hours + Identity and location + Occupancy merged. + This occupancy was merged into + Occupancy # + Fire protection and utilities + Occupancy marked reviewed. + Occupancy saved. + Search number, name or address + Structure + Hazards and tactics + Occupancy type + Applies to occupancy types + Comma-separated type numbers; blank means every type. + Occupant load + Occupants needing assistance + Open case + You become the lead investigator. Add members after opening; only members can read the case. + Open violations + Opened on + Origin description + Out of service + Out-of-service reason + Failed events + Event outbox + Oldest pending + Pending events + Outcome + Outlet (in) + Owner name + Pre-plans are still written under Contacts. Reconcile the crosswalk to switch write ownership to occupancies. + Records occupancies own pre-plans. Contacts pages and the apps read projections from here. + Write ownership switched to Records occupancies. + Parcel ID + Passed + Reviews to score + Performed on + Code + Permit # + Permit saved. + Permit state updated. + Permit type + Permit type saved. + Permit types + Permits + pinned revision + Pitot (psi) + Outcome + Plan review recorded. + Plan review + Point of interest ID + Pre-plan crosswalk + Pre-plan write ownership + The review date for this occupancy has passed. + Prevention + Program saved. + Protected content is concealed; reveal it before this action. + Protected data enforced + Quality review + Sampling picks finalized records deterministically per rubric; scoring is weighted and feeds the trends. + Recommend an amendment + Recommends amending the incident report + Findings never edit the incident. The recommendation is delivered through the record amendment flow. + Record fee + Record findings + Record flow test + Record maintenance + Record plan review + Recorded by + Records created + Records finalized + Release health + Baseline telemetry for the Records activation: legacy write attempts, outbox lag, Workflow runs, scans and prevention counters. Every source degrades to a warning instead of failing the page. + Records activated + Records overdue for review + Reference number + Referral added. + Referral updated. + Referrals + Re-inspection of + Reject + Remove this attachment? + Reopen case + Required fire flow (gpm) + Requires plan review + Residual (psi) + Resulting state + Return to author + Returned to service + Reviews + Cycle + Reviewer note + Review notes + Review overdue + Review scored. + Reviewed on + Roof type + Rubric saved. + Rubrics + Run inventory + Sample + Completed + Failed + Running + Skipped + {0} records sampled for review. + Sample size + Records per sampling run; selection is deterministic for the rubric and period. + Sampled + Sampled on + Attachments awaiting scan + Attachments rejected by scan + Scan + Schedule inspection + Schedule re-inspection + Scheduled + Score each criterion 0-4; the weighted total becomes a percentage. + Score review + Scored + Scored on + Section + {0} sections imported. + Return to service + Take out of service + Alert crews + Signature name + Typed name of the person attesting the inspection + Smoke alarms installed + Sprinklers + Square feet + Staff + Comma-separated user IDs of the members who took part. + Standpipe + Start inspection + Started + Static (psi) + Storage location + Stories + Submissions awaiting + Submissions failed + Submitted on + Suggested occupancy + The switch is blocked until the inventory has run, every candidate is decided and no pre-plan is unreconciled. + Switch write ownership to Records + I understand that Contacts pre-plan pages and the apps become read-only projections of occupancies. + Switch write ownership now? Contacts pre-plan editing stops immediately. + After the switch, pre-plans are edited only as occupancies. Contacts and the apps keep working from projections; rollback is a database restore, not a toggle. + Switched on + Tactical summary + Window + test due + Tested on + Transfer custody + To + To external party + Transferred on + Change state + Trends + Since + Unlink + Unreconciled pre-plans + Uploaded + Utility notes + Validity (days) + Violation saved. + Violation updated. + Violations + Water shutoff + Water supply notes + Workflow runs diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index e5a9c6100..6b648730d 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -1123,6 +1123,11 @@ Asignar número Secuencia por estación Secuencia por incidente + Registros por llamada + Varios por llamada + Uno autorizado por llamada + Uno por sujeto por llamada + Cuántos registros de esta definición puede tener una llamada. Se aplica al crear; un registro anulado o cancelado libera su lugar. Reiniciar anualmente Clasificación Retención (años) @@ -1418,4 +1423,414 @@ El estado de la fuente va por delante de la asignación local El estado de la fuente va por detrás de la asignación local Mantenido por un conector + Auditoría de acceso + Notas de acceso + Añadir evidencia + Añadir peligro + Añadir miembro + Añadir nota + Añadir derivación + Añadir infracción + Agencia + Empresa de alarmas + Panel de alarma + Todos los programas + Enmiendas recomendadas + Solicitante + Correo del solicitante + Teléfono del solicitante + Solicitado el + Presentar solicitud + Aprobar conclusiones + Las conclusiones deben aprobarlas un revisor o responsable distinto de su autor. + Adjunto añadido. + Elija un archivo para adjuntar. + Adjunto eliminado. + Asistentes + Denegaciones de autorización + Puntuación media + Por autor + Por criterio + Por definición + Por unidad + Cancelar inspección + ¿Cancelar esta inspección? El registro se conserva en el historial. + Candidato vinculado. + Candidato rechazado. + Candidatos + El acceso a este caso se limita a sus miembros y cada lectura queda registrada. + Caso cerrado. + Miembros del caso + Notas del caso + Caso {0} abierto. + Caso reabierto. + Caso guardado. + Versión del catálogo + Clasificación de la causa + Detalle de la causa + Lista de comprobación + Un elemento por línea: clave | texto | obligatorio (y/n) | id de sección de código. + Elemento de la lista + Cerrar caso + ¿Cerrar este caso? Las notas pasan a ser de solo lectura. + Cerrar inspección + Motivo de cierre + Edición + Sección de código guardada. + Texto de la sección + Secciones del código + Conjunto de códigos guardado. + Conjuntos de códigos + Códigos de incendios y edificación con los que inspecciona el departamento. Las secciones llevan una gravedad y un plazo de corrección predeterminados. + Recogido de + Recogido el + Comentarios + Completar inspección + Condiciones + Plantilla de condiciones + ID de contacto + Contacto vinculado. + Contacto desvinculado. + Acción correctiva + Crear una nueva ocupación a partir de esta fuente + Criterios + Un criterio por línea: clave | texto | peso. + Puntuación (0-4) + Peso + El inventario localiza planes previos, contactos con dirección y puntos de interés, los agrupa por dirección y proximidad y propone ocupaciones. Decida cada candidato antes de traspasar la propiedad de escritura a Registros. + Reducción de riesgos comunitarios + Actividad eliminada. + Actividad guardada. + Resumen del periodo + Custodio + Cadena de custodia + Las transferencias solo se añaden; cada entrega indica quién cedió, quién recibió, por qué y el estado resultante. + Custodia transferida. + Motivo de la decisión + Días de corrección + Gravedad predeterminada + Eliminar actividad + ¿Eliminar esta actividad? + Eliminar hidrante + ¿Eliminar este hidrante? Se pierden las pruebas de caudal y el historial de mantenimiento. + Eliminar ocupación + ¿Eliminar esta ocupación? Inspecciones, permisos y peligros conservan su historial pero pierden el padre. + Vence el + Editar hidrante + Editar ocupación + Editar permiso + Vigente desde + Corte eléctrico + Contacto de emergencia + Evidencia añadida. + Evidencias + N.º de evidencia + Caduca el + Déjelo en blanco para usar la validez del tipo de permiso desde la emisión. + Próximo a caducar + Exportar expediente + No conforme + Ubicación de la toma de bomberos + Tasa + Solo como referencia; aquí se registra la factura, no se cobra. + Tasa pagada el + Tasa registrada. + Archivo + Conclusiones + Conclusiones aprobadas. + Aprobadas + Pendientes de aprobación, registradas + Conclusiones registradas y enviadas para aprobación. + Conclusiones devueltas al autor. + Alarma de incendio + Clase de caudal + Caudal (gpm) + Las pruebas de caudal registradas actualizan este valor y la clase NFPA 291 automáticamente. + El caudal se calcula como 29,83 × c × d² × √pitot y se redondea a 10 gpm; la clase sigue NFPA 291. + Prueba de caudal registrada. + Pruebas de caudal + Frecuencia (meses) + De + Corte de gas + Código de la puerta + Notas generales de peligros + Generar inspecciones pendientes + {0} inspecciones generadas. + Peligro eliminado. + Peligro guardado. + Peligros + Materiales peligrosos en el sitio + Horas + {0} hidrantes, {1} fuera de servicio, {2} con prueba de caudal pendiente. + Hidrante eliminado. + Mapa + El color sigue la clase de caudal NFPA 291; el negro indica fuera de servicio. + N.º de hidrante + Propietario + Hidrante guardado. + Hidrantes + Importar hidrantes + CSV con fila de cabecera: number, latitude, longitude, type, address, main_size, flow_gpm, owner. Los números existentes se actualizan. + Filas no importadas + {0} filas leídas, {1} creadas, {2} actualizadas, {3} rechazadas. + Importar secciones + CSV con fila de cabecera: section, title, text, severity (1-4), correction_days. + En servicio + Incidente vinculado; la revisión actual queda fijada. + Resumen del incidente + Incidente desvinculado. + Incluir cerrados + Inspección cancelada. + Inspección cerrada. + Inspección completada; los elementos no conformes generaron infracciones. + Notas + N.º de inspección + Programas de inspección + Un programa combina una lista de comprobación con una frecuencia y los tipos de ocupación a los que aplica; de él se generan las inspecciones pendientes. + Resultado + Inspección programada. + Inspección iniciada. + Inspecciones + Inspector + Inventariado el + {0} fuentes analizadas, {1} candidatos creados, {2} actualizados, {3} ya decididos. + Investigaciones + Solo se listan los casos de los que es miembro. Cada lectura de un caso queda en la auditoría de acceso. + Referencia de factura + Emitir notificación + Emitido el + Nota + Jurisdicción + Caja de llaves + Última inspección + Último mantenimiento + Última revisión + Última prueba + Investigador principal + Intentos de escritura heredada + Vincular + Vincular contacto + Vincular incidente + Contactos vinculados + Vinculados + Incidentes vinculados + Al vincular se fija la revisión del incidente en ese momento; las conclusiones nunca modifican el informe. + Diámetro de la tubería (pulg.) + Registro de mantenimiento + Mantenimiento registrado. + Marcar como revisado + Confianza de coincidencia + Miembro añadido. + Miembro eliminado. + ¿Fusionar esta ocupación en la de destino? Peligros, vínculos e historial se trasladan; este registro pasa a ser un puntero. + Fusionar en + ID de la ocupación de destino + Hidrante más cercano + Nuevo conjunto de códigos + Nueva actividad + Nuevo hidrante + Nueva ocupación + Nuevo permiso + Nuevo tipo de permiso + Nuevo programa + Nueva rúbrica + Sin alertas ni avisos en este periodo. + Sin adjuntos. + No hay candidatos pendientes. Ejecute el inventario para hallar planes previos y contactos que conciliar. + Sin casos. + Este programa no tiene elementos en la lista. + Sin actividades en este periodo. + Sin pruebas de caudal registradas. + Sin peligros registrados. + Aún no hay hidrantes. + Sin inspecciones. + Sin contactos vinculados. + Sin mantenimiento registrado. + Ninguna ocupación coincide. + No hay revisiones pendientes de puntuar. + Ningún permiso coincide. + Aún no hay ciclos de revisión de planos. + Sin revisiones puntuadas en este periodo. + Aún no hay rúbricas. Un administrador del departamento crea la primera. + Sin infracciones. + N/D + Texto de la nota + Nota guardada. + Asunto + Notificación emitida. + Referencia de la notificación + Ocupaciones + Acceso + {0} ocupaciones + Ocupación eliminada. + Horario de ocupación + Identidad y ubicación + Ocupación fusionada. + Esta ocupación se fusionó en + N.º de ocupación + Protección contra incendios y suministros + Ocupación marcada como revisada. + Ocupación guardada. + Buscar número, nombre o dirección + Estructura + Peligros y tácticas + Tipo de ocupación + Aplica a tipos de ocupación + Números de tipo separados por comas; en blanco significa todos los tipos. + Aforo + Ocupantes que requieren asistencia + Abrir caso + Usted será el investigador principal. Añada miembros tras abrirlo; solo los miembros pueden leer el caso. + Infracciones abiertas + Abierto el + Descripción del origen + Fuera de servicio + Motivo de fuera de servicio + Eventos fallidos + Bandeja de salida de eventos + Pendiente más antiguo + Eventos pendientes + Resultado + Salida (pulg.) + Nombre del propietario + Los planes previos aún se editan en Contactos. Concilie el cruce para traspasar la propiedad de escritura a las ocupaciones. + Las ocupaciones de Registros son propietarias de los planes previos. Las páginas de Contactos y las apps leen proyecciones desde aquí. + Propiedad de escritura traspasada a las ocupaciones de Registros. + ID de parcela + Conforme + Revisiones por puntuar + Realizado el + Código + N.º de permiso + Permiso guardado. + Estado del permiso actualizado. + Tipo de permiso + Tipo de permiso guardado. + Tipos de permiso + Permisos + revisión fijada + Pitot (psi) + Resultado de la revisión + Revisión de planos registrada. + Revisión de planos + ID de punto de interés + Cruce de planes previos + Propiedad de escritura de planes previos + La fecha de revisión de esta ocupación ha pasado. + Prevención + Programa guardado. + El contenido protegido está oculto; revélelo antes de esta acción. + Protección de datos aplicada + Revisión de calidad + El muestreo elige registros finalizados de forma determinista por rúbrica; la puntuación es ponderada y alimenta las tendencias. + Recomendar una enmienda + Recomienda enmendar el informe del incidente + Las conclusiones nunca editan el incidente. La recomendación se tramita por el flujo de enmienda del registro. + Registrar tasa + Registrar conclusiones + Registrar prueba de caudal + Registrar mantenimiento + Registrar revisión de planos + Registrado por + Registros creados + Registros finalizados + Estado de la versión + Telemetría base de la activación de Registros: intentos de escritura heredada, retraso de la bandeja de salida, ejecuciones de Workflow, análisis y contadores de prevención. Cada fuente degrada a un aviso en lugar de fallar la página. + Registros activado + Registros con revisión vencida + Número de referencia + Derivación añadida. + Derivación actualizada. + Derivaciones + Reinspección de + Rechazar + ¿Eliminar este adjunto? + Reabrir caso + Caudal requerido (gpm) + Requiere revisión de planos + Residual (psi) + Estado resultante + Devolver al autor + Devuelto al servicio + Revisiones + Ciclo + Nota del revisor + Notas de revisión + Revisión vencida + Revisión puntuada. + Revisado el + Tipo de cubierta + Rúbrica guardada. + Rúbricas + Ejecutar inventario + Muestrear + Completadas + Fallidas + En ejecución + Omitidas + {0} registros muestreados para revisión. + Tamaño de la muestra + Registros por ejecución de muestreo; la selección es determinista para la rúbrica y el periodo. + Muestreados + Muestreado el + Adjuntos pendientes de análisis + Adjuntos rechazados por el análisis + Análisis + Programar inspección + Programar reinspección + Programada + Puntúe cada criterio de 0 a 4; el total ponderado se convierte en porcentaje. + Puntuar revisión + Puntuados + Puntuado el + Sección + {0} secciones importadas. + Devolver al servicio + Poner fuera de servicio + Alertar a las dotaciones + Nombre de la firma + Nombre escrito de quien certifica la inspección + Detectores de humo instalados + Rociadores + Pies cuadrados + Personal + IDs de usuario separados por comas de los miembros que participaron. + Columna seca + Iniciar inspección + Iniciada + Estática (psi) + Lugar de almacenamiento + Plantas + Envíos pendientes + Envíos fallidos + Presentado el + Ocupación sugerida + El traspaso está bloqueado hasta que se ejecute el inventario, se decida cada candidato y no quede ningún plan previo sin conciliar. + Traspasar la propiedad de escritura a Registros + Entiendo que las páginas de planes previos de Contactos y las apps pasan a ser proyecciones de solo lectura de las ocupaciones. + ¿Traspasar la propiedad de escritura ahora? La edición de planes previos en Contactos se detiene de inmediato. + Tras el traspaso, los planes previos solo se editan como ocupaciones. Contactos y las apps siguen funcionando con proyecciones; revertir es una restauración de base de datos, no un interruptor. + Traspasado el + Resumen táctico + Ventana + prueba pendiente + Probado el + Transferir custodia + A + A entidad externa + Transferido el + Cambiar estado + Tendencias + Desde + Desvincular + Planes previos sin conciliar + Subido + Notas de suministros + Validez (días) + Infracción guardada. + Infracción actualizada. + Infracciones + Corte de agua + Notas de suministro de agua + Ejecuciones de Workflow diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index 3152008dc..e2f8e258d 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -1123,6 +1123,11 @@ Attribuer le numéro Séquence par caserne Séquence par incident + Enregistrements par appel + Plusieurs par appel + Un seul faisant foi par appel + Un par sujet par appel + Combien d'enregistrements de cette définition un appel peut avoir. Appliqué à la création ; un enregistrement annulé ou abandonné libère sa place. Réinitialiser chaque année Classification Conservation (années) @@ -1418,4 +1423,414 @@ Le statut de la source est en avance sur l'affectation locale Le statut de la source est en retard sur l'affectation locale Maintenu par un connecteur + Journal d'accès + Notes d'accès + Ajouter une pièce + Ajouter un danger + Ajouter un membre + Ajouter une note + Ajouter un renvoi + Ajouter une infraction + Organisme + Société d'alarme + Centrale d'alarme + Tous les programmes + Modifications recommandées + Demandeur + Courriel du demandeur + Téléphone du demandeur + Demandé le + Soumettre la demande + Approuver les conclusions + Les conclusions doivent être approuvées par un relecteur ou un responsable autre que leur auteur. + Pièce jointe ajoutée. + Choisissez un fichier à joindre. + Pièce jointe supprimée. + Public touché + Refus d'autorisation + Score moyen + Par auteur + Par critère + Par définition + Par unité + Annuler l'inspection + Annuler cette inspection ? L'enregistrement reste dans l'historique. + Candidat lié. + Candidat rejeté. + Candidats + L'accès à ce dossier est limité à ses membres et chaque lecture est journalisée. + Dossier clos. + Membres du dossier + Notes du dossier + Dossier {0} ouvert. + Dossier rouvert. + Dossier enregistré. + Version du catalogue + Classification de la cause + Détail de la cause + Liste de contrôle + Un élément par ligne : clé | texte | obligatoire (y/n) | identifiant de section de code. + Élément de contrôle + Clore le dossier + Clore ce dossier ? Les notes deviennent en lecture seule. + Clore l'inspection + Motif de clôture + Édition + Section de code enregistrée. + Texte de la section + Sections de code + Recueil de codes enregistré. + Recueils de codes + Codes incendie et bâtiment servant de référence aux inspections. Les sections portent une gravité et un délai de correction par défaut. + Prélevé sur + Prélevé le + Commentaires + Terminer l'inspection + Conditions + Modèle de conditions + Identifiant du contact + Contact lié. + Contact dissocié. + Mesure corrective + Créer une nouvelle occupation à partir de cette source + Critères + Un critère par ligne : clé | texte | poids. + Score (0-4) + Poids + L'inventaire repère les plans d'intervention, les contacts avec adresse et les points d'intérêt, les regroupe par adresse et proximité et propose des occupations. Statuez sur chaque candidat avant de transférer la propriété en écriture aux Dossiers. + Réduction des risques communautaires + Activité supprimée. + Activité enregistrée. + Synthèse de la période + Dépositaire + Chaîne de possession + Les transferts sont en ajout seul ; chaque remise indique qui a remis, qui a reçu, pourquoi et l'état résultant. + Possession transférée. + Motif de la décision + Délai de correction (jours) + Gravité par défaut + Supprimer l'activité + Supprimer cette activité ? + Supprimer la borne + Supprimer cette borne ? Les essais de débit et l'historique de maintenance disparaissent. + Supprimer l'occupation + Supprimer cette occupation ? Inspections, permis et dangers conservent leur historique mais perdent leur parent. + Échéance + Modifier la borne + Modifier l'occupation + Modifier le permis + Effectif le + Coupure électrique + Contact d'urgence + Pièce ajoutée. + Pièces à conviction + N° de pièce + Expire le + Laisser vide pour appliquer la validité du type de permis à partir de l'émission. + Expire bientôt + Exporter le dossier + Non conforme + Emplacement du raccord pompiers + Redevance + Pour référence seulement ; la facturation est consignée ici, pas encaissée. + Redevance réglée le + Redevance enregistrée. + Fichier + Conclusions + Conclusions approuvées. + Approuvées + En attente d'approbation, enregistrées + Conclusions enregistrées et envoyées pour approbation. + Conclusions renvoyées à l'auteur. + Alarme incendie + Classe de débit + Débit (gpm) + Les essais de débit enregistrés mettent à jour cette valeur et la classe NFPA 291 automatiquement. + Le débit est calculé comme 29,83 × c × d² × √pitot et arrondi à 10 gpm ; la classe suit la NFPA 291. + Essai de débit enregistré. + Essais de débit + Fréquence (mois) + De + Coupure de gaz + Code du portail + Notes générales sur les dangers + Générer les inspections dues + {0} inspections générées. + Danger supprimé. + Danger enregistré. + Dangers + Matières dangereuses sur place + Heures + {0} bornes, {1} hors service, {2} avec essai de débit à faire. + Borne supprimée. + Carte + La couleur suit la classe de débit NFPA 291 ; le noir signale hors service. + N° de borne + Propriétaire + Borne enregistrée. + Bornes d'incendie + Importer des bornes + CSV avec ligne d'en-tête : number, latitude, longitude, type, address, main_size, flow_gpm, owner. Les numéros existants sont mis à jour. + Lignes non importées + {0} lignes lues, {1} créées, {2} mises à jour, {3} rejetées. + Importer des sections + CSV avec ligne d'en-tête : section, title, text, severity (1-4), correction_days. + En service + Incident lié ; la révision actuelle est figée. + Résumé de l'incident + Incident dissocié. + Inclure les dossiers clos + Inspection annulée. + Inspection close. + Inspection terminée ; les éléments non conformes ont créé des infractions. + Notes + N° d'inspection + Programmes d'inspection + Un programme associe une liste de contrôle à une fréquence et aux types d'occupation concernés ; les inspections dues en sont générées. + Résultat + Inspection planifiée. + Inspection commencée. + Inspections + Inspecteur + Inventorié le + {0} sources analysées, {1} candidats créés, {2} mis à jour, {3} déjà tranchés. + Enquêtes + Seuls les dossiers dont vous êtes membre sont listés. Chaque lecture d'un dossier est consignée dans le journal d'accès. + Référence de facture + Émettre un avis + Délivré le + Note + Juridiction + Boîte à clés + Dernière inspection + Dernière maintenance + Dernière revue + Dernier essai + Enquêteur principal + Tentatives d'écriture héritées + Lier + Lier un contact + Lier un incident + Contacts liés + Liés + Incidents liés + Le lien fige la révision de l'incident à cet instant ; les conclusions ne modifient jamais le rapport d'incident. + Diamètre de conduite (po) + Journal de maintenance + Maintenance enregistrée. + Marquer comme revu + Confiance de correspondance + Membre ajouté. + Membre retiré. + Fusionner cette occupation dans la cible ? Dangers, liens et historique sont déplacés ; cet enregistrement devient un renvoi. + Fusionner dans + Identifiant de l'occupation cible + Borne la plus proche + Nouveau recueil de codes + Nouvelle activité + Nouvelle borne + Nouvelle occupation + Nouveau permis + Nouveau type de permis + Nouveau programme + Nouvelle grille + Aucune alerte ni avertissement sur cette période. + Aucune pièce jointe. + Aucun candidat en attente. Lancez l'inventaire pour trouver les plans d'intervention et contacts à rapprocher. + Aucun dossier. + Ce programme n'a aucun élément de contrôle. + Aucune activité sur cette période. + Aucun essai de débit enregistré. + Aucun danger enregistré. + Aucune borne pour le moment. + Aucune inspection. + Aucun contact lié. + Aucune maintenance enregistrée. + Aucune occupation ne correspond. + Aucune revue en attente de notation. + Aucun permis ne correspond. + Aucun cycle d'examen des plans pour le moment. + Aucune revue notée sur cette période. + Aucune grille pour le moment. Un administrateur du service crée la première. + Aucune infraction. + S. O. + Texte de la note + Note enregistrée. + Objet + Avis émis. + Référence de l'avis + Occupations + Accès + {0} occupations + Occupation supprimée. + Horaires d'occupation + Identité et emplacement + Occupation fusionnée. + Cette occupation a été fusionnée dans + N° d'occupation + Protection incendie et réseaux + Occupation marquée comme revue. + Occupation enregistrée. + Rechercher un numéro, un nom ou une adresse + Structure + Dangers et tactique + Type d'occupation + S'applique aux types d'occupation + Numéros de type séparés par des virgules ; vide signifie tous les types. + Effectif admissible + Occupants nécessitant une assistance + Ouvrir un dossier + Vous devenez l'enquêteur principal. Ajoutez des membres après l'ouverture ; seuls les membres peuvent lire le dossier. + Infractions ouvertes + Ouvert le + Description de l'origine + Hors service + Motif de mise hors service + Événements en échec + Boîte d'envoi des événements + Plus ancien en attente + Événements en attente + Résultat + Sortie (po) + Nom du propriétaire + Les plans d'intervention sont encore rédigés sous Contacts. Rapprochez la table de correspondance pour transférer la propriété en écriture aux occupations. + Les occupations des Dossiers possèdent les plans d'intervention. Les pages Contacts et les applications lisent les projections depuis ici. + Propriété en écriture transférée aux occupations des Dossiers. + Identifiant cadastral + Conforme + Revues à noter + Effectué le + Code + N° de permis + Permis enregistré. + État du permis mis à jour. + Type de permis + Type de permis enregistré. + Types de permis + Permis + révision figée + Pitot (psi) + Résultat de l'examen + Examen des plans enregistré. + Examen des plans + Identifiant du point d'intérêt + Correspondance des plans d'intervention + Propriété en écriture des plans d'intervention + La date de revue de cette occupation est dépassée. + Prévention + Programme enregistré. + Le contenu protégé est masqué ; révélez-le avant cette action. + Protection des données appliquée + Revue qualité + L'échantillonnage sélectionne de façon déterministe des dossiers finalisés par grille ; la notation est pondérée et alimente les tendances. + Recommander une modification + Recommande de modifier le rapport d'incident + Les conclusions ne modifient jamais l'incident. La recommandation passe par le flux de modification du dossier. + Enregistrer la redevance + Enregistrer les conclusions + Enregistrer l'essai de débit + Enregistrer la maintenance + Enregistrer l'examen des plans + Enregistré par + Dossiers créés + Dossiers finalisés + Santé du déploiement + Télémétrie de référence pour l'activation des Dossiers : tentatives d'écriture héritées, retard de la boîte d'envoi, exécutions de Workflow, analyses et compteurs de prévention. Chaque source se dégrade en avertissement au lieu de faire échouer la page. + Dossiers activés + Dossiers en retard de revue + Numéro de référence + Renvoi ajouté. + Renvoi mis à jour. + Renvois + Réinspection de + Rejeter + Supprimer cette pièce jointe ? + Rouvrir le dossier + Débit requis (gpm) + Examen des plans requis + Résiduelle (psi) + État résultant + Renvoyer à l'auteur + Remis en service + Revues + Cycle + Note du relecteur + Notes d'examen + Revue en retard + Revue notée. + Examiné le + Type de toiture + Grille enregistrée. + Grilles + Lancer l'inventaire + Échantillonner + Terminées + En échec + En cours + Ignorées + {0} dossiers échantillonnés pour revue. + Taille de l'échantillon + Dossiers par passage d'échantillonnage ; la sélection est déterministe pour la grille et la période. + Échantillonnés + Échantillonné le + Pièces jointes en attente d'analyse + Pièces jointes rejetées par l'analyse + Analyse + Planifier une inspection + Planifier une réinspection + Planifiée + Notez chaque critère de 0 à 4 ; le total pondéré devient un pourcentage. + Noter la revue + Notés + Noté le + Section + {0} sections importées. + Remettre en service + Mettre hors service + Alerter les équipages + Nom du signataire + Nom saisi de la personne attestant l'inspection + Détecteurs de fumée installés + Sprinkleurs + Pieds carrés + Personnel + Identifiants utilisateur séparés par des virgules des membres ayant participé. + Colonne sèche + Commencer l'inspection + Commencée + Statique (psi) + Lieu de stockage + Niveaux + Soumissions en attente + Soumissions en échec + Soumis le + Occupation suggérée + Le transfert est bloqué tant que l'inventaire n'a pas été exécuté, que chaque candidat n'a pas été tranché et qu'un plan d'intervention reste non rapproché. + Transférer la propriété en écriture aux Dossiers + Je comprends que les pages de plans d'intervention des Contacts et les applications deviennent des projections en lecture seule des occupations. + Transférer la propriété en écriture maintenant ? La modification des plans d'intervention dans Contacts s'arrête immédiatement. + Après le transfert, les plans d'intervention ne sont modifiés que comme occupations. Contacts et les applications continuent via des projections ; le retour arrière est une restauration de base de données, pas un simple bouton. + Transféré le + Synthèse tactique + Fenêtre + essai à faire + Testé le + Transférer la possession + À + À un tiers + Transféré le + Changer l'état + Tendances + Depuis + Dissocier + Plans d'intervention non rapprochés + Téléversé + Notes sur les réseaux + Validité (jours) + Infraction enregistrée. + Infraction mise à jour. + Infractions + Coupure d'eau + Notes sur l'alimentation en eau + Exécutions de Workflow diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index 6d37df036..1210741bd 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -1123,6 +1123,11 @@ Assegna numero Sequenza per stazione Sequenza per incidente + Record per chiamata + Più di uno per chiamata + Uno autorevole per chiamata + Uno per soggetto per chiamata + Quanti record di questa definizione può avere una chiamata. Applicato alla creazione; un record annullato o cancellato libera il suo posto. Azzera ogni anno Classificazione Conservazione (anni) @@ -1418,4 +1423,414 @@ Lo stato della fonte è più avanti dell'assegnazione locale Lo stato della fonte è indietro rispetto all'assegnazione locale Mantenuto da un connettore + Registro accessi + Note di accesso + Aggiungi reperto + Aggiungi pericolo + Aggiungi membro + Aggiungi nota + Aggiungi segnalazione + Aggiungi violazione + Ente + Società di allarme + Centrale allarme + Tutti i programmi + Modifiche consigliate + Richiedente + E-mail del richiedente + Telefono del richiedente + Richiesto il + Invia richiesta + Approva conclusioni + Le conclusioni devono essere approvate da un revisore o responsabile diverso dal loro autore. + Allegato aggiunto. + Scegli un file da allegare. + Allegato rimosso. + Pubblico + Autorizzazioni negate + Punteggio medio + Per autore + Per criterio + Per definizione + Per unità + Annulla ispezione + Annullare questa ispezione? Il record resta nello storico. + Candidato collegato. + Candidato rifiutato. + Candidati + L'accesso a questo caso è limitato ai suoi membri e ogni lettura viene registrata. + Caso chiuso. + Membri del caso + Note del caso + Caso {0} aperto. + Caso riaperto. + Caso salvato. + Versione catalogo + Classificazione della causa + Dettaglio della causa + Lista di controllo + Un elemento per riga: chiave | testo | obbligatorio (y/n) | id sezione di codice. + Voce di controllo + Chiudi caso + Chiudere questo caso? Le note diventano di sola lettura. + Chiudi ispezione + Motivo della chiusura + Edizione + Sezione di codice salvata. + Testo della sezione + Sezioni di codice + Raccolta di norme salvata. + Raccolte di norme + Norme antincendio ed edilizie usate per le ispezioni. Le sezioni hanno gravità e termine di correzione predefiniti. + Raccolto da + Raccolto il + Commenti + Completa ispezione + Condizioni + Modello di condizioni + ID contatto + Contatto collegato. + Contatto scollegato. + Azione correttiva + Crea una nuova occupazione da questa fonte + Criteri + Un criterio per riga: chiave | testo | peso. + Punteggio (0-4) + Peso + L'inventario trova i piani preventivi, i contatti con indirizzo e i punti di interesse, li raggruppa per indirizzo e vicinanza e propone occupazioni. Decidi ogni candidato prima di passare la proprietà in scrittura ai Registri. + Riduzione del rischio comunitario + Attività eliminata. + Attività salvata. + Riepilogo del periodo + Custode + Catena di custodia + I trasferimenti sono solo in aggiunta; ogni passaggio indica chi ha consegnato, chi ha ricevuto, il motivo e lo stato risultante. + Custodia trasferita. + Motivo della decisione + Giorni per la correzione + Gravità predefinita + Elimina attività + Eliminare questa attività? + Elimina idrante + Eliminare questo idrante? Prove di portata e storico manutenzione vengono rimossi. + Elimina occupazione + Eliminare questa occupazione? Ispezioni, permessi e pericoli mantengono lo storico ma perdono il collegamento. + Scadenza + Modifica idrante + Modifica occupazione + Modifica permesso + Valido dal + Interruzione elettrica + Contatto di emergenza + Reperto aggiunto. + Reperti + N. reperto + Scade il + Lascia vuoto per usare la validità del tipo di permesso dall'emissione. + In scadenza + Esporta fascicolo + Non superato + Posizione attacco motopompa + Tariffa + Solo di riferimento; qui la fattura viene registrata, non incassata. + Tariffa pagata il + Tariffa registrata. + File + Conclusioni + Conclusioni approvate. + Approvate + In attesa di approvazione, registrate + Conclusioni registrate e inviate per approvazione. + Conclusioni restituite all'autore. + Allarme antincendio + Classe di portata + Portata (gpm) + Le prove di portata registrate aggiornano automaticamente questo valore e la classe NFPA 291. + La portata è calcolata come 29,83 × c × d² × √pitot e arrotondata a 10 gpm; la classe segue NFPA 291. + Prova di portata registrata. + Prove di portata + Frequenza (mesi) + Da + Interruzione gas + Codice cancello + Note generali sui pericoli + Genera ispezioni dovute + {0} ispezioni generate. + Pericolo rimosso. + Pericolo salvato. + Pericoli + Materiali pericolosi in loco + Ore + {0} idranti, {1} fuori servizio, {2} con prova di portata dovuta. + Idrante eliminato. + Mappa + Il colore segue la classe di portata NFPA 291; il nero indica fuori servizio. + N. idrante + Proprietario + Idrante salvato. + Idranti + Importa idranti + CSV con riga di intestazione: number, latitude, longitude, type, address, main_size, flow_gpm, owner. I numeri esistenti vengono aggiornati. + Righe non importate + {0} righe lette, {1} create, {2} aggiornate, {3} rifiutate. + Importa sezioni + CSV con riga di intestazione: section, title, text, severity (1-4), correction_days. + In servizio + Incidente collegato; la revisione corrente è bloccata. + Riepilogo dell'incidente + Incidente scollegato. + Includi chiusi + Ispezione annullata. + Ispezione chiusa. + Ispezione completata; le voci non superate hanno generato violazioni. + Note + N. ispezione + Programmi di ispezione + Un programma abbina una lista di controllo a una frequenza e ai tipi di occupazione a cui si applica; le ispezioni dovute vengono generate da esso. + Esito + Ispezione pianificata. + Ispezione avviata. + Ispezioni + Ispettore + Inventariato il + {0} fonti analizzate, {1} candidati creati, {2} aggiornati, {3} già decisi. + Indagini + Sono elencati solo i casi di cui sei membro. Ogni lettura di un caso viene registrata nel registro accessi. + Riferimento fattura + Emetti avviso + Rilasciato il + Nota + Giurisdizione + Cassetta chiavi + Ultima ispezione + Ultima manutenzione + Ultima revisione + Ultima prova + Investigatore capo + Tentativi di scrittura legacy + Collega + Collega contatto + Collega incidente + Contatti collegati + Collegati + Incidenti collegati + Il collegamento blocca la revisione dell'incidente in quel momento; le conclusioni non modificano mai il rapporto. + Diametro condotta (pollici) + Registro manutenzione + Manutenzione registrata. + Segna come revisionato + Affidabilità corrispondenza + Membro aggiunto. + Membro rimosso. + Unire questa occupazione nella destinazione? Pericoli, collegamenti e storico vengono spostati; questo record diventa un rimando. + Unisci in + ID occupazione di destinazione + Idrante più vicino + Nuova raccolta di norme + Nuova attività + Nuovo idrante + Nuova occupazione + Nuovo permesso + Nuovo tipo di permesso + Nuovo programma + Nuova griglia + Nessun allarme o avviso in questo intervallo. + Nessun allegato. + Nessun candidato in attesa. Esegui l’inventario per trovare piani preventivi e contatti da riconciliare. + Nessun caso. + Questo programma non ha voci di controllo. + Nessuna attività nel periodo. + Nessuna prova di portata registrata. + Nessun pericolo registrato. + Nessun idrante ancora. + Nessuna ispezione. + Nessun contatto collegato. + Nessuna manutenzione registrata. + Nessuna occupazione corrisponde. + Nessuna revisione in attesa di punteggio. + Nessun permesso corrisponde. + Nessun ciclo di revisione progetti ancora. + Nessuna revisione valutata nel periodo. + Nessuna griglia ancora. Un amministratore del dipartimento crea la prima. + Nessuna violazione. + N/D + Testo della nota + Nota salvata. + Oggetto + Avviso emesso. + Riferimento avviso + Occupazioni + Accesso + {0} occupazioni + Occupazione eliminata. + Orari di occupazione + Identità e posizione + Occupazione unita. + Questa occupazione è stata unita in + N. occupazione + Protezione antincendio e utenze + Occupazione segnata come revisionata. + Occupazione salvata. + Cerca numero, nome o indirizzo + Struttura + Pericoli e tattica + Tipo di occupazione + Si applica ai tipi di occupazione + Numeri di tipo separati da virgola; vuoto significa tutti i tipi. + Affollamento massimo + Occupanti che necessitano assistenza + Apri caso + Diventi l'investigatore capo. Aggiungi membri dopo l'apertura; solo i membri possono leggere il caso. + Violazioni aperte + Aperto il + Descrizione dell'origine + Fuori servizio + Motivo del fuori servizio + Eventi falliti + Coda eventi in uscita + Più vecchio in attesa + Eventi in attesa + Esito + Uscita (pollici) + Nome del proprietario + I piani preventivi sono ancora scritti in Contatti. Riconcilia la corrispondenza per passare la proprietà in scrittura alle occupazioni. + Le occupazioni dei Registri possiedono i piani preventivi. Le pagine Contatti e le app leggono le proiezioni da qui. + Proprietà in scrittura passata alle occupazioni dei Registri. + Particella catastale + Superato + Revisioni da valutare + Eseguito il + Codice + N. permesso + Permesso salvato. + Stato del permesso aggiornato. + Tipo di permesso + Tipo di permesso salvato. + Tipi di permesso + Permessi + revisione bloccata + Pitot (psi) + Esito della revisione + Revisione progetti registrata. + Revisione progetti + ID punto di interesse + Corrispondenza piani preventivi + Proprietà in scrittura dei piani preventivi + La data di revisione di questa occupazione è passata. + Prevenzione + Programma salvato. + Il contenuto protetto è nascosto; rivelalo prima di questa azione. + Protezione dati applicata + Revisione qualità + Il campionamento seleziona in modo deterministico i record finalizzati per griglia; il punteggio è ponderato e alimenta le tendenze. + Consiglia una modifica + Consiglia di modificare il rapporto di incidente + Le conclusioni non modificano mai l'incidente. La raccomandazione passa dal flusso di modifica del record. + Registra tariffa + Registra conclusioni + Registra prova di portata + Registra manutenzione + Registra revisione progetti + Registrato da + Record creati + Record finalizzati + Stato del rilascio + Telemetria di base per l'attivazione dei Registri: tentativi di scrittura legacy, ritardo della coda in uscita, esecuzioni Workflow, scansioni e contatori di prevenzione. Ogni fonte degrada a un avviso invece di far fallire la pagina. + Registri attivati + Record in ritardo per la revisione + Numero di riferimento + Segnalazione aggiunta. + Segnalazione aggiornata. + Segnalazioni + Reispezione di + Rifiuta + Rimuovere questo allegato? + Riapri caso + Portata richiesta (gpm) + Richiede revisione progetti + Residua (psi) + Stato risultante + Restituisci all'autore + Rimesso in servizio + Revisioni + Ciclo + Nota del revisore + Note di revisione + Revisione scaduta + Revisione valutata. + Revisionato il + Tipo di tetto + Griglia salvata. + Griglie + Esegui inventario + Campiona + Completate + Fallite + In esecuzione + Saltate + {0} record campionati per la revisione. + Dimensione campione + Record per esecuzione di campionamento; la selezione è deterministica per griglia e periodo. + Campionati + Campionato il + Allegati in attesa di scansione + Allegati rifiutati dalla scansione + Scansione + Pianifica ispezione + Pianifica reispezione + Pianificata + Valuta ogni criterio da 0 a 4; il totale ponderato diventa una percentuale. + Valuta revisione + Valutati + Valutato il + Sezione + {0} sezioni importate. + Rimetti in servizio + Metti fuori servizio + Avvisa le squadre + Nome della firma + Nome digitato della persona che attesta l'ispezione + Rilevatori di fumo installati + Sprinkler + Piedi quadrati + Personale + ID utente separati da virgola dei membri che hanno partecipato. + Colonna idrica + Avvia ispezione + Avviata + Statica (psi) + Luogo di conservazione + Piani + Invii in attesa + Invii falliti + Presentato il + Occupazione suggerita + Il passaggio è bloccato finché l'inventario non è stato eseguito, ogni candidato deciso e nessun piano preventivo resta non riconciliato. + Passa la proprietà in scrittura ai Registri + Comprendo che le pagine dei piani preventivi in Contatti e le app diventano proiezioni di sola lettura delle occupazioni. + Passare ora la proprietà in scrittura? La modifica dei piani preventivi in Contatti si interrompe subito. + Dopo il passaggio, i piani preventivi si modificano solo come occupazioni. Contatti e app continuano a funzionare dalle proiezioni; il ripristino è un restore del database, non un interruttore. + Passato il + Sintesi tattica + Finestra + prova dovuta + Testato il + Trasferisci custodia + A + A soggetto esterno + Trasferito il + Cambia stato + Tendenze + Da + Scollega + Piani preventivi non riconciliati + Caricato + Note sulle utenze + Validità (giorni) + Violazione salvata. + Violazione aggiornata. + Violazioni + Interruzione acqua + Note sulla fornitura idrica + Esecuzioni Workflow diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index 17190f5b9..98820e40e 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -1123,6 +1123,11 @@ Przydziel numer Sekwencja na stację Numeracja na zdarzenie + Rekordy na wezwanie + Wiele na wezwanie + Jeden wiążący na wezwanie + Jeden na podmiot na wezwanie + Ile rekordów tej definicji może mieć jedno wezwanie. Wymuszane przy tworzeniu; rekord unieważniony lub anulowany zwalnia swoje miejsce. Resetuj co rok Klasyfikacja Przechowywanie (lata) @@ -1418,4 +1423,414 @@ Status źródła wyprzedza lokalną obsadę Status źródła jest za lokalną obsadą Utrzymywane przez łącznik + Dziennik dostępu + Uwagi o dostępie + Dodaj dowód + Dodaj zagrożenie + Dodaj członka + Dodaj notatkę + Dodaj skierowanie + Dodaj naruszenie + Instytucja + Firma alarmowa + Centrala alarmowa + Wszystkie programy + Zalecane poprawki + Wnioskodawca + E-mail wnioskodawcy + Telefon wnioskodawcy + Złożono + Złóż wniosek + Zatwierdź ustalenia + Ustalenia musi zatwierdzić recenzent lub prowadzący inny niż ich autor. + Załącznik dodany. + Wybierz plik do załączenia. + Załącznik usunięty. + Odbiorcy + Odmowy autoryzacji + Średni wynik + Według autora + Według kryterium + Według definicji + Według jednostki + Anuluj kontrolę + Anulować tę kontrolę? Wpis pozostanie w historii. + Kandydat powiązany. + Kandydat odrzucony. + Kandydaci + Dostęp do tej sprawy mają tylko jej członkowie, a każdy odczyt jest rejestrowany. + Sprawa zamknięta. + Członkowie sprawy + Notatki sprawy + Sprawa {0} otwarta. + Sprawa ponownie otwarta. + Sprawa zapisana. + Wersja katalogu + Klasyfikacja przyczyny + Szczegóły przyczyny + Lista kontrolna + Jeden punkt w wierszu: klucz | tekst | wymagany (y/n) | id sekcji przepisu. + Punkt kontrolny + Zamknij sprawę + Zamknąć tę sprawę? Notatki staną się tylko do odczytu. + Zamknij kontrolę + Powód zamknięcia + Wydanie + Sekcja przepisu zapisana. + Treść sekcji + Sekcje przepisów + Zbiór przepisów zapisany. + Zbiory przepisów + Przepisy przeciwpożarowe i budowlane, według których jednostka prowadzi kontrole. Sekcje mają domyślną wagę i termin usunięcia naruszeń. + Zebrane z + Zebrano + Komentarze + Zakończ kontrolę + Warunki + Szablon warunków + ID kontaktu + Kontakt powiązany. + Powiązanie kontaktu usunięte. + Działanie naprawcze + Utwórz nowy obiekt z tego źródła + Kryteria + Jedno kryterium w wierszu: klucz | tekst | waga. + Ocena (0-4) + Waga + Inwentaryzacja znajduje plany operacyjne, kontakty z adresem i punkty POI, grupuje je według adresu i odległości oraz proponuje obiekty. Rozstrzygnij każdego kandydata przed przekazaniem własności zapisu do Rejestrów. + Ograniczanie ryzyka w społeczności + Działanie usunięte. + Działanie zapisane. + Podsumowanie okresu + Depozytariusz + Łańcuch dowodowy + Przekazania można tylko dopisywać; każde wskazuje, kto przekazał, kto odebrał, dlaczego i jaki jest stan wynikowy. + Przekazano depozyt. + Uzasadnienie decyzji + Dni na usunięcie + Domyślna waga + Usuń działanie + Usunąć to działanie? + Usuń hydrant + Usunąć ten hydrant? Testy wydajności i historia konserwacji zostaną usunięte. + Usuń obiekt + Usunąć ten obiekt? Kontrole, zezwolenia i zagrożenia zachowają historię, ale stracą element nadrzędny. + Termin + Edytuj hydrant + Edytuj obiekt + Edytuj zezwolenie + Obowiązuje od + Wyłącznik prądu + Kontakt alarmowy + Dowód dodany. + Dowody + Nr dowodu + Wygasa + Pozostaw puste, aby użyć ważności typu zezwolenia od daty wydania. + Wkrótce wygasa + Eksportuj pakiet + Niezaliczone + Lokalizacja nasady tłocznej + Opłata + Tylko orientacyjnie; faktura jest tu rejestrowana, nie pobierana. + Opłata zapłacona + Opłata zarejestrowana. + Plik + Ustalenia + Ustalenia zatwierdzone. + Zatwierdzone + Oczekują na zatwierdzenie, zapisane + Ustalenia zapisane i przekazane do zatwierdzenia. + Ustalenia zwrócone autorowi. + Alarm pożarowy + Klasa wydajności + Wydajność (gpm) + Zarejestrowane testy wydajności automatycznie aktualizują tę wartość i klasę NFPA 291. + Wydajność liczona jest jako 29,83 × c × d² × √pitot i zaokrąglana do 10 gpm; klasa według NFPA 291. + Test wydajności zarejestrowany. + Testy wydajności + Częstotliwość (miesiące) + Od + Zawór gazu + Kod bramy + Ogólne uwagi o zagrożeniach + Wygeneruj należne kontrole + Wygenerowano {0} kontroli. + Zagrożenie usunięte. + Zagrożenie zapisane. + Zagrożenia + Materiały niebezpieczne na miejscu + Godziny + {0} hydrantów, {1} wyłączonych z użytku, {2} z należnym testem wydajności. + Hydrant usunięty. + Mapa + Kolor odpowiada klasie wydajności NFPA 291; czarny oznacza wyłączony z użytku. + Nr hydrantu + Właściciel + Hydrant zapisany. + Hydranty + Importuj hydranty + CSV z wierszem nagłówka: number, latitude, longitude, type, address, main_size, flow_gpm, owner. Istniejące numery są aktualizowane. + Wiersze nie zaimportowane + Odczytano {0} wierszy, utworzono {1}, zaktualizowano {2}, odrzucono {3}. + Importuj sekcje + CSV z wierszem nagłówka: section, title, text, severity (1-4), correction_days. + Sprawny + Zdarzenie powiązane; bieżąca wersja została przypięta. + Podsumowanie zdarzenia + Powiązanie zdarzenia usunięte. + Uwzględnij zamknięte + Kontrola anulowana. + Kontrola zamknięta. + Kontrola zakończona; niezaliczone punkty utworzyły naruszenia. + Uwagi + Nr kontroli + Programy kontroli + Program łączy listę kontrolną z częstotliwością i typami obiektów, których dotyczy; na jego podstawie generowane są należne kontrole. + Wynik + Kontrola zaplanowana. + Kontrola rozpoczęta. + Kontrole + Kontroler + Inwentaryzacja + Przeskanowano {0} źródeł, utworzono {1} kandydatów, zaktualizowano {2}, {3} już rozstrzygniętych. + Dochodzenia + Widoczne są tylko sprawy, których jesteś członkiem. Każdy odczyt sprawy trafia do dziennika dostępu. + Numer faktury + Wystaw wezwanie + Wydano + Uwaga + Jurysdykcja + Skrytka na klucze + Ostatnia kontrola + Ostatnia konserwacja + Ostatni przegląd + Ostatni test + Prowadzący dochodzenie + Próby zapisu do starego systemu + Powiąż + Powiąż kontakt + Powiąż zdarzenie + Powiązane kontakty + Powiązane + Powiązane zdarzenia + Powiązanie przypina wersję zdarzenia z tej chwili; ustalenia nigdy nie zmieniają raportu ze zdarzenia. + Średnica magistrali (cale) + Dziennik konserwacji + Konserwacja zarejestrowana. + Oznacz jako przejrzane + Pewność dopasowania + Członek dodany. + Członek usunięty. + Scalić ten obiekt z docelowym? Zagrożenia, powiązania i historia zostaną przeniesione; ten wpis stanie się odnośnikiem. + Scal z + ID obiektu docelowego + Najbliższy hydrant + Nowy zbiór przepisów + Nowe działanie + Nowy hydrant + Nowy obiekt + Nowe zezwolenie + Nowy typ zezwolenia + Nowy program + Nowa rubryka + Brak alertów i ostrzeżeń w tym oknie. + Brak załączników. + Brak oczekujących kandydatów. Uruchom inwentaryzację, aby znaleźć plany operacyjne i kontakty do uzgodnienia. + Brak spraw. + Ten program nie ma punktów kontrolnych. + Brak działań w tym okresie. + Brak zarejestrowanych testów wydajności. + Brak zarejestrowanych zagrożeń. + Brak hydrantów. + Brak kontroli. + Brak powiązanych kontaktów. + Brak zarejestrowanej konserwacji. + Brak pasujących obiektów. + Brak przeglądów oczekujących na ocenę. + Brak pasujących zezwoleń. + Brak cykli przeglądu projektu. + Brak ocenionych przeglądów w tym okresie. + Brak rubryk. Pierwszą tworzy administrator jednostki. + Brak naruszeń. + Nie dot. + Treść notatki + Notatka zapisana. + Temat + Wezwanie wystawione. + Numer wezwania + Obiekty + Dostęp + Obiekty: {0} + Obiekt usunięty. + Godziny użytkowania + Tożsamość i lokalizacja + Obiekt scalony. + Ten obiekt został scalony z + Nr obiektu + Ochrona przeciwpożarowa i media + Obiekt oznaczony jako przejrzany. + Obiekt zapisany. + Szukaj numeru, nazwy lub adresu + Konstrukcja + Zagrożenia i taktyka + Typ obiektu + Dotyczy typów obiektów + Numery typów rozdzielone przecinkami; puste oznacza wszystkie typy. + Liczba użytkowników + Osoby wymagające pomocy + Otwórz sprawę + Zostaniesz prowadzącym dochodzenie. Dodaj członków po otwarciu; tylko członkowie mogą czytać sprawę. + Otwarte naruszenia + Otwarto + Opis miejsca powstania + Wyłączony z użytku + Powód wyłączenia + Nieudane zdarzenia + Kolejka zdarzeń wychodzących + Najstarsze oczekujące + Oczekujące zdarzenia + Rezultat + Wylot (cale) + Nazwa właściciela + Plany operacyjne nadal są edytowane w Kontaktach. Uzgodnij powiązania, aby przekazać własność zapisu obiektom. + Obiekty w Rejestrach są właścicielami planów operacyjnych. Strony Kontaktów i aplikacje czytają stąd projekcje. + Własność zapisu przekazana obiektom w Rejestrach. + Numer działki + Zaliczone + Przeglądy do oceny + Wykonano + Kod + Nr zezwolenia + Zezwolenie zapisane. + Stan zezwolenia zaktualizowany. + Typ zezwolenia + Typ zezwolenia zapisany. + Typy zezwoleń + Zezwolenia + przypięta wersja + Pitot (psi) + Wynik przeglądu + Przegląd projektu zarejestrowany. + Przegląd projektu + ID punktu POI + Powiązania planów operacyjnych + Własność zapisu planów operacyjnych + Termin przeglądu tego obiektu minął. + Prewencja + Program zapisany. + Chronione treści są ukryte; odsłoń je przed tą operacją. + Ochrona danych wymuszona + Przegląd jakości + Próbkowanie wybiera sfinalizowane rekordy deterministycznie dla każdej rubryki; ocena jest ważona i zasila trendy. + Zalecam poprawkę + Zaleca poprawienie raportu ze zdarzenia + Ustalenia nigdy nie edytują zdarzenia. Zalecenie trafia do procesu poprawki rekordu. + Zarejestruj opłatę + Zapisz ustalenia + Zarejestruj test wydajności + Zarejestruj konserwację + Zarejestruj przegląd projektu + Zarejestrował + Utworzone rekordy + Sfinalizowane rekordy + Kondycja wdrożenia + Podstawowa telemetria aktywacji Rejestrów: próby zapisu do starego systemu, opóźnienie kolejki, uruchomienia Workflow, skany i liczniki prewencji. Każde źródło degraduje do ostrzeżenia zamiast psuć stronę. + Rejestry aktywne + Rekordy z zaległym przeglądem + Numer referencyjny + Skierowanie dodane. + Skierowanie zaktualizowane. + Skierowania + Ponowna kontrola + Odrzuć + Usunąć ten załącznik? + Otwórz ponownie + Wymagana wydajność (gpm) + Wymaga przeglądu projektu + Resztkowe (psi) + Stan wynikowy + Zwróć autorowi + Przywrócono do użytku + Przeglądy + Cykl + Uwaga recenzenta + Uwagi z przeglądu + Zaległy przegląd + Przegląd oceniony. + Przejrzano + Typ dachu + Rubryka zapisana. + Rubryki + Uruchom inwentaryzację + Próbkuj + Ukończone + Nieudane + W toku + Pominięte + Wylosowano {0} rekordów do przeglądu. + Wielkość próby + Rekordy na jedno próbkowanie; wybór jest deterministyczny dla rubryki i okresu. + Wylosowane + Wylosowano + Załączniki oczekujące na skan + Załączniki odrzucone przez skan + Skan + Zaplanuj kontrolę + Zaplanuj ponowną kontrolę + Zaplanowano + Oceń każde kryterium od 0 do 4; ważona suma staje się wartością procentową. + Oceń przegląd + Ocenione + Oceniono + Sekcja + Zaimportowano {0} sekcji. + Przywróć do użytku + Wyłącz z użytku + Ostrzegaj załogi + Imię i nazwisko podpisującego + Wpisane imię i nazwisko osoby potwierdzającej kontrolę + Zainstalowane czujki dymu + Instalacja tryskaczowa + Stopy kwadratowe + Personel + Rozdzielone przecinkami identyfikatory użytkowników, którzy brali udział. + Pion hydrantowy + Rozpocznij kontrolę + Rozpoczęto + Statyczne (psi) + Miejsce przechowywania + Kondygnacje + Oczekujące przesłania + Nieudane przesłania + Złożono + Sugerowany obiekt + Przełączenie jest zablokowane, dopóki nie uruchomiono inwentaryzacji, nie rozstrzygnięto każdego kandydata i pozostaje nieuzgodniony plan operacyjny. + Przekaż własność zapisu do Rejestrów + Rozumiem, że strony planów operacyjnych w Kontaktach i aplikacje staną się projekcjami obiektów tylko do odczytu. + Przekazać własność zapisu teraz? Edycja planów operacyjnych w Kontaktach natychmiast się zakończy. + Po przełączeniu plany operacyjne edytuje się tylko jako obiekty. Kontakty i aplikacje działają dalej na projekcjach; wycofanie to przywrócenie bazy danych, nie przełącznik. + Przełączono + Podsumowanie taktyczne + Okno + test należny + Testowano + Przekaż depozyt + Do + Do podmiotu zewnętrznego + Przekazano + Zmień stan + Trendy + Od + Usuń powiązanie + Nieuzgodnione plany operacyjne + Przesłano + Uwagi o mediach + Ważność (dni) + Naruszenie zapisane. + Naruszenie zaktualizowane. + Naruszenia + Zawór wody + Uwagi o zaopatrzeniu w wodę + Uruchomienia Workflow diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index b5b1fd40e..4f42f0b24 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -1123,6 +1123,11 @@ Tilldela nummer Sekvens per station Sekvens per insats + Poster per larm + Flera per larm + En auktoritativ per larm + En per subjekt per larm + Hur många poster av den här definitionen ett larm får ha. Kontrolleras vid skapande; en ogiltigförklarad eller avbruten post frigör sin plats. Återställ årligen Klassificering Bevarande (år) @@ -1418,4 +1423,414 @@ Källans status ligger före den lokala tillsättningen Källans status ligger efter den lokala tillsättningen Underhålls av en koppling + Åtkomstlogg + Åtkomstanteckningar + Lägg till bevis + Lägg till fara + Lägg till medlem + Lägg till anteckning + Lägg till hänvisning + Lägg till överträdelse + Myndighet + Larmbolag + Larmcentral + Alla program + Rekommenderade ändringar + Sökande + Sökandens e-post + Sökandens telefon + Ansökt + Skicka ansökan + Godkänn slutsatser + Slutsatserna måste godkännas av en granskare eller ansvarig som inte är författaren. + Bilaga tillagd. + Välj en fil att bifoga. + Bilaga borttagen. + Publik + Nekade behörigheter + Genomsnittlig poäng + Per författare + Per kriterium + Per definition + Per enhet + Avbryt tillsyn + Avbryta denna tillsyn? Posten finns kvar i historiken. + Kandidat länkad. + Kandidat avvisad. + Kandidater + Åtkomst till ärendet är begränsad till dess medlemmar och varje läsning loggas. + Ärende stängt. + Ärendemedlemmar + Ärendeanteckningar + Ärende {0} öppnat. + Ärende återöppnat. + Ärende sparat. + Katalogversion + Orsaksklassificering + Orsaksdetalj + Checklista + Ett objekt per rad: nyckel | text | obligatorisk (y/n) | id för regelavsnitt. + Kontrollpunkt + Stäng ärende + Stänga ärendet? Anteckningarna blir skrivskyddade. + Avsluta tillsyn + Orsak till stängning + Utgåva + Regelavsnitt sparat. + Avsnittstext + Regelavsnitt + Regelverk sparat. + Regelverk + Brand- och byggregler som tillsynen utgår från. Avsnitten har standardallvarlighet och rättelsetid för överträdelser. + Insamlat från + Insamlat + Kommentarer + Slutför tillsyn + Villkor + Villkorsmall + Kontakt-ID + Kontakt länkad. + Kontakt avlänkad. + Åtgärd + Skapa ett nytt objekt från denna källa + Kriterier + Ett kriterium per rad: nyckel | text | vikt. + Poäng (0-4) + Vikt + Inventeringen hittar insatsplaner, kontakter med adress och intressepunkter, grupperar dem efter adress och närhet och föreslår objekt. Avgör varje kandidat innan skrivägandet flyttas till Register. + Riskminskning i samhället + Aktivitet borttagen. + Aktivitet sparad. + Sammanfattning för perioden + Förvarare + Spårbarhetskedja + Överlämningar kan bara läggas till; varje överlämning anger vem som lämnade, vem som tog emot, varför och resulterande tillstånd. + Förvaring överlämnad. + Beslutsmotivering + Rättelsedagar + Standardallvarlighet + Ta bort aktivitet + Ta bort aktiviteten? + Ta bort brandpost + Ta bort brandposten? Flödestester och underhållshistorik försvinner. + Ta bort objekt + Ta bort objektet? Tillsyner, tillstånd och faror behåller historiken men förlorar sin överordnade post. + Förfaller + Redigera brandpost + Redigera objekt + Redigera tillstånd + Gäller från + Elavstängning + Nödkontakt + Bevis tillagt. + Bevis + Bevisnr + Går ut + Lämna tomt för att använda tillståndstypens giltighet från utfärdandet. + Går snart ut + Exportera paket + Underkänd + Placering av stigarledning + Avgift + Endast referens; fakturering registreras här men drivs inte in. + Avgift betald + Avgift registrerad. + Fil + Slutsatser + Slutsatser godkända. + Godkända + Väntar på godkännande, registrerade + Slutsatser registrerade och skickade för godkännande. + Slutsatser återsända till författaren. + Brandlarm + Flödesklass + Flöde (gpm) + Registrerade flödestester uppdaterar värdet och NFPA 291-klassen automatiskt. + Flödet beräknas som 29,83 × c × d² × √pitot och avrundas till 10 gpm; klassen följer NFPA 291. + Flödestest registrerat. + Flödestester + Intervall (månader) + Från + Gasavstängning + Grindkod + Allmänna faroanteckningar + Skapa förfallna tillsyner + {0} tillsyner skapade. + Fara borttagen. + Fara sparad. + Faror + Farligt gods på plats + Timmar + {0} brandposter, {1} ur drift, {2} med förfallet flödestest. + Brandpost borttagen. + Karta + Färgen följer NFPA 291-flödesklassen; svart betyder ur drift. + Brandpostnr + Ägare + Brandpost sparad. + Brandposter + Importera brandposter + CSV med rubrikrad: number, latitude, longitude, type, address, main_size, flow_gpm, owner. Befintliga nummer uppdateras. + Rader som inte importerades + {0} rader lästa, {1} skapade, {2} uppdaterade, {3} avvisade. + Importera avsnitt + CSV med rubrikrad: section, title, text, severity (1-4), correction_days. + I drift + Incident länkad; nuvarande revision är låst. + Incidentsammanfattning + Incident avlänkad. + Inkludera stängda + Tillsyn avbruten. + Tillsyn avslutad. + Tillsyn slutförd; underkända punkter skapade överträdelser. + Anteckningar + Tillsynsnr + Tillsynsprogram + Ett program kopplar en checklista till ett intervall och de objekttyper det gäller; förfallna tillsyner skapas från det. + Resultat + Tillsyn planerad. + Tillsyn påbörjad. + Tillsyner + Inspektör + Inventerat + {0} källor genomsökta, {1} kandidater skapade, {2} uppdaterade, {3} redan avgjorda. + Utredningar + Bara ärenden du är medlem i visas. Varje läsning av ett ärende skrivs till åtkomstloggen. + Fakturareferens + Utfärda föreläggande + Utfärdat + Anteckning + Jurisdiktion + Nyckelskåp + Senast inspekterad + Senast underhållen + Senast granskad + Senast testad + Huvudutredare + Skrivförsök mot gamla systemet + Länka + Länka kontakt + Länka incident + Länkade kontakter + Länkade + Länkade incidenter + Länkningen låser incidentrevisionen vid det tillfället; slutsatser ändrar aldrig incidentrapporten. + Ledningsdimension (tum) + Underhållslogg + Underhåll registrerat. + Markera som granskad + Matchningssäkerhet + Medlem tillagd. + Medlem borttagen. + Slå ihop objektet med målet? Faror, länkar och historik flyttas; denna post blir en hänvisning. + Slå ihop med + Målobjektets ID + Närmaste brandpost + Nytt regelverk + Ny aktivitet + Ny brandpost + Nytt objekt + Nytt tillstånd + Ny tillståndstyp + Nytt program + Ny bedömningsmall + Inga larm eller varningar i detta fönster. + Inga bilagor. + Inga kandidater väntar. Kör inventeringen för att hitta insatsplaner och kontakter att stämma av. + Inga ärenden. + Programmet har inga kontrollpunkter. + Inga aktiviteter under perioden. + Inga flödestester registrerade. + Inga faror registrerade. + Inga brandposter ännu. + Inga tillsyner. + Inga länkade kontakter. + Inget underhåll registrerat. + Inga objekt matchar. + Inga granskningar väntar på poängsättning. + Inga tillstånd matchar. + Inga granskningsomgångar ännu. + Inga poängsatta granskningar under perioden. + Inga bedömningsmallar ännu. En administratör skapar den första. + Inga överträdelser. + Ej tillämpl. + Anteckningstext + Anteckning sparad. + Ämne + Föreläggande utfärdat. + Föreläggandereferens + Objekt + Åtkomst + {0} objekt + Objekt borttaget. + Verksamhetstider + Identitet och plats + Objekt sammanslaget. + Objektet slogs ihop med + Objektnr + Brandskydd och försörjning + Objekt markerat som granskat. + Objekt sparat. + Sök nummer, namn eller adress + Byggnad + Faror och taktik + Objekttyp + Gäller objekttyper + Typnummer avgränsade med komma; tomt betyder alla typer. + Personantal + Personer som behöver hjälp + Öppna ärende + Du blir huvudutredare. Lägg till medlemmar efter öppnandet; bara medlemmar kan läsa ärendet. + Öppna överträdelser + Öppnat + Beskrivning av startplats + Ur drift + Orsak till driftstopp + Misslyckade händelser + Händelseutkorg + Äldsta väntande + Väntande händelser + Utfall + Uttag (tum) + Ägarens namn + Insatsplaner skrivs fortfarande under Kontakter. Stäm av kopplingen för att flytta skrivägandet till objekt. + Objekt i Register äger insatsplanerna. Kontaktsidor och apparna läser projektioner härifrån. + Skrivägande flyttat till objekt i Register. + Fastighetsbeteckning + Godkänd + Granskningar att poängsätta + Utfört + Kod + Tillståndsnr + Tillstånd sparat. + Tillståndets status uppdaterad. + Tillståndstyp + Tillståndstyp sparad. + Tillståndstyper + Tillstånd + låst revision + Pitot (psi) + Granskningsutfall + Ritningsgranskning registrerad. + Ritningsgranskning + Intressepunkts-ID + Koppling av insatsplaner + Skrivägande för insatsplaner + Granskningsdatumet för objektet har passerat. + Förebyggande + Program sparat. + Skyddat innehåll är dolt; visa det innan denna åtgärd. + Dataskydd aktivt + Kvalitetsgranskning + Urvalet väljer slutförda poster deterministiskt per mall; poängen är viktad och matar trenderna. + Rekommendera ändring + Rekommenderar ändring av incidentrapporten + Slutsatser ändrar aldrig incidenten. Rekommendationen går via postens ändringsflöde. + Registrera avgift + Registrera slutsatser + Registrera flödestest + Registrera underhåll + Registrera ritningsgranskning + Registrerat av + Skapade poster + Slutförda poster + Driftstatus + Grundtelemetri för aktiveringen av Register: skrivförsök mot gamla systemet, utkorgsfördröjning, Workflow-körningar, skanningar och förebyggande räknare. Varje källa faller tillbaka till en varning i stället för att fälla sidan. + Register aktiverat + Poster försenade för granskning + Referensnummer + Hänvisning tillagd. + Hänvisning uppdaterad. + Hänvisningar + Omtillsyn av + Avvisa + Ta bort bilagan? + Återöppna ärende + Erforderligt släckvattenflöde (gpm) + Kräver ritningsgranskning + Resttryck (psi) + Resulterande tillstånd + Återsänd till författaren + Åter i drift + Granskningar + Omgång + Granskarens anteckning + Granskningsanteckningar + Försenad granskning + Granskning poängsatt. + Granskat + Taktyp + Bedömningsmall sparad. + Bedömningsmallar + Kör inventering + Ta stickprov + Slutförda + Misslyckade + Pågående + Överhoppade + {0} poster utvalda för granskning. + Urvalsstorlek + Poster per urvalskörning; urvalet är deterministiskt för mallen och perioden. + Utvalda + Utvald + Bilagor som väntar på skanning + Bilagor avvisade av skanning + Skanning + Planera tillsyn + Planera omtillsyn + Planerad + Poängsätt varje kriterium 0-4; den viktade summan blir en procentsats. + Poängsätt granskning + Poängsatta + Poängsatt + Avsnitt + {0} avsnitt importerade. + Åter i drift + Ta ur drift + Larma besättningar + Underskriftens namn + Inskrivet namn på den som intygar tillsynen + Installerade brandvarnare + Sprinkler + Kvadratfot + Personal + Kommaavgränsade användar-ID för de medlemmar som deltog. + Stigarledning + Starta tillsyn + Påbörjad + Statiskt (psi) + Förvaringsplats + Våningar + Väntande inskick + Misslyckade inskick + Inlämnat + Föreslaget objekt + Bytet är blockerat tills inventeringen körts, varje kandidat avgjorts och ingen insatsplan är oavstämd. + Flytta skrivägandet till Register + Jag förstår att insatsplansidorna i Kontakter och apparna blir skrivskyddade projektioner av objekt. + Flytta skrivägandet nu? Redigering av insatsplaner i Kontakter upphör omedelbart. + Efter bytet redigeras insatsplaner bara som objekt. Kontakter och apparna fortsätter via projektioner; återställning är en databasåterställning, inte en knapp. + Bytt + Taktisk sammanfattning + Fönster + test förfallet + Testad + Överlämna förvaring + Till + Till extern part + Överlämnat + Ändra status + Trender + Sedan + Avlänka + Oavstämda insatsplaner + Uppladdad + Anteckningar om försörjning + Giltighet (dagar) + Överträdelse sparad. + Överträdelse uppdaterad. + Överträdelser + Vattenavstängning + Anteckningar om vattenförsörjning + Workflow-körningar diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index 850355c9c..6dde03dee 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -1123,6 +1123,11 @@ Призначити номер Послідовність на станцію Послідовність за інцидентом + Записи на виклик + Кілька на виклик + Один авторитетний на виклик + Один на суб'єкта на виклик + Скільки записів цього визначення може мати один виклик. Перевіряється під час створення; анульований або скасований запис звільняє своє місце. Скидати щороку Класифікація Зберігання (роки) @@ -1418,4 +1423,414 @@ Статус джерела випереджає локальне заповнення Статус джерела відстає від локального заповнення Підтримується конектором + Журнал доступу + Примітки щодо доступу + Додати доказ + Додати небезпеку + Додати учасника + Додати нотатку + Додати передачу + Додати порушення + Установа + Охоронна компанія + Панель сигналізації + Усі програми + Рекомендовані виправлення + Заявник + Ел. пошта заявника + Телефон заявника + Подано + Подати заявку + Затвердити висновки + Висновки має затвердити рецензент або керівник, який не є їх автором. + Вкладення додано. + Виберіть файл для вкладення. + Вкладення видалено. + Аудиторія + Відмови в доступі + Середній бал + За автором + За критерієм + За визначенням + За підрозділом + Скасувати перевірку + Скасувати цю перевірку? Запис залишиться в історії. + Кандидата пов’язано. + Кандидата відхилено. + Кандидати + Доступ до цієї справи мають лише її учасники, кожне читання фіксується. + Справу закрито. + Учасники справи + Нотатки справи + Справу {0} відкрито. + Справу відновлено. + Справу збережено. + Версія каталогу + Класифікація причини + Деталі причини + Контрольний список + Один пункт у рядку: ключ | текст | обов’язковий (y/n) | id розділу норми. + Пункт перевірки + Закрити справу + Закрити цю справу? Нотатки стануть доступними лише для читання. + Закрити перевірку + Причина закриття + Видання + Розділ норми збережено. + Текст розділу + Розділи норм + Збірку норм збережено. + Збірки норм + Протипожежні та будівельні норми, за якими підрозділ проводить перевірки. Розділи мають типову серйозність і строк усунення порушень. + Зібрано з + Зібрано + Коментарі + Завершити перевірку + Умови + Шаблон умов + ID контакту + Контакт пов’язано. + Зв’язок з контактом видалено. + Коригувальна дія + Створити новий об’єкт з цього джерела + Критерії + Один критерій у рядку: ключ | текст | вага. + Бал (0-4) + Вага + Інвентаризація знаходить плани реагування, контакти з адресою та точки інтересу, групує їх за адресою і близькістю та пропонує об’єкти. Вирішіть кожного кандидата перед передачею права запису до Записів. + Зниження ризиків у громаді + Захід видалено. + Захід збережено. + Підсумок за період + Зберігач + Ланцюг зберігання + Передачі лише додаються; кожна вказує, хто передав, хто отримав, чому та кінцевий стан. + Зберігання передано. + Обґрунтування рішення + Днів на усунення + Типова серйозність + Видалити захід + Видалити цей захід? + Видалити гідрант + Видалити цей гідрант? Разом з ним зникнуть випробування витрати та історія обслуговування. + Видалити об’єкт + Видалити цей об’єкт? Перевірки, дозволи та небезпеки збережуть історію, але втратять батьківський запис. + Термін + Редагувати гідрант + Редагувати об’єкт + Редагувати дозвіл + Діє з + Вимкнення електрики + Контакт для екстрених випадків + Доказ додано. + Докази + № доказу + Закінчується + Залиште порожнім, щоб застосувати строк дії типу дозволу від дати видачі. + Незабаром закінчується + Експортувати пакет + Не пройдено + Розташування підключення насосів + Збір + Лише довідково; рахунок тут фіксується, а не стягується. + Збір сплачено + Збір зафіксовано. + Файл + Висновки + Висновки затверджено. + Затверджено + Очікують затвердження, зафіксовано + Висновки зафіксовано та надіслано на затвердження. + Висновки повернуто автору. + Пожежна сигналізація + Клас витрати + Витрата (гал/хв) + Зафіксовані випробування витрати автоматично оновлюють це значення та клас NFPA 291. + Витрата обчислюється як 29,83 × c × d² × √піто і округлюється до 10 гал/хв; клас за NFPA 291. + Випробування витрати зафіксовано. + Випробування витрати + Періодичність (місяці) + Від + Перекриття газу + Код воріт + Загальні примітки про небезпеки + Створити належні перевірки + Створено перевірок: {0}. + Небезпеку видалено. + Небезпеку збережено. + Небезпеки + Небезпечні матеріали на об’єкті + Години + Гідрантів: {0}, не працює: {1}, потребують випробування: {2}. + Гідрант видалено. + Карта + Колір відповідає класу витрати NFPA 291; чорний означає не працює. + № гідранта + Власник + Гідрант збережено. + Гідранти + Імпортувати гідранти + CSV із рядком заголовка: number, latitude, longitude, type, address, main_size, flow_gpm, owner. Наявні номери оновлюються. + Неімпортовані рядки + Прочитано рядків: {0}, створено: {1}, оновлено: {2}, відхилено: {3}. + Імпортувати розділи + CSV із рядком заголовка: section, title, text, severity (1-4), correction_days. + Працює + Інцидент пов’язано; поточну редакцію закріплено. + Опис інциденту + Зв’язок з інцидентом видалено. + Включно із закритими + Перевірку скасовано. + Перевірку закрито. + Перевірку завершено; непройдені пункти створили порушення. + Примітки + № перевірки + Програми перевірок + Програма поєднує контрольний список із періодичністю та типами об’єктів, до яких вона застосовується; з неї створюються належні перевірки. + Результат + Перевірку заплановано. + Перевірку розпочато. + Перевірки + Інспектор + Інвентаризовано + Переглянуто джерел: {0}, створено кандидатів: {1}, оновлено: {2}, уже вирішено: {3}. + Розслідування + Показано лише справи, учасником яких ви є. Кожне читання справи записується до журналу доступу. + Номер рахунку + Видати припис + Видано + Примітка + Юрисдикція + Ключовий сейф + Остання перевірка + Останнє обслуговування + Останній перегляд + Останнє випробування + Головний слідчий + Спроби запису в старий модуль + Пов’язати + Пов’язати контакт + Пов’язати інцидент + Пов’язані контакти + Пов’язано + Пов’язані інциденти + Пов’язування закріплює редакцію інциденту на той момент; висновки ніколи не змінюють звіт про інцидент. + Діаметр магістралі (дюйми) + Журнал обслуговування + Обслуговування зафіксовано. + Позначити переглянутим + Упевненість збігу + Учасника додано. + Учасника видалено. + Об’єднати цей об’єкт із цільовим? Небезпеки, зв’язки та історія переносяться; цей запис стає посиланням. + Об’єднати з + ID цільового об’єкта + Найближчий гідрант + Нова збірка норм + Новий захід + Новий гідрант + Новий об’єкт + Новий дозвіл + Новий тип дозволу + Нова програма + Нова рубрика + У цьому вікні немає сповіщень чи попереджень. + Немає вкладень. + Немає кандидатів. Запустіть інвентаризацію, щоб знайти плани реагування та контакти для узгодження. + Немає справ. + У цій програмі немає пунктів перевірки. + Немає заходів за цей період. + Випробувань витрати не зафіксовано. + Небезпек не зафіксовано. + Гідрантів ще немає. + Немає перевірок. + Немає пов’язаних контактів. + Обслуговування не зафіксовано. + Немає відповідних об’єктів. + Немає перевірок, що очікують оцінки. + Немає відповідних дозволів. + Циклів розгляду проєкту ще немає. + Немає оцінених перевірок за цей період. + Рубрик ще немає. Першу створює адміністратор підрозділу. + Немає порушень. + Н/З + Текст нотатки + Нотатку збережено. + Тема + Припис видано. + Номер припису + Об’єкти + Доступ + Об’єктів: {0} + Об’єкт видалено. + Години використання + Ідентифікація та місцезнаходження + Об’єкт об’єднано. + Цей об’єкт об’єднано з + № об’єкта + Протипожежний захист і комунікації + Об’єкт позначено переглянутим. + Об’єкт збережено. + Пошук за номером, назвою чи адресою + Будівля + Небезпеки й тактика + Тип об’єкта + Застосовується до типів об’єктів + Номери типів через кому; порожньо означає всі типи. + Місткість + Мешканці, які потребують допомоги + Відкрити справу + Ви станете головним слідчим. Додайте учасників після відкриття; читати справу можуть лише учасники. + Відкриті порушення + Відкрито + Опис місця виникнення + Не працює + Причина непрацездатності + Невдалі події + Черга вихідних подій + Найстаріша в очікуванні + Події в очікуванні + Результат + Вихід (дюйми) + Ім’я власника + Плани реагування досі ведуться в Контактах. Узгодьте відповідність, щоб передати право запису об’єктам. + Об’єкти в Записах володіють планами реагування. Сторінки Контактів і застосунки читають проєкції звідси. + Право запису передано об’єктам у Записах. + Кадастровий номер + Пройдено + Перевірки для оцінювання + Виконано + Код + № дозволу + Дозвіл збережено. + Стан дозволу оновлено. + Тип дозволу + Тип дозволу збережено. + Типи дозволів + Дозволи + закріплена редакція + Піто (psi) + Результат розгляду + Розгляд проєкту зафіксовано. + Розгляд проєкту + ID точки інтересу + Відповідність планів реагування + Право запису планів реагування + Дата перегляду цього об’єкта минула. + Профілактика + Програму збережено. + Захищений вміст приховано; розкрийте його перед цією дією. + Захист даних увімкнено + Перевірка якості + Вибірка детерміновано добирає завершені записи за рубрикою; оцінка зважена та формує тенденції. + Рекомендувати виправлення + Рекомендує виправити звіт про інцидент + Висновки ніколи не редагують інцидент. Рекомендація передається через процес виправлення запису. + Зафіксувати збір + Зафіксувати висновки + Зафіксувати випробування витрати + Зафіксувати обслуговування + Зафіксувати розгляд проєкту + Зафіксував + Створено записів + Завершено записів + Стан випуску + Базова телеметрія активації Записів: спроби запису в старий модуль, затримка черги подій, запуски Workflow, сканування та лічильники профілактики. Кожне джерело переходить у попередження замість помилки сторінки. + Записи активовано + Записи з простроченим переглядом + Номер посилання + Передачу додано. + Передачу оновлено. + Передачі + Повторна перевірка + Відхилити + Видалити це вкладення? + Відновити справу + Потрібна витрата води (гал/хв) + Потребує розгляду проєкту + Залишковий (psi) + Кінцевий стан + Повернути автору + Повернуто в роботу + Перевірки + Цикл + Примітка рецензента + Примітки розгляду + Прострочений перегляд + Перевірку оцінено. + Розглянуто + Тип даху + Рубрику збережено. + Рубрики + Запустити інвентаризацію + Вибірка + Завершено + Невдалі + Виконуються + Пропущено + Відібрано записів для перевірки: {0}. + Розмір вибірки + Записів на одну вибірку; добір детермінований для рубрики та періоду. + Відібрано + Відібрано + Вкладення, що очікують сканування + Вкладення, відхилені скануванням + Сканування + Запланувати перевірку + Запланувати повторну перевірку + Заплановано + Оцініть кожен критерій від 0 до 4; зважена сума стає відсотком. + Оцінити перевірку + Оцінено + Оцінено + Розділ + Імпортовано розділів: {0}. + Повернути в роботу + Вивести з роботи + Сповіщати екіпажі + Ім’я підписанта + Введене ім’я особи, що засвідчує перевірку + Встановлено димових сповіщувачів + Спринклери + Кв. футів + Персонал + ID користувачів через кому для учасників заходу. + Стояк + Розпочати перевірку + Розпочато + Статичний (psi) + Місце зберігання + Поверхи + Подання в очікуванні + Невдалі подання + Подано + Запропонований об’єкт + Перемикання заблоковано, доки не виконано інвентаризацію, не вирішено кожного кандидата і залишаються неузгоджені плани. + Передати право запису до Записів + Я розумію, що сторінки планів реагування в Контактах і застосунки стають проєкціями об’єктів лише для читання. + Передати право запису зараз? Редагування планів реагування в Контактах припиниться негайно. + Після перемикання плани реагування редагуються лише як об’єкти. Контакти й застосунки працюють з проєкцій; відкат означає відновлення бази даних, а не перемикач. + Перемкнуто + Тактичний підсумок + Вікно + потребує випробування + Випробувано + Передати зберігання + Кому + Зовнішній стороні + Передано + Змінити стан + Тенденції + З + Від’єднати + Неузгоджені плани реагування + Завантажено + Примітки про комунікації + Строк дії (днів) + Порушення збережено. + Порушення оновлено. + Порушення + Перекриття води + Примітки про водопостачання + Запуски Workflow diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx index 2ce521d8c..f657f8450 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx @@ -390,4 +390,6 @@ من يمكنه فرض الحجز القانوني ورفعه، وهو ما يعلّق مدة الاحتفاظ ويمنع إبطال السجل. إعادة إسناد مسودات السجلات من يمكنه نقل مسودة سجل إلى مؤلف آخر، مثلاً عندما يكون المؤلف الأصلي غير متاح. + إدارة بيانات الوقاية + من يستطيع إنشاء وتعديل المباني وبرامج التفتيش ومجموعات القوانين وصنابير الإطفاء والتصاريح وأنشطة الحد من مخاطر المجتمع. تتطلب قراءة بيانات الوقاية الوصول إلى السجلات فقط؛ أما التحقيقات فتخضع لصلاحية السجلات المقيدة وعضوية القضية. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx index 615333ba1..4f0529969 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx @@ -992,4 +992,6 @@ Wer rechtliche Sperren setzen und aufheben darf; sie setzen die Aufbewahrungsfrist aus und verhindern das Annullieren eines Berichts. Berichtsentwürfe neu zuweisen Wer einen Berichtsentwurf einem anderen Verfasser zuweisen darf, etwa wenn der ursprüngliche Verfasser nicht verfügbar ist. + Präventionsdaten verwalten + Wer Objekte, Prüfprogramme und Vorschriftensätze, Hydranten, Genehmigungen und Aktivitäten zur Risikominderung anlegen und ändern darf. Das Lesen von Präventionsdaten erfordert nur Zugriff auf Berichte; Ermittlungen unterliegen der Berechtigung für eingeschränkte Berichte und der Fallmitgliedschaft. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx index 2cf68d633..468966014 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx @@ -437,6 +437,8 @@ Ποιος μπορεί να θέτει και να αίρει νομικές δεσμεύσεις, οι οποίες αναστέλλουν τη διατήρηση και εμποδίζουν την ακύρωση αναφοράς. Ανάθεση πρόχειρων αναφορών σε άλλον Ποιος μπορεί να μεταφέρει ένα πρόχειρο αναφοράς σε άλλον συντάκτη, για παράδειγμα όταν ο αρχικός συντάκτης δεν είναι διαθέσιμος. + Διαχείριση δεδομένων πρόληψης + Ποιος μπορεί να δημιουργεί και να αλλάζει κτίρια, προγράμματα επιθεώρησης και σύνολα κωδίκων, πυροσβεστικούς κρουνούς, άδειες και δράσεις μείωσης κινδύνου στην κοινότητα. Η ανάγνωση δεδομένων πρόληψης απαιτεί μόνο πρόσβαση στα Αρχεία· οι έρευνες διέπονται από το δικαίωμα περιορισμένων αρχείων και τη συμμετοχή στην υπόθεση. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx index dff663d59..04c8e0712 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx @@ -437,6 +437,8 @@ Who can place and release legal holds, which suspend retention and prevent a record from being voided. Reassign Draft Records Who can move a draft record to a different author, for example when the original author is unavailable. + Manage Prevention Data + Who can create and change occupancies, inspection programs and code sets, hydrants, permits and community risk reduction activities. Reading prevention data needs only Records access; investigations are governed by the restricted-records permission plus case membership. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx index cb0c2a389..138829d2d 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx @@ -396,6 +396,8 @@ Quién puede aplicar y levantar retenciones legales, que suspenden la retención e impiden anular un registro. Reasignar borradores de registro Quién puede trasladar un borrador de registro a otro autor, por ejemplo cuando el autor original no está disponible. + Gestionar datos de prevención + Quién puede crear y modificar ocupaciones, programas de inspección y conjuntos de códigos, hidrantes, permisos y actividades de reducción de riesgos comunitarios. Leer datos de prevención solo requiere acceso a Registros; las investigaciones se rigen por el permiso de registros restringidos y la pertenencia al caso. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx index 2e09bf983..1e2951780 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx @@ -992,4 +992,6 @@ Qui peut poser et lever des conservations légales, qui suspendent la rétention et empêchent l'annulation d'un rapport. Réattribuer les brouillons de rapport Qui peut transférer un brouillon de rapport à un autre auteur, par exemple lorsque l'auteur d'origine est indisponible. + Gérer les données de prévention + Qui peut créer et modifier les occupations, les programmes d'inspection et les recueils de codes, les bornes d'incendie, les permis et les activités de réduction des risques communautaires. La lecture des données de prévention ne demande que l'accès aux dossiers ; les enquêtes relèvent de la permission des dossiers restreints et de l'appartenance au dossier. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx index 232dc51cf..fd594503a 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx @@ -992,4 +992,6 @@ Chi può applicare e rimuovere i vincoli legali, che sospendono la conservazione e impediscono l'invalidazione di un rapporto. Riassegnare bozze di rapporto Chi può trasferire una bozza di rapporto a un altro autore, ad esempio quando l'autore originale non è disponibile. + Gestire i dati di prevenzione + Chi può creare e modificare occupazioni, programmi di ispezione e raccolte di norme, idranti, permessi e attività di riduzione del rischio comunitario. La lettura dei dati di prevenzione richiede solo l'accesso ai Registri; le indagini sono regolate dal permesso sui registri riservati e dall'appartenenza al caso. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx index a43362f4a..c67159703 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx @@ -992,4 +992,6 @@ Kto może nakładać i zdejmować blokady prawne, które wstrzymują retencję i uniemożliwiają unieważnienie raportu. Przypisywanie wersji roboczych innym autorom Kto może przekazać wersję roboczą raportu innemu autorowi, np. gdy pierwotny autor jest niedostępny. + Zarządzanie danymi prewencji + Kto może tworzyć i zmieniać obiekty, programy kontroli i zbiory przepisów, hydranty, zezwolenia oraz działania na rzecz ograniczania ryzyka w społeczności. Odczyt danych prewencji wymaga jedynie dostępu do Rejestrów; dochodzenia podlegają uprawnieniu do rejestrów zastrzeżonych i członkostwu w sprawie. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx index 76710dd20..21db8cac9 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx @@ -992,4 +992,6 @@ Vem som får lägga och häva rättsliga spärrar, som pausar gallring och hindrar att en rapport ogiltigförklaras. Omfördela rapportutkast Vem som får flytta ett rapportutkast till en annan författare, till exempel när den ursprungliga författaren inte är tillgänglig. + Hantera förebyggande data + Vem som får skapa och ändra objekt, tillsynsprogram och regelverk, brandposter, tillstånd och aktiviteter för att minska risker i samhället. Att läsa förebyggande data kräver bara åtkomst till Register; utredningar styrs av behörigheten för begränsade register och medlemskap i ärendet. diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx index 0bc30b0c5..d45459eff 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx @@ -992,4 +992,6 @@ Хто може накладати та знімати юридичні утримання, які призупиняють строк зберігання та не дозволяють анулювати звіт. Перепризначення чернеток звітів Хто може передати чернетку звіту іншому автору, наприклад коли початковий автор недоступний. + Керування даними профілактики + Хто може створювати та змінювати об'єкти, програми перевірок і збірки норм, гідранти, дозволи та заходи зі зниження ризиків у громаді. Для читання даних профілактики достатньо доступу до Записів; розслідування регулюються дозволом на обмежені записи та членством у справі. diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 7cdc342d4..737d29a35 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -197,6 +197,12 @@ public enum AuditLogTypes // Advanced Data Protection: a department releasing (or restoring) the step-up prompt for a // client app. Weakening a protection control is exactly the kind of change that has to be // answerable later, so it is audited with the before and after mask. - DataProtectionStepUpExemptionsChanged + DataProtectionStepUpExemptionsChanged, + // Contact pre-plans and site attachments (Contacts plan Phase A, A7) + ContactPreplanAdded, + ContactPreplanUpdated, + ContactPreplanRemoved, + ContactAttachmentAdded, + ContactAttachmentRemoved } } diff --git a/Core/Resgrid.Model/CallSiteInfo.cs b/Core/Resgrid.Model/CallSiteInfo.cs new file mode 100644 index 000000000..bd9fa6db3 --- /dev/null +++ b/Core/Resgrid.Model/CallSiteInfo.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Everything a responder needs about the premises linked to a call, composed in one round trip + /// (Contacts plan Phase A, decision 3b). This is also the RMS NERIS authoring prefill source for + /// calls linked to a Contact (A8), so its shape is a consumed contract: additive changes only. + /// + public class CallSiteInfo + { + public int CallId { get; set; } + public int DepartmentId { get; set; } + public List Contacts { get; set; } = new List(); + } + + /// One linked contact's site knowledge: the contact, its pre-plan, hazards, live alert notes and file metadata. + public class CallSiteContactInfo + { + public Contact Contact { get; set; } + + /// 0 = Primary, 1 = Additional (matches CallContact.CallContactType). + public int CallContactType { get; set; } + + public ContactPreplan Preplan { get; set; } + public List Hazards { get; set; } = new List(); + + /// ShouldAlert notes that are not deleted and not expired. + public List AlertNotes { get; set; } = new List(); + + /// Attachment metadata only; is never loaded here. + public List Attachments { get; set; } = new List(); + } + + /// Lightweight per-call contact summary for list payloads (Contacts plan Phase A, decision 3a). + public class CallContactSummary + { + public Contact Contact { get; set; } + public int CallContactType { get; set; } + public bool HasPreplan { get; set; } + public int AlertNoteCount { get; set; } + public int HazardCount { get; set; } + } +} diff --git a/Core/Resgrid.Model/ContactAttachment.cs b/Core/Resgrid.Model/ContactAttachment.cs new file mode 100644 index 000000000..47ee699c1 --- /dev/null +++ b/Core/Resgrid.Model/ContactAttachment.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// A file attached to a Contact (site document, pre-plan PDF, floor plan, photo, drawing). Blob stored in the + /// row like CallAttachment/DispatchProtocolAttachment; list queries never load . + /// Contacts plan Phase A, decision 2. + /// + [Table("ContactAttachments")] + public class ContactAttachment : IEntity + { + /// Upload cap (30 MB), the same ceiling the protocol/call attachment uploads enforce. + public const int MaxSizeBytes = 30 * 1024 * 1024; + + [Key] + [Required] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int ContactAttachmentId { get; set; } + + [Required] + public string ContactId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + public int ContactAttachmentType { get; set; } + + /// User-facing title. + public string Name { get; set; } + + public string FileName { get; set; } + + /// MIME type. + public string FileType { get; set; } + + public int Size { get; set; } + + public byte[] Data { get; set; } + + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + + /// ADP row marker (catalog v12): true once name, file name and bytes are enveloped. + public bool IsProtected { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return ContactAttachmentId; } + set { ContactAttachmentId = (int)value; } + } + + [NotMapped] + public string TableName => "ContactAttachments"; + + [NotMapped] + public string IdName => "ContactAttachmentId"; + + [NotMapped] + public int IdType => 0; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/ContactAttachmentTypes.cs b/Core/Resgrid.Model/ContactAttachmentTypes.cs new file mode 100644 index 000000000..af643a1fc --- /dev/null +++ b/Core/Resgrid.Model/ContactAttachmentTypes.cs @@ -0,0 +1,13 @@ +namespace Resgrid.Model +{ + /// Category of a file attached to a Contact (Contacts plan Phase A, decision 2). Append only. + public enum ContactAttachmentTypes + { + Document = 0, + PrePlan = 1, + FloorPlan = 2, + SitePhoto = 3, + SiteDrawing = 4, + Other = 5 + } +} diff --git a/Core/Resgrid.Model/ContactPreplan.cs b/Core/Resgrid.Model/ContactPreplan.cs new file mode 100644 index 000000000..6047baf44 --- /dev/null +++ b/Core/Resgrid.Model/ContactPreplan.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// Structured pre-incident plan for a Contact (NFPA 1620 core fields; Contacts plan Phase A, decision 1). + /// One live row per contact. The dispatch-facing structure master until the RMS occupancy master lands, + /// after which it becomes the lightweight projection (RMS plan section 4.3). + /// + public class ContactPreplan : IEntity + { + [Required] + public string ContactPreplanId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + [Required] + public string ContactId { get; set; } + + // Construction / occupancy + public int ConstructionType { get; set; } + public int RoofType { get; set; } + public int OccupancyType { get; set; } + public string OccupancyNotes { get; set; } + public string OccupancyHours { get; set; } + public int? OccupantLoad { get; set; } + public bool HasOccupantsNeedingAssistance { get; set; } + public string OccupantsNeedingAssistanceNotes { get; set; } + + // Utilities + public string GasShutoffLocation { get; set; } + public string ElectricShutoffLocation { get; set; } + public string WaterShutoffLocation { get; set; } + public string UtilityNotes { get; set; } + + // Access + public string KnoxBoxLocation { get; set; } + + /// + /// ADP catalog v12 field (contactpreplans.gatecode): enveloped at rest in a protected department and + /// revealed only through the protected read with a current grant. Never written to search, workflow + /// payloads, or audit snapshots in clear. + /// + public string GateCode { get; set; } + public string AlarmPanelLocation { get; set; } + public string AlarmCompany { get; set; } + public string AlarmCompanyPhone { get; set; } + public string AccessNotes { get; set; } + + // Water supply + public string NearestHydrantLocation { get; set; } + public int? RequiredFireFlowGpm { get; set; } + public string WaterSupplyNotes { get; set; } + + // On-site contacts + public string EmergencyContactName { get; set; } + public string EmergencyContactPhone { get; set; } + public string SecondaryContactName { get; set; } + public string SecondaryContactPhone { get; set; } + + // Hazards / tactics + public bool HazmatOnSite { get; set; } + public string GeneralHazardNotes { get; set; } + public string TacticalSummary { get; set; } + + // Review cycle + public DateTime? LastReviewedOn { get; set; } + public string ReviewedByUserId { get; set; } + public DateTime? NextReviewDue { get; set; } + + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + + /// ADP row marker (catalog v12): true once the cataloged text columns are enveloped. + public bool IsProtected { get; set; } + + [NotMapped] + public List Hazards { get; set; } + + [NotMapped] + public string TableName => "ContactPreplans"; + + [NotMapped] + public string IdName => "ContactPreplanId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return ContactPreplanId; } + set { ContactPreplanId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Hazards" }; + + /// True when a review date is set and has passed. + public bool IsReviewOverdue(DateTime utcNow) + { + return NextReviewDue.HasValue && NextReviewDue.Value < utcNow; + } + } +} diff --git a/Core/Resgrid.Model/ContactPreplanEnums.cs b/Core/Resgrid.Model/ContactPreplanEnums.cs new file mode 100644 index 000000000..3f0b4651d --- /dev/null +++ b/Core/Resgrid.Model/ContactPreplanEnums.cs @@ -0,0 +1,80 @@ +namespace Resgrid.Model +{ + /// + /// Building construction classification for a contact pre-plan (Contacts plan Phase A, decision 28 / A8). + /// Values follow the IBC/NFPA 220 construction types so each maps 1:1 onto the NERIS structure value set + /// through the RMS-owned crosswalk; there is deliberately no combined bucket. Append only. + /// + public enum ContactPreplanConstructionTypes + { + Unknown = 0, + TypeIFireResistive = 1, + TypeIINonCombustible = 2, + TypeIIIOrdinary = 3, + TypeIVHeavyTimber = 4, + TypeVWoodFrame = 5, + ManufacturedOrMobile = 6, + Other = 7 + } + + /// Roof form for a contact pre-plan. Append only. + public enum ContactPreplanRoofTypes + { + Unknown = 0, + Flat = 1, + Gable = 2, + Hip = 3, + Gambrel = 4, + Mansard = 5, + Shed = 6, + BowstringTruss = 7, + LightweightTruss = 8, + Other = 9 + } + + /// + /// Occupancy classification for a contact pre-plan, following the IBC occupancy groups (with the + /// residential group split the way NERIS location use distinguishes it). Append only. + /// + public enum ContactPreplanOccupancyTypes + { + Unknown = 0, + ResidentialSingleFamily = 1, + ResidentialMultiFamily = 2, + ResidentialLodging = 3, + ResidentialCareFacility = 4, + Assembly = 5, + Business = 6, + Educational = 7, + FactoryIndustrial = 8, + HighHazard = 9, + Institutional = 10, + Mercantile = 11, + Storage = 12, + Utility = 13, + Agricultural = 14, + Vacant = 15, + Other = 16 + } + + /// Premise hazard classification. Append only. + public enum ContactPreplanHazardTypes + { + General = 0, + Hazmat = 1, + Structural = 2, + Electrical = 3, + Biological = 4, + Animal = 5, + Occupant = 6, + Other = 7 + } + + /// Premise hazard severity, in ascending order. + public enum ContactPreplanHazardSeverities + { + Info = 0, + Caution = 1, + Danger = 2 + } +} diff --git a/Core/Resgrid.Model/ContactPreplanHazard.cs b/Core/Resgrid.Model/ContactPreplanHazard.cs new file mode 100644 index 000000000..0ab812e0a --- /dev/null +++ b/Core/Resgrid.Model/ContactPreplanHazard.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// A repeating premise hazard on a contact pre-plan (Contacts plan Phase A, decision 1). Typed severity + /// and optional GPS; ShouldAlert rows surface in the dispatch alert banner alongside alert notes. + /// + public class ContactPreplanHazard : IEntity + { + [Required] + public string ContactPreplanHazardId { get; set; } + + [Required] + public string ContactPreplanId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + /// Denormalized so hazards can be listed by contact without a join. + [Required] + public string ContactId { get; set; } + + public int HazardType { get; set; } + public int Severity { get; set; } + + [Required] + public string Title { get; set; } + public string Description { get; set; } + public string LocationDescription { get; set; } + + /// "lat,lng" like the rest of the platform. + public string GpsCoordinates { get; set; } + + public bool ShouldAlert { get; set; } + + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + + /// ADP row marker (catalog v12): true once the cataloged text columns are enveloped. + public bool IsProtected { get; set; } + + [NotMapped] + public string TableName => "ContactPreplanHazards"; + + [NotMapped] + public string IdName => "ContactPreplanHazardId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return ContactPreplanHazardId; } + set { ContactPreplanHazardId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs index 2a65ad0af..2d609b037 100644 --- a/Core/Resgrid.Model/FeatureFlagKeys.cs +++ b/Core/Resgrid.Model/FeatureFlagKeys.cs @@ -54,5 +54,26 @@ public static class FeatureFlagKeys /// Field Records surface in the Dispatch app. Depends on Records.System. Seeded off by M0152. public const string RecordsFieldDispatch = "Records.Field.Dispatch"; + + /// RMS-5 occupancy/property master and the Contacts pre-plan crosswalk. Depends on Records.System. Seeded off by M0186. + public const string RecordsPreventionOccupancy = "Records.Prevention.Occupancy"; + + /// RMS-5 inspection programs, code sets, inspections and violations. Depends on Records.Prevention.Occupancy. Seeded off by M0186. + public const string RecordsPreventionInspections = "Records.Prevention.Inspections"; + + /// RMS-5 hydrants and water sources. Depends on Records.System. Seeded off by M0186. + public const string RecordsPreventionHydrants = "Records.Prevention.Hydrants"; + + /// RMS-5 permits and plan review. Depends on Records.Prevention.Occupancy. Seeded off by M0186. + public const string RecordsPreventionPermits = "Records.Prevention.Permits"; + + /// RMS-5 community risk reduction activities. Depends on Records.System. Seeded off by M0186. + public const string RecordsPreventionCrr = "Records.Prevention.Crr"; + + /// RMS-5 investigation cases (restricted, case-level authorization). Depends on Records.System. Seeded off by M0186. + public const string RecordsInvestigations = "Records.Investigations"; + + /// RMS-4 optional post-finalization quality review. Depends on Records.System. Seeded off by M0186. + public const string RecordsQualityReview = "Records.QualityReview"; } } diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs index d62382104..a049a39ff 100644 --- a/Core/Resgrid.Model/PermissionTypes.cs +++ b/Core/Resgrid.Model/PermissionTypes.cs @@ -140,7 +140,15 @@ public enum PermissionTypes ManageRecordLegalHold = 66, /// Reassign an unfinalized draft to another author (Record_Reassign). No-row default: department and group admins. - ReassignRecordDrafts = 67 + ReassignRecordDrafts = 67, + + /// + /// Manage prevention data: occupancies, inspection programs and code sets, hydrants, permits and CRR + /// (Record_PreventionAdmin). No-row default: department admins. RMS-5, registry value 69 (from the pool + /// released on 2026-08-27; 68 is Unified Search's ManageSearchIndex). Reading prevention data needs only + /// Record_View; investigations use ViewRestrictedRecords (59) plus case membership, never this value. + /// + RecordsPreventionAdmin = 69 } } diff --git a/Core/Resgrid.Model/Records/ExternalOrderFeed.cs b/Core/Resgrid.Model/Records/ExternalOrderFeed.cs index 08e8f784c..c0ebe7208 100644 --- a/Core/Resgrid.Model/Records/ExternalOrderFeed.cs +++ b/Core/Resgrid.Model/Records/ExternalOrderFeed.cs @@ -80,6 +80,19 @@ public static string LocalStatusOf(RmsDeploymentFillStatus status) } } + /// + /// A value the source controls, made safe to repeat back. A parse problem is stored on the run and shown + /// to an administrator, so a source cannot use it to put arbitrary text or length in front of a person: + /// what comes back is a short token of plain characters, or a placeholder. + /// + private static string Safe(string sourceValue) + { + if (string.IsNullOrWhiteSpace(sourceValue)) + return "(none)"; + var kept = new string(sourceValue.Trim().Where(c => char.IsLetterOrDigit(c) || c == '.' || c == '-' || c == '_').Take(32).ToArray()); + return kept.Length == 0 ? "(unreadable)" : kept; + } + /// Parses and validates one feed document. Problems are returned, never thrown, so a run can log them. public static ExternalOrderFeed Parse(string json, out List problems) { @@ -108,7 +121,7 @@ public static ExternalOrderFeed Parse(string json, out List problems) } if (!string.Equals(feed.Contract, Version, StringComparison.Ordinal)) { - problems.Add($"The feed declares contract '{feed.Contract}'; this connector speaks '{Version}'."); + problems.Add($"The feed declares contract '{Safe(feed.Contract)}'; this connector speaks '{Version}'."); return null; } feed.Orders ??= new List(); diff --git a/Core/Resgrid.Model/Records/FieldRecordsContracts.cs b/Core/Resgrid.Model/Records/FieldRecordsContracts.cs index 4bed5ab27..90e804832 100644 --- a/Core/Resgrid.Model/Records/FieldRecordsContracts.cs +++ b/Core/Resgrid.Model/Records/FieldRecordsContracts.cs @@ -46,10 +46,16 @@ public static class ExclusionReasons public const string Retired = "retired"; public const string NotPublished = "not_published"; public const string ProtectedDataUnavailable = "protected_data_unavailable"; + + /// + /// The server could not read the catalog this time — a transient failure, not a decision about this app + /// or this member. A client retries; unlike every other refusal it is not grounds for discarding a cache. + /// + public const string CatalogUnavailable = "catalog_unavailable"; public static readonly IReadOnlyList All = new[] { OriginNotField, ModuleDisabled, RecordsNotUsable, AppDisabled, AppVersionTooOld, NotMember, SurfaceNotEnabled, ContextNotAllowed, ContextNotVerified, - CapabilityUnsupported, Retired, NotPublished, ProtectedDataUnavailable + CapabilityUnsupported, Retired, NotPublished, ProtectedDataUnavailable, CatalogUnavailable }; } diff --git a/Core/Resgrid.Model/Records/OccupancyDispatchProjectionV1.cs b/Core/Resgrid.Model/Records/OccupancyDispatchProjectionV1.cs new file mode 100644 index 000000000..a97eac0b2 --- /dev/null +++ b/Core/Resgrid.Model/Records/OccupancyDispatchProjectionV1.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + /// + /// The additive-stable structure projection RMS exposes to Contacts and dispatch once it owns the occupancy master + /// (RMS plan section 4.3: "exposes an additive-stable projection to Contacts rather than duplicating structure + /// data"). Fields are only ever added; is bumped when they are. Protected values ride + /// the REDACTED sentinel for a caller without a grant and names them, exactly like the + /// Contacts Phase A pre-plan payload, so every existing consumer keeps its concealment logic. + /// + public class OccupancyDispatchProjectionV1 + { + public const int CurrentContractVersion = 1; + + public int ContractVersion { get; set; } = CurrentContractVersion; + public string OccupancyId { get; set; } + public string OccupancyNumber { get; set; } + public string Name { get; set; } + public int Status { get; set; } + public string AddressText { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + + public int ConstructionType { get; set; } + public int RoofType { get; set; } + public int OccupancyType { get; set; } + public int? Stories { get; set; } + public int? OccupantLoad { get; set; } + public string OccupancyHours { get; set; } + public bool HasOccupantsNeedingAssistance { get; set; } + public string OccupantsNeedingAssistanceNotes { get; set; } + + public int SprinklerType { get; set; } + public bool HasStandpipe { get; set; } + public bool HasFireAlarm { get; set; } + public string FdcLocation { get; set; } + + public string GasShutoffLocation { get; set; } + public string ElectricShutoffLocation { get; set; } + public string WaterShutoffLocation { get; set; } + public string UtilityNotes { get; set; } + + public string KnoxBoxLocation { get; set; } + public string GateCode { get; set; } + public string AlarmPanelLocation { get; set; } + public string AlarmCompany { get; set; } + public string AlarmCompanyPhone { get; set; } + public string AccessNotes { get; set; } + + public string NearestHydrantId { get; set; } + public string NearestHydrantNumber { get; set; } + public string NearestHydrantLocation { get; set; } + public int? RequiredFireFlowGpm { get; set; } + public string WaterSupplyNotes { get; set; } + + public string EmergencyContactName { get; set; } + public string EmergencyContactPhone { get; set; } + + public bool HazmatOnSite { get; set; } + public string GeneralHazardNotes { get; set; } + public string TacticalSummary { get; set; } + public List Hazards { get; set; } = new List(); + public List Contacts { get; set; } = new List(); + + public DateTime? LastReviewedOn { get; set; } + public DateTime? NextReviewDue { get; set; } + public bool IsReviewOverdue { get; set; } + public DateTime? LastInspectedOn { get; set; } + public int OpenViolationCount { get; set; } + /// Source kinds that supplied at least one field (provenance summary): ContactPreplan, Contact, Poi, Records. + public List Sources { get; set; } = new List(); + + public bool IsProtected { get; set; } + public string ProtectedReason { get; set; } + public List RedactedFields { get; set; } = new List(); + + /// + /// The same structure knowledge in the Contacts Phase A pre-plan shape, so the Call Site Info tab, the v4 + /// GetCallSiteInfo payload and the four apps keep working unchanged after a department's structure writes move + /// to RMS. Hazards are returned separately because the Contacts shape carries them as a list. + /// + public ContactPreplan ToContactPreplanView(string contactId, int departmentId) + { + var view = new ContactPreplan + { + ContactPreplanId = "occ:" + OccupancyId, + DepartmentId = departmentId, + ContactId = contactId, + ConstructionType = ConstructionType, + RoofType = RoofType, + OccupancyType = OccupancyType, + OccupancyHours = OccupancyHours, + OccupantLoad = OccupantLoad, + HasOccupantsNeedingAssistance = HasOccupantsNeedingAssistance, + OccupantsNeedingAssistanceNotes = OccupantsNeedingAssistanceNotes, + GasShutoffLocation = GasShutoffLocation, + ElectricShutoffLocation = ElectricShutoffLocation, + WaterShutoffLocation = WaterShutoffLocation, + UtilityNotes = UtilityNotes, + KnoxBoxLocation = KnoxBoxLocation, + GateCode = GateCode, + AlarmPanelLocation = AlarmPanelLocation, + AlarmCompany = AlarmCompany, + AlarmCompanyPhone = AlarmCompanyPhone, + AccessNotes = AccessNotes, + NearestHydrantLocation = NearestHydrantLocation ?? NearestHydrantNumber, + RequiredFireFlowGpm = RequiredFireFlowGpm, + WaterSupplyNotes = WaterSupplyNotes, + EmergencyContactName = EmergencyContactName, + EmergencyContactPhone = EmergencyContactPhone, + HazmatOnSite = HazmatOnSite, + GeneralHazardNotes = GeneralHazardNotes, + TacticalSummary = TacticalSummary, + LastReviewedOn = LastReviewedOn, + NextReviewDue = NextReviewDue, + IsProtected = IsProtected, + AddedOn = LastReviewedOn ?? DateTime.UtcNow + }; + view.Hazards = Hazards.Select(h => h.ToContactHazardView(view, departmentId)).ToList(); + return view; + } + } + + public class OccupancyDispatchHazardV1 + { + public string HazardId { get; set; } + public int HazardType { get; set; } + public int Severity { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string LocationDescription { get; set; } + public string GpsCoordinates { get; set; } + public bool ShouldAlert { get; set; } + public List RedactedFields { get; set; } = new List(); + + public ContactPreplanHazard ToContactHazardView(ContactPreplan preplan, int departmentId) + { + return new ContactPreplanHazard + { + ContactPreplanHazardId = "occ:" + HazardId, + DepartmentId = departmentId, + ContactPreplanId = preplan.ContactPreplanId, + ContactId = preplan.ContactId, + HazardType = HazardType, + Severity = Severity, + Title = Title, + Description = Description, + LocationDescription = LocationDescription, + GpsCoordinates = GpsCoordinates, + ShouldAlert = ShouldAlert, + IsProtected = RedactedFields.Count > 0, + AddedOn = DateTime.UtcNow + }; + } + } + + public class OccupancyDispatchContactV1 + { + public string ContactId { get; set; } + public int Role { get; set; } + public bool IsPrimary { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordCardinalityException.cs b/Core/Resgrid.Model/Records/RecordCardinalityException.cs new file mode 100644 index 000000000..27230b8a6 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordCardinalityException.cs @@ -0,0 +1,27 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// A definition's already has its Record for this Call (plan section 5.2.1). + /// The existing Record is carried on the exception rather than discarded, because the answer for the author is + /// "here is the one that exists, open it" — not an error page. + /// + public sealed class RecordCardinalityException : InvalidOperationException + { + public RecordCardinalityException(string existingRecordId, string definitionKey, RmsRecordCardinality cardinality, string message) + : base(message) + { + ExistingRecordId = existingRecordId; + DefinitionKey = definitionKey; + Cardinality = cardinality; + } + + /// The Record that already holds the key; the caller opens this instead of creating another. + public string ExistingRecordId { get; } + + public string DefinitionKey { get; } + + public RmsRecordCardinality Cardinality { get; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordPermissionCatalog.cs b/Core/Resgrid.Model/Records/RecordPermissionCatalog.cs index f9db03f22..f39f40fa1 100644 --- a/Core/Resgrid.Model/Records/RecordPermissionCatalog.cs +++ b/Core/Resgrid.Model/Records/RecordPermissionCatalog.cs @@ -64,11 +64,13 @@ public static class RecordPermissionCatalog new RecordPermissionDescriptor(PermissionTypes.ManageRecordReports, PermissionActions.DepartmentAdminsOnly, false, "Manage saved Record reports", everyoneOffered: false), new RecordPermissionDescriptor(PermissionTypes.ManageRecordDisclosures, PermissionActions.DepartmentAdminsOnly, false, "Manage public-records disclosures", everyoneOffered: false), new RecordPermissionDescriptor(PermissionTypes.ManageRecordLegalHold, PermissionActions.DepartmentAdminsOnly, false, "Place and release legal holds", everyoneOffered: false), - new RecordPermissionDescriptor(PermissionTypes.ReassignRecordDrafts, PermissionActions.DepartmentAndGroupAdmins, true, "Reassign draft Records") + new RecordPermissionDescriptor(PermissionTypes.ReassignRecordDrafts, PermissionActions.DepartmentAndGroupAdmins, true, "Reassign draft Records"), + new RecordPermissionDescriptor(PermissionTypes.RecordsPreventionAdmin, PermissionActions.DepartmentAdminsOnly, false, "Manage prevention data (occupancies, inspections, hydrants, permits, CRR)", everyoneOffered: false) }; public const int FirstValue = 50; - public const int LastValue = 67; + /// Highest RMS value. 68 (Unified Search's ManageSearchIndex) sits inside the range and is not a Records permission; iterate , never the range. + public const int LastValue = 69; public static RecordPermissionDescriptor Get(PermissionTypes type) { diff --git a/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs b/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs new file mode 100644 index 000000000..0092c181d --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace Resgrid.Model +{ + /// The separately enabled RMS-5 modules and the feature flag that gates each (RMS plan section 6, RMS-5). + public enum RecordsPreventionModule + { + Occupancy = 1, + Inspections = 2, + Hydrants = 3, + Permits = 4, + Crr = 5, + Investigations = 6, + QualityReview = 7 + } + + public static class RecordsPreventionModules + { + public static string FlagKey(RecordsPreventionModule module) + { + switch (module) + { + case RecordsPreventionModule.Occupancy: return FeatureFlagKeys.RecordsPreventionOccupancy; + case RecordsPreventionModule.Inspections: return FeatureFlagKeys.RecordsPreventionInspections; + case RecordsPreventionModule.Hydrants: return FeatureFlagKeys.RecordsPreventionHydrants; + case RecordsPreventionModule.Permits: return FeatureFlagKeys.RecordsPreventionPermits; + case RecordsPreventionModule.Crr: return FeatureFlagKeys.RecordsPreventionCrr; + case RecordsPreventionModule.Investigations: return FeatureFlagKeys.RecordsInvestigations; + case RecordsPreventionModule.QualityReview: return FeatureFlagKeys.RecordsQualityReview; + default: throw new ArgumentOutOfRangeException(nameof(module)); + } + } + + public static readonly IReadOnlyList All = new[] + { + RecordsPreventionModule.Occupancy, RecordsPreventionModule.Inspections, RecordsPreventionModule.Hydrants, + RecordsPreventionModule.Permits, RecordsPreventionModule.Crr, RecordsPreventionModule.Investigations, RecordsPreventionModule.QualityReview + }; + } + + /// Thrown when a prevention module is not enabled for the department; surfaces as 404 on the API and Web. + public class RecordsModuleDisabledException : InvalidOperationException + { + public RecordsModuleDisabledException(RecordsPreventionModule module) + : base($"The Records {module} module is not enabled for this department.") + { + Module = module; + } + + public RecordsPreventionModule Module { get; } + } + + /// Address folding shared by the crosswalk inventory and the occupancy master so both sides match the same way. + public static class AddressNormalizer + { + private static readonly Regex NonAlphanumeric = new Regex("[^A-Z0-9 ]", RegexOptions.Compiled); + private static readonly Regex Whitespace = new Regex(" {2,}", RegexOptions.Compiled); + private static readonly Dictionary Abbreviations = new Dictionary(StringComparer.Ordinal) + { + ["STREET"] = "ST", ["AVENUE"] = "AVE", ["ROAD"] = "RD", ["DRIVE"] = "DR", ["BOULEVARD"] = "BLVD", ["LANE"] = "LN", + ["COURT"] = "CT", ["PLACE"] = "PL", ["HIGHWAY"] = "HWY", ["PARKWAY"] = "PKWY", ["NORTH"] = "N", ["SOUTH"] = "S", + ["EAST"] = "E", ["WEST"] = "W", ["SUITE"] = "STE", ["APARTMENT"] = "APT", ["UNIT"] = "UNIT", ["FIRST"] = "1ST", + ["SECOND"] = "2ND", ["THIRD"] = "3RD" + }; + + public static string Normalize(string address) + { + if (string.IsNullOrWhiteSpace(address)) + return null; + var upper = address.Normalize(NormalizationForm.FormD).ToUpperInvariant(); + var sb = new StringBuilder(upper.Length); + foreach (var c in upper) + if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) + sb.Append(c == ',' || c == '.' || c == '-' || c == '#' ? ' ' : c); + var folded = NonAlphanumeric.Replace(sb.ToString(), " "); + var parts = Whitespace.Replace(folded, " ").Trim().Split(' '); + for (var i = 0; i < parts.Length; i++) + if (Abbreviations.TryGetValue(parts[i], out var abbreviation)) + parts[i] = abbreviation; + var result = string.Join(" ", parts).Trim(); + return result.Length == 0 ? null : result; + } + } + + /// Result of an inventory pass over the department's structure sources. + public class OccupancyCrosswalkInventoryResult + { + public int SourcesScanned { get; set; } + public int CandidatesCreated { get; set; } + public int CandidatesUpdated { get; set; } + public int Groups { get; set; } + public int AlreadyDecided { get; set; } + } + + public class OccupancyReconciliationStatus + { + public RmsOccupancyOwnershipState State { get; set; } + public DateTime? InventoriedOn { get; set; } + public DateTime? SwitchedOn { get; set; } + public int Candidates { get; set; } + public int Linked { get; set; } + public int Rejected { get; set; } + public int Occupancies { get; set; } + /// Contact pre-plans that exist but have no Linked or Rejected crosswalk row; each blocks the switch. + public int UnreconciledPreplans { get; set; } + public bool CanSwitchToRecords => State != RmsOccupancyOwnershipState.RecordsOwned && Candidates == 0 && UnreconciledPreplans == 0 && InventoriedOn.HasValue; + } + + /// Lightweight map-layer row for hydrants (v4 and the web map); never carries notes. + public class HydrantMapPoint + { + public string HydrantId { get; set; } + public string HydrantNumber { get; set; } + public int Type { get; set; } + public decimal Latitude { get; set; } + public decimal Longitude { get; set; } + public int FlowClass { get; set; } + public int? FlowGpm { get; set; } + public bool InService { get; set; } + public decimal? MainSizeInches { get; set; } + } + + /// One line of a hydrant CSV import; parsed leniently, validated strictly. + public class HydrantImportRow + { + public int Line { get; set; } + public string HydrantNumber { get; set; } + public decimal Latitude { get; set; } + public decimal Longitude { get; set; } + public string Type { get; set; } + public string Address { get; set; } + public string MainSize { get; set; } + public string FlowGpm { get; set; } + public string Owner { get; set; } + public string Error { get; set; } + } + + public class HydrantImportResult + { + public int RowsRead { get; set; } + public int Created { get; set; } + public int Updated { get; set; } + public List Rejected { get; set; } = new List(); + } + + public class PreventionSummary + { + public int Occupancies { get; set; } + public int OccupanciesReviewOverdue { get; set; } + public int InspectionsScheduled { get; set; } + public int InspectionsDue { get; set; } + public int ViolationsOpen { get; set; } + public int ViolationsOverdue { get; set; } + public int Hydrants { get; set; } + public int HydrantsOutOfService { get; set; } + public int HydrantsTestDue { get; set; } + public int PermitsActive { get; set; } + public int PermitsExpiringSoon { get; set; } + public int PermitsAwaitingReview { get; set; } + public int CrrActivitiesLast90Days { get; set; } + public int CrrSmokeAlarmsLast90Days { get; set; } + public int InvestigationCasesOpen { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordsReleaseTelemetry.cs b/Core/Resgrid.Model/Records/RecordsReleaseTelemetry.cs new file mode 100644 index 000000000..b920ac74a --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsReleaseTelemetry.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// The RMS-4 baseline telemetry (RMS plan section 6, RMS-4: "legacy-write attempts, outbox lag, Workflow + /// run/skip/retry counts, scope-leak alerts, protected-content scans — so a problem after release is visible + /// rather than reported by a customer"). Counts and ages only; nothing here is record content. One snapshot per + /// department per read; the daily worker logs a structured line so the numbers exist in the log store too. + /// + public class RecordsReleaseTelemetry + { + public int DepartmentId { get; set; } + public DateTime GeneratedOn { get; set; } = DateTime.UtcNow; + /// Window the rolling counters cover, in hours. + public int WindowHours { get; set; } + + public bool RecordsActive { get; set; } + public DateTime? ActivatedOn { get; set; } + + /// Denied legacy Log/UnitLog mutation attempts inside the window (RmsAccessAudits action LegacyWriteDenied). + public int LegacyWriteAttempts { get; set; } + /// Denied Records reads/actions inside the window (RmsAccessAudits action Denied) — the runtime face of the scope-leak suite. + public int AuthorizationDenials { get; set; } + + public int OutboxPending { get; set; } + public int OutboxFailed { get; set; } + /// Age of the oldest undispatched outbox row; the section 5.12 alert threshold is 60 sustained seconds. + public double? OutboxOldestPendingSeconds { get; set; } + public bool OutboxLagAlert { get; set; } + + public int WorkflowRunsCompleted { get; set; } + public int WorkflowRunsSkipped { get; set; } + public int WorkflowRunsFailed { get; set; } + public int WorkflowRunsRunning { get; set; } + + public int AttachmentsScanPending { get; set; } + public int AttachmentsScanRejected { get; set; } + + public int RecordsCreated { get; set; } + public int RecordsFinalized { get; set; } + public int RecordsOverdue { get; set; } + public int SubmissionsFailed { get; set; } + public int SubmissionsAwaiting { get; set; } + + /// Prevention sweep counters from the last daily run, when the modules are enabled. + public int InspectionsDue { get; set; } + public int ViolationsOverdue { get; set; } + public int PermitsExpiringSoon { get; set; } + public int HydrantsOutOfService { get; set; } + + /// Whether the Records search host is configured (SearchConfig.Enabled); the index sweep reports its own health on the Settings page. + public bool SearchEnabled { get; set; } + public bool ProtectionEnforced { get; set; } + public int ProtectedCatalogVersion { get; set; } + + public List Alerts { get; set; } = new List(); + public List Warnings { get; set; } = new List(); + } + + /// Outcome of one prevention sweep (worker 42, daily). + public class RecordsPreventionSweepResult + { + public int DepartmentsEvaluated { get; set; } + public int InspectionsGenerated { get; set; } + public int ViolationsBecameOverdue { get; set; } + public int PermitsExpiringNotified { get; set; } + public int PermitsExpired { get; set; } + public int HydrantTestsDue { get; set; } + public int Errors { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RmsInvestigations.cs b/Core/Resgrid.Model/Records/RmsInvestigations.cs new file mode 100644 index 000000000..1bf82a910 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsInvestigations.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + public enum RmsInvestigationCaseState + { + Open = 1, + Active = 2, + /// Findings recorded; awaiting approval by a reviewer who is not their author. + PendingReview = 3, + Closed = 4 + } + + /// Case-level role (RMS plan section 4.4: "restricted investigator roles and case-level authorization"). + public enum RmsInvestigationRole + { + Lead = 1, + Investigator = 2, + /// May approve findings; may not author them. + Reviewer = 3, + ReadOnly = 4 + } + + /// NFPA 921 cause classification. + public enum RmsFireCauseClassification + { + UnderInvestigation = 0, + Accidental = 1, + Natural = 2, + Incendiary = 3, + Undetermined = 4 + } + + public enum RmsInvestigationNoteKind + { + SceneNote = 1, + Interview = 2, + Observation = 3, + Diagram = 4, + Timeline = 5, + Other = 6 + } + + public enum RmsInvestigationEvidenceKind + { + Physical = 1, + Photograph = 2, + Document = 3, + Digital = 4, + Sample = 5 + } + + public enum RmsEvidenceState + { + Collected = 1, + InStorage = 2, + Transferred = 3, + Released = 4, + Destroyed = 5 + } + + public enum RmsReferralState + { + Sent = 1, + Acknowledged = 2, + Declined = 3, + Closed = 4 + } + + /// + /// An investigation case (RMS plan section 4.4, RMS-5). Restricted end to end: reading any part of it needs + /// RecordRestricted_View plus membership on the case, department administrators included. Findings are + /// kept apart from the official NERIS incident revision; a discrepancy is recorded as a recommended amendment and + /// the incident report is changed only through its own explicit amendment path. + /// + public class RmsInvestigationCase : IEntity + { + public string RmsInvestigationCaseId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + /// INV-{year}-{sequence}. + public string CaseNumber { get; set; } + public string Title { get; set; } + /// . + public int State { get; set; } + public DateTime OpenedOn { get; set; } + public string OpenedByUserId { get; set; } + public string LeadInvestigatorUserId { get; set; } + public string RmsOccupancyId { get; set; } + public int? CallId { get; set; } + public string IncidentSummary { get; set; } + /// . + public int CauseClassification { get; set; } + public string CauseDetail { get; set; } + public string OriginDescription { get; set; } + public string Findings { get; set; } + public string FindingsAuthorUserId { get; set; } + public DateTime? FindingsRecordedOn { get; set; } + public DateTime? FindingsApprovedOn { get; set; } + public string FindingsApprovedByUserId { get; set; } + /// The investigation found the official incident revision needs correcting; the amendment itself is explicit and separate. + public bool RecommendsIncidentAmendment { get; set; } + public DateTime? ClosedOn { get; set; } + public string ClosedByUserId { get; set; } + public string ClosureReason { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public bool IsClosed => State == (int)RmsInvestigationCaseState.Closed; + + public object IdValue { get => RmsInvestigationCaseId; set => RmsInvestigationCaseId = (string)value; } + public string TableName => "RmsInvestigationCases"; + public string IdName => "RmsInvestigationCaseId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "IsClosed" }; + } + + /// An incident report linked to a case, pinned to the official revision current when the link was made. + public class RmsInvestigationCaseIncident : IEntity + { + public string RmsInvestigationCaseIncidentId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsInvestigationCaseId { get; set; } + /// RmsIncidentReport id (the Record). + public string RecordId { get; set; } + public string PinnedRevisionId { get; set; } + public string RecordNumber { get; set; } + public DateTime LinkedOn { get; set; } + public string LinkedByUserId { get; set; } + + public object IdValue { get => RmsInvestigationCaseIncidentId; set => RmsInvestigationCaseIncidentId = (string)value; } + public string TableName => "RmsInvestigationCaseIncidents"; + public string IdName => "RmsInvestigationCaseIncidentId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class RmsInvestigationCaseMember : IEntity + { + public string RmsInvestigationCaseMemberId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsInvestigationCaseId { get; set; } + public string UserId { get; set; } + /// . + public int Role { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? RemovedOn { get; set; } + public string RemovedByUserId { get; set; } + + public bool IsActive => RemovedOn == null; + + public object IdValue { get => RmsInvestigationCaseMemberId; set => RmsInvestigationCaseMemberId = (string)value; } + public string TableName => "RmsInvestigationCaseMembers"; + public string IdName => "RmsInvestigationCaseMemberId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "IsActive" }; + } + + /// Scene notes, interviews, observations. Locked when the case closes; never edited after that. + public class RmsInvestigationNote : IEntity + { + public string RmsInvestigationNoteId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsInvestigationCaseId { get; set; } + /// . + public int Kind { get; set; } + public DateTime OccurredOn { get; set; } + public string AuthorUserId { get; set; } + /// Interview subject or note title; personal information when it names a witness. + public string Subject { get; set; } + public string Body { get; set; } + public bool IsLocked { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsInvestigationNoteId; set => RmsInvestigationNoteId = (string)value; } + public string TableName => "RmsInvestigationNotes"; + public string IdName => "RmsInvestigationNoteId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class RmsInvestigationEvidence : IEntity + { + public string RmsInvestigationEvidenceId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsInvestigationCaseId { get; set; } + /// EV-{year}-{sequence}, unique per department. + public string EvidenceNumber { get; set; } + /// . + public int Kind { get; set; } + public string Description { get; set; } + public DateTime CollectedOn { get; set; } + public string CollectedByUserId { get; set; } + public string CollectedFrom { get; set; } + /// . + public int State { get; set; } + public string CurrentCustodianUserId { get; set; } + public string CurrentCustodianExternal { get; set; } + public string StorageLocation { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsInvestigationEvidenceId; set => RmsInvestigationEvidenceId = (string)value; } + public string TableName => "RmsInvestigationEvidence"; + public string IdName => "RmsInvestigationEvidenceId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Chain of custody: append-only, one row per hand-off. + public class RmsInvestigationCustody : IEntity + { + public string RmsInvestigationCustodyId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsInvestigationEvidenceId { get; set; } + public int Sequence { get; set; } + public DateTime TransferredOn { get; set; } + public string FromUserId { get; set; } + public string FromExternal { get; set; } + public string ToUserId { get; set; } + public string ToExternal { get; set; } + public string Reason { get; set; } + /// after this transfer. + public int ResultingState { get; set; } + public string RecordedByUserId { get; set; } + public DateTime CreatedOn { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + + public object IdValue { get => RmsInvestigationCustodyId; set => RmsInvestigationCustodyId = (string)value; } + public string TableName => "RmsInvestigationCustody"; + public string IdName => "RmsInvestigationCustodyId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class RmsInvestigationReferral : IEntity + { + public string RmsInvestigationReferralId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsInvestigationCaseId { get; set; } + public string Agency { get; set; } + public DateTime ReferredOn { get; set; } + public string ReferredByUserId { get; set; } + public string Reason { get; set; } + public string ReferenceNumber { get; set; } + /// . + public int State { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + public object IdValue { get => RmsInvestigationReferralId; set => RmsInvestigationReferralId = (string)value; } + public string TableName => "RmsInvestigationReferrals"; + public string IdName => "RmsInvestigationReferralId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsOccupancy.cs b/Core/Resgrid.Model/Records/RmsOccupancy.cs new file mode 100644 index 000000000..5aaa04ae6 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsOccupancy.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// Lifecycle of an occupancy/property master row (RMS plan section 4.3, RMS-5). + public enum RmsOccupancyStatus + { + Active = 1, + Vacant = 2, + Demolished = 3, + /// Folded into another occupancy; names the survivor. + Merged = 4 + } + + /// Role-separated Contact links on an occupancy (plan section 4.3: site, owner, emergency-contact and bill-to are different people). + public enum RmsOccupancyContactRole + { + Site = 1, + Owner = 2, + EmergencyContact = 3, + BillTo = 4, + Tenant = 5, + PropertyManager = 6 + } + + /// Where a crosswalk candidate came from. + public enum RmsOccupancyCrosswalkSourceKind + { + /// A Contacts Phase A pre-plan (ContactPreplans row); the dispatch-facing structure view RMS-5 absorbs. + ContactPreplan = 1, + /// A Contact with a site address or coordinates but no pre-plan. + Contact = 2, + /// A mapping point of interest. + Poi = 3 + } + + /// Decision state of one crosswalk candidate. + public enum RmsOccupancyCrosswalkState + { + /// Inventoried, not yet decided; blocks the write cutover. + Candidate = 1, + /// Bound to an occupancy; the source keeps projecting from that occupancy. + Linked = 2, + /// Not a structure this department tracks (a person, a duplicate, a mailing address). + Rejected = 3, + /// Linked to an occupancy that was later merged; follows the survivor. + Merged = 4 + } + + /// Which system owns structure/occupancy writes for a department (plan section 4.3, "do not leave two editable structure masters"). + public enum RmsOccupancyOwnershipState + { + /// Contacts pre-plans are the editable structure master; RMS occupancy is empty or read-only staging. + ContactsOwned = 1, + /// Inventory has run; candidates are being decided. Both remain readable, Contacts still writes. + Reconciling = 2, + /// RMS occupancy is the system of record; Contacts pre-plan writes are refused and Contacts reads project from it. + RecordsOwned = 3 + } + + /// Fire protection present at the site. + public enum RmsSprinklerType + { + Unknown = 0, + None = 1, + Wet = 2, + Dry = 3, + PreAction = 4, + Deluge = 5, + Partial = 6 + } + + /// + /// The occupancy/property master (RMS plan section 4.3, RMS-5, registry M0186): the structure the department + /// pre-plans, inspects, permits and investigates. Access secrets, hazard text and contact names are ADP catalog + /// v13 fields; construction/occupancy codes, flags, counts and review dates stay plaintext so dispatch badges, + /// inspection scheduling and the NERIS crosswalk work without a grant. + /// + public class RmsOccupancy : IEntity + { + public string RmsOccupancyId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + + /// Department-facing number, OCC-{year}-{sequence}. + public string OccupancyNumber { get; set; } + public string Name { get; set; } + /// . + public int Status { get; set; } + public string MergedIntoOccupancyId { get; set; } + + public string AddressText { get; set; } + public string City { get; set; } + public string StateProvince { get; set; } + public string PostalCode { get; set; } + public string Country { get; set; } + /// Address folded for matching: upper case, punctuation stripped, whitespace collapsed. + public string NormalizedAddress { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public string ParcelId { get; set; } + + /// Contacts plan ConstructionTypes / RoofTypes / OccupancyTypes values (shared enums). + public int ConstructionType { get; set; } + public int RoofType { get; set; } + public int OccupancyType { get; set; } + public int? Stories { get; set; } + public int? YearBuilt { get; set; } + public int? SquareFeet { get; set; } + public int? OccupantLoad { get; set; } + public string OccupancyHours { get; set; } + public bool HasOccupantsNeedingAssistance { get; set; } + public string OccupantsNeedingAssistanceNotes { get; set; } + + /// . + public int SprinklerType { get; set; } + public bool HasStandpipe { get; set; } + public bool HasFireAlarm { get; set; } + public string FdcLocation { get; set; } + + public string GasShutoffLocation { get; set; } + public string ElectricShutoffLocation { get; set; } + public string WaterShutoffLocation { get; set; } + public string UtilityNotes { get; set; } + + public string KnoxBoxLocation { get; set; } + public string GateCode { get; set; } + public string AlarmPanelLocation { get; set; } + public string AlarmCompany { get; set; } + public string AlarmCompanyPhone { get; set; } + public string AccessNotes { get; set; } + + public string NearestHydrantId { get; set; } + public int? RequiredFireFlowGpm { get; set; } + public string WaterSupplyNotes { get; set; } + + public string EmergencyContactName { get; set; } + public string EmergencyContactPhone { get; set; } + + public bool HazmatOnSite { get; set; } + public string GeneralHazardNotes { get; set; } + public string TacticalSummary { get; set; } + + /// The mapping POI this structure was created from or is pinned to, when any. + public int? PoiId { get; set; } + + public DateTime? LastReviewedOn { get; set; } + public string ReviewedByUserId { get; set; } + public DateTime? NextReviewDue { get; set; } + public DateTime? LastInspectedOn { get; set; } + + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public string ModifiedByUserId { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public bool IsReviewOverdue(DateTime utcNow) => NextReviewDue.HasValue && NextReviewDue.Value < utcNow; + + public object IdValue { get => RmsOccupancyId; set => RmsOccupancyId = (string)value; } + public string TableName => "RmsOccupancies"; + public string IdName => "RmsOccupancyId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// A Contact playing a role at an occupancy. Roles are separate rows so one person can be owner and emergency contact. + public class RmsOccupancyContactLink : IEntity + { + public string RmsOccupancyContactLinkId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsOccupancyId { get; set; } + public string ContactId { get; set; } + /// . + public int Role { get; set; } + public bool IsPrimary { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsOccupancyContactLinkId; set => RmsOccupancyContactLinkId = (string)value; } + public string TableName => "RmsOccupancyContactLinks"; + public string IdName => "RmsOccupancyContactLinkId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// A premise hazard on the occupancy master; mirrors ContactPreplanHazard so a linked pre-plan's hazards carry over 1:1. + public class RmsOccupancyHazard : IEntity + { + public string RmsOccupancyHazardId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsOccupancyId { get; set; } + /// Contacts plan PreplanHazardTypes value. + public int HazardType { get; set; } + /// Contacts plan PreplanHazardSeverity value (1 low – 4 critical). + public int Severity { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string LocationDescription { get; set; } + public string GpsCoordinates { get; set; } + public bool ShouldAlert { get; set; } + /// The Contacts hazard this row was absorbed from, when any; keeps the link for provenance. + public string SourceContactPreplanHazardId { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsOccupancyHazardId; set => RmsOccupancyHazardId = (string)value; } + public string TableName => "RmsOccupancyHazards"; + public string IdName => "RmsOccupancyHazardId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// One source identity (pre-plan, contact, POI) and its relationship to the occupancy master (plan section 4.3: + /// "create a stable occupancy identity and Contact/POI/address/geospatial crosswalk; inventory and merge duplicate + /// source candidates"). Every candidate must be decided before a department switches structure writes to RMS. + /// + public class RmsOccupancyCrosswalk : IEntity + { + public string RmsOccupancyCrosswalkId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + /// Null while a Candidate. + public string RmsOccupancyId { get; set; } + /// . + public int SourceKind { get; set; } + public string SourceId { get; set; } + /// The source's contact id when the source is a pre-plan or contact, so a Contacts read can find its occupancy in one lookup. + public string ContactId { get; set; } + public string SourceDisplayName { get; set; } + public string NormalizedAddress { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + /// 0–100; how confident the inventory was that this source describes the same structure as the suggested occupancy. + public int MatchConfidence { get; set; } + public string MatchReason { get; set; } + /// Occupancy the inventory suggested; the deciding administrator may pick another. + public string SuggestedOccupancyId { get; set; } + /// Other candidate ids the inventory grouped with this one (same address or within the proximity radius), comma separated. + public string GroupKey { get; set; } + /// . + public int State { get; set; } + public DateTime? DecidedOn { get; set; } + public string DecidedByUserId { get; set; } + public DateTime InventoriedOn { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + public object IdValue { get => RmsOccupancyCrosswalkId; set => RmsOccupancyCrosswalkId = (string)value; } + public string TableName => "RmsOccupancyCrosswalks"; + public string IdName => "RmsOccupancyCrosswalkId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Field-level provenance on the occupancy master: which source supplied a field, when, and when a person last confirmed it. + public class RmsOccupancyFieldProvenance : IEntity + { + public string RmsOccupancyFieldProvenanceId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsOccupancyId { get; set; } + /// Property name on (or "hazards" for the hazard set). + public string FieldKey { get; set; } + /// , or 0 for a value typed into RMS directly. + public int SourceKind { get; set; } + public string SourceId { get; set; } + public DateTime CapturedOn { get; set; } + public string CapturedByUserId { get; set; } + public DateTime? ReviewedOn { get; set; } + public string ReviewedByUserId { get; set; } + + public object IdValue { get => RmsOccupancyFieldProvenanceId; set => RmsOccupancyFieldProvenanceId = (string)value; } + public string TableName => "RmsOccupancyFieldProvenances"; + public string IdName => "RmsOccupancyFieldProvenanceId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// One row per department: which system owns structure writes (plan section 4.3 write cutover). Written only by the + /// occupancy service through an audited administrative command; the Contacts service reads it on every pre-plan write. + /// + public class RmsOccupancyOwnership : IEntity + { + public string RmsOccupancyOwnershipId { get; set; } + public int DepartmentId { get; set; } + /// . + public int State { get; set; } + public DateTime? InventoriedOn { get; set; } + public DateTime? SwitchedOn { get; set; } + public string SwitchedByUserId { get; set; } + public string Reason { get; set; } + public int CandidateCount { get; set; } + public int LinkedCount { get; set; } + public int RejectedCount { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + public object IdValue { get => RmsOccupancyOwnershipId; set => RmsOccupancyOwnershipId = (string)value; } + public string TableName => "RmsOccupancyOwnerships"; + public string IdName => "RmsOccupancyOwnershipId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsOperationalRecord.cs b/Core/Resgrid.Model/Records/RmsOperationalRecord.cs index da4a69ab9..65c9cd74a 100644 --- a/Core/Resgrid.Model/Records/RmsOperationalRecord.cs +++ b/Core/Resgrid.Model/Records/RmsOperationalRecord.cs @@ -109,6 +109,15 @@ public class RmsOperationalRecord : IEntity public string CancelledByUserId { get; set; } + /// + /// The uniqueness key the definition's demands (plan section 5.2.1), or + /// null when the definition allows multiples, the Record has no Call, or the Record reached a terminal + /// state. A filtered unique index on (DepartmentId, CardinalityKey) is what actually enforces the rule; + /// the service checks first only so the author gets the existing Record instead of a database error. + /// Clearing it on Void/Cancel is deliberate — an abandoned Record must not block the replacement. + /// + public string CardinalityKey { get; set; } + /// Scoped idempotency key for create; unique per department when present. public string IdempotencyKey { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsPrevention.cs b/Core/Resgrid.Model/Records/RmsPrevention.cs new file mode 100644 index 000000000..28e9ecd98 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsPrevention.cs @@ -0,0 +1,628 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// Lifecycle of an inspection (RMS plan section 4.3, RMS-5). + public enum RmsInspectionState + { + Scheduled = 1, + InProgress = 2, + Completed = 3, + /// Completed with open violations; a child re-inspection is expected by the violation due date. + ReinspectionRequired = 4, + Closed = 5, + Cancelled = 6 + } + + public enum RmsInspectionResult + { + NotRecorded = 0, + Pass = 1, + Fail = 2, + Conditional = 3 + } + + public enum RmsViolationState + { + Open = 1, + Corrected = 2, + Verified = 3, + Escalated = 4, + Waived = 5 + } + + public enum RmsViolationSeverity + { + Minor = 1, + Moderate = 2, + Serious = 3, + /// Imminent hazard; immediate correction or evacuation. + Critical = 4 + } + + public enum RmsHydrantType + { + DryBarrel = 1, + WetBarrel = 2, + Standpipe = 3, + Cistern = 4, + DraftingSite = 5, + Other = 6 + } + + public enum RmsHydrantOwnerKind + { + Municipal = 1, + Private = 2, + Department = 3, + Other = 4 + } + + /// NFPA 291 flow classification (colour of the bonnet/caps). + public enum RmsHydrantFlowClass + { + Unknown = 0, + /// 1500 gpm or more — light blue. + AA = 1, + /// 1000–1499 gpm — green. + A = 2, + /// 500–999 gpm — orange. + B = 3, + /// Under 500 gpm — red. + C = 4 + } + + public enum RmsHydrantMaintenanceKind + { + Inspection = 1, + Flush = 2, + Repair = 3, + Paint = 4, + Winterize = 5, + Other = 6 + } + + public enum RmsPermitState + { + Applied = 1, + UnderReview = 2, + Approved = 3, + Issued = 4, + Expired = 5, + Revoked = 6, + Denied = 7, + Closed = 8 + } + + public enum RmsPlanReviewOutcome + { + Pending = 0, + Approved = 1, + CorrectionsRequired = 2, + Rejected = 3 + } + + public enum RmsCrrActivityKind + { + PublicEducation = 1, + SmokeAlarmInstallation = 2, + HomeSafetyVisit = 3, + PublicEvent = 4, + SchoolProgram = 5, + MediaOutreach = 6, + Other = 7 + } + + /// Parent aggregate of a prevention attachment. + public enum RmsPreventionParentKind + { + Occupancy = 1, + Inspection = 2, + Violation = 3, + Hydrant = 4, + Permit = 5, + CrrActivity = 6, + /// Investigation photos and documents; always restricted. + InvestigationCase = 7, + InvestigationEvidence = 8 + } + + /// Sequence names used by . + public static class RmsPreventionNumberKinds + { + public const string Occupancy = "OCC"; + public const string Inspection = "INSP"; + public const string Permit = "PRM"; + public const string Investigation = "INV"; + public const string Evidence = "EV"; + } + + /// An adopted code set (e.g. IFC 2021 with local amendments). + public class RmsCodeSet : IEntity + { + public string RmsCodeSetId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string Name { get; set; } + public string Edition { get; set; } + public string Jurisdiction { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsCodeSetId; set => RmsCodeSetId = (string)value; } + public string TableName => "RmsCodeSets"; + public string IdName => "RmsCodeSetId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// One citable section of a code set. + public class RmsCodeSection : IEntity + { + public string RmsCodeSectionId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsCodeSetId { get; set; } + public string SectionNumber { get; set; } + public string Title { get; set; } + public string Text { get; set; } + /// proposed when a violation cites this section. + public int DefaultSeverity { get; set; } + /// Default days to correct a violation citing this section. + public int DefaultCorrectionDays { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsCodeSectionId; set => RmsCodeSectionId = (string)value; } + public string TableName => "RmsCodeSections"; + public string IdName => "RmsCodeSectionId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// An inspection program: which occupancy types it applies to, how often, and the checklist an inspector walks. + /// The checklist is bounded JSON ( list) rather than an RMS definition + /// because an inspection item is pass/fail against a code citation, not a typed field. + /// + public class RmsInspectionProgram : IEntity + { + public string RmsInspectionProgramId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + /// Contacts plan OccupancyTypes values this program applies to, comma separated; empty = all. + public string OccupancyTypesCsv { get; set; } + /// Months between inspections; 0 = on demand only. + public int FrequencyMonths { get; set; } + public string RmsCodeSetId { get; set; } + public string ChecklistJson { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsInspectionProgramId; set => RmsInspectionProgramId = (string)value; } + public string TableName => "RmsInspectionPrograms"; + public string IdName => "RmsInspectionProgramId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// One item of an inspection program checklist (stored in ChecklistJson). + public class RmsInspectionChecklistItem + { + public string Key { get; set; } + public string Text { get; set; } + public string RmsCodeSectionId { get; set; } + public bool Required { get; set; } + public int Order { get; set; } + } + + /// The inspector's answer to one checklist item (stored in RmsInspection.ItemsJson). + public class RmsInspectionItemResult + { + public string Key { get; set; } + /// true = compliant, false = violation, null = not inspected. + public bool? Passed { get; set; } + public string Note { get; set; } + } + + public class RmsInspection : IEntity + { + public string RmsInspectionId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsOccupancyId { get; set; } + public string RmsInspectionProgramId { get; set; } + /// INSP-{year}-{sequence}. + public string InspectionNumber { get; set; } + /// . + public int State { get; set; } + /// . + public int Result { get; set; } + public DateTime? ScheduledOn { get; set; } + public DateTime? StartedOn { get; set; } + public DateTime? CompletedOn { get; set; } + public string InspectorUserId { get; set; } + public string ItemsJson { get; set; } + public string Notes { get; set; } + /// Printed name of the occupant representative who signed the inspection, if any. + public string SignatureName { get; set; } + public DateTime? SignedOn { get; set; } + /// The inspection this one re-checks. + public string ParentInspectionId { get; set; } + public DateTime? NoticeIssuedOn { get; set; } + public string NoticeReference { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsInspectionId; set => RmsInspectionId = (string)value; } + public string TableName => "RmsInspections"; + public string IdName => "RmsInspectionId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class RmsViolation : IEntity + { + public string RmsViolationId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsInspectionId { get; set; } + public string RmsOccupancyId { get; set; } + public string RmsCodeSetId { get; set; } + public string RmsCodeSectionId { get; set; } + public string ChecklistItemKey { get; set; } + public string Description { get; set; } + /// . + public int Severity { get; set; } + public string CorrectiveAction { get; set; } + public DateTime? DueOn { get; set; } + /// . + public int State { get; set; } + public DateTime? CorrectedOn { get; set; } + public DateTime? VerifiedOn { get; set; } + public string VerifiedByUserId { get; set; } + public string ReinspectionId { get; set; } + /// Set once the overdue event has been raised, so the sweep emits it exactly once (same rule as record due states). + public DateTime? OverdueEmittedOn { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public bool IsOpen => State == (int)RmsViolationState.Open || State == (int)RmsViolationState.Escalated; + + public object IdValue { get => RmsViolationId; set => RmsViolationId = (string)value; } + public string TableName => "RmsViolations"; + public string IdName => "RmsViolationId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "IsOpen" }; + } + + public class RmsHydrant : IEntity + { + public string RmsHydrantId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string HydrantNumber { get; set; } + /// . + public int Type { get; set; } + public decimal Latitude { get; set; } + public decimal Longitude { get; set; } + public string AddressText { get; set; } + /// . + public int OwnerKind { get; set; } + public string OwnerName { get; set; } + public decimal? MainSizeInches { get; set; } + public int? StaticPressurePsi { get; set; } + public int? ResidualPressurePsi { get; set; } + public int? FlowGpm { get; set; } + /// . + public int FlowClass { get; set; } + public bool InService { get; set; } + public string OutOfServiceReason { get; set; } + public DateTime? OutOfServiceSince { get; set; } + public DateTime? LastTestedOn { get; set; } + public DateTime? LastMaintainedOn { get; set; } + public string Notes { get; set; } + public int? PoiId { get; set; } + /// Where the row came from: manual, csv-import, poi. + public string Source { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsHydrantId; set => RmsHydrantId = (string)value; } + public string TableName => "RmsHydrants"; + public string IdName => "RmsHydrantId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class RmsHydrantFlowTest : IEntity + { + public string RmsHydrantFlowTestId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsHydrantId { get; set; } + public DateTime TestedOn { get; set; } + public string TestedByUserId { get; set; } + public int StaticPressurePsi { get; set; } + public int ResidualPressurePsi { get; set; } + public int PitotPressurePsi { get; set; } + public decimal OutletDiameterInches { get; set; } + /// Discharge coefficient of the outlet (0.9 smooth, 0.8 square, 0.7 projecting). + public decimal Coefficient { get; set; } + /// Computed: 29.83 × c × d² × √p. + public int FlowGpm { get; set; } + /// derived from FlowGpm. + public int FlowClass { get; set; } + public string Notes { get; set; } + public DateTime CreatedOn { get; set; } + + public object IdValue { get => RmsHydrantFlowTestId; set => RmsHydrantFlowTestId = (string)value; } + public string TableName => "RmsHydrantFlowTests"; + public string IdName => "RmsHydrantFlowTestId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class RmsHydrantMaintenance : IEntity + { + public string RmsHydrantMaintenanceId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsHydrantId { get; set; } + public DateTime PerformedOn { get; set; } + public string PerformedByUserId { get; set; } + /// . + public int Kind { get; set; } + public string Notes { get; set; } + public bool ReturnedToService { get; set; } + public DateTime CreatedOn { get; set; } + + public object IdValue { get => RmsHydrantMaintenanceId; set => RmsHydrantMaintenanceId = (string)value; } + public string TableName => "RmsHydrantMaintenances"; + public string IdName => "RmsHydrantMaintenanceId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class RmsPermitType : IEntity + { + public string RmsPermitTypeId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string Name { get; set; } + public string Code { get; set; } + public string Description { get; set; } + public int DefaultValidityDays { get; set; } + public bool RequiresPlanReview { get; set; } + public decimal? FeeAmount { get; set; } + /// Default conditions pre-filled on a new permit, one per line. + public string ConditionsTemplate { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsPermitTypeId; set => RmsPermitTypeId = (string)value; } + public string TableName => "RmsPermitTypes"; + public string IdName => "RmsPermitTypeId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class RmsPermit : IEntity + { + public string RmsPermitId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsPermitTypeId { get; set; } + public string RmsOccupancyId { get; set; } + /// PRM-{year}-{sequence}. + public string PermitNumber { get; set; } + public string ApplicantContactId { get; set; } + public string ApplicantName { get; set; } + public string ApplicantPhone { get; set; } + public string ApplicantEmail { get; set; } + public string Description { get; set; } + /// . + public int State { get; set; } + public DateTime AppliedOn { get; set; } + public DateTime? ReviewedOn { get; set; } + public string ReviewedByUserId { get; set; } + public DateTime? IssuedOn { get; set; } + public string IssuedByUserId { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + /// Conditions of issue, one per line. + public string Conditions { get; set; } + public string ReviewNotes { get; set; } + public decimal? FeeAmount { get; set; } + public DateTime? FeePaidOn { get; set; } + /// Optional reference into the invoicing system (Contacts plan Phase B) — text only, never a join. + public string InvoiceReference { get; set; } + public string DecisionReason { get; set; } + /// Set once the expiring-soon event has been raised, so the sweep emits it exactly once. + public DateTime? ExpiringEmittedOn { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsPermitId; set => RmsPermitId = (string)value; } + public string TableName => "RmsPermits"; + public string IdName => "RmsPermitId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class RmsPlanReview : IEntity + { + public string RmsPlanReviewId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsPermitId { get; set; } + public int CycleNumber { get; set; } + public DateTime SubmittedOn { get; set; } + public string ReviewerUserId { get; set; } + public DateTime? ReviewedOn { get; set; } + /// . + public int Outcome { get; set; } + public string Comments { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + public object IdValue { get => RmsPlanReviewId; set => RmsPlanReviewId = (string)value; } + public string TableName => "RmsPlanReviews"; + public string IdName => "RmsPlanReviewId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// A community risk reduction activity (plan section 4.3). Carries no personal information about attendees, only counts. + public class RmsCrrActivity : IEntity + { + public string RmsCrrActivityId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + /// . + public int Kind { get; set; } + public DateTime OccurredOn { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string RmsOccupancyId { get; set; } + public string LocationText { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public int AudienceCount { get; set; } + public int SmokeAlarmsInstalled { get; set; } + public decimal HoursSpent { get; set; } + /// Department members who staffed the activity, comma separated user ids. + public string StaffUserIdsCsv { get; set; } + public string Outcome { get; set; } + /// Reserved for the NERIS CRR secondary schema once it matures; JSON, additive only. + public string NerisSecondaryJson { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsCrrActivityId; set => RmsCrrActivityId = (string)value; } + public string TableName => "RmsCrrActivities"; + public string IdName => "RmsCrrActivityId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// A file on a prevention or investigation aggregate. Goes through the same hygiene and scanner as record + /// attachments; the name, description and bytes are ADP catalog v13 fields. Investigation attachments are + /// restricted and readable only by case members. + /// + public class RmsPreventionAttachment : IEntity + { + public string RmsPreventionAttachmentId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + /// . + public int ParentKind { get; set; } + public string ParentId { get; set; } + public string FileName { get; set; } + public string ContentType { get; set; } + public long ByteSize { get; set; } + public string Checksum { get; set; } + public byte[] Data { get; set; } + public string Description { get; set; } + public string UploadedByUserId { get; set; } + public DateTime UploadedOn { get; set; } + /// . + public int ScanState { get; set; } + public bool MetadataStripped { get; set; } + /// : Restricted for every investigation attachment. + public int Classification { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsPreventionAttachmentId; set => RmsPreventionAttachmentId = (string)value; } + public string TableName => "RmsPreventionAttachments"; + public string IdName => "RmsPreventionAttachmentId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Per-department, per-kind, per-year number sequence for the prevention aggregates (OCC/INSP/PRM/INV/EV). + public class RmsPreventionSequence : IEntity + { + public string RmsPreventionSequenceId { get; set; } + public int DepartmentId { get; set; } + public string Kind { get; set; } + public int Year { get; set; } + public int LastValue { get; set; } + public DateTime ModifiedOn { get; set; } + + public object IdValue { get => RmsPreventionSequenceId; set => RmsPreventionSequenceId = (string)value; } + public string TableName => "RmsPreventionSequences"; + public string IdName => "RmsPreventionSequenceId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// NFPA 291 hydrant flow arithmetic, pure so it is unit-testable and shared by web, API and import. + public static class HydrantFlowCalculator + { + /// Q = 29.83 × c × d² × √p (gpm), rounded to the nearest 10 as the standard reports. + public static int FlowGpm(decimal coefficient, decimal outletDiameterInches, int pitotPsi) + { + if (coefficient <= 0 || outletDiameterInches <= 0 || pitotPsi <= 0) + return 0; + var q = 29.83 * (double)coefficient * Math.Pow((double)outletDiameterInches, 2) * Math.Sqrt(pitotPsi); + return (int)(Math.Round(q / 10.0) * 10); + } + + public static RmsHydrantFlowClass Classify(int? flowGpm) + { + if (!flowGpm.HasValue || flowGpm.Value <= 0) return RmsHydrantFlowClass.Unknown; + if (flowGpm.Value >= 1500) return RmsHydrantFlowClass.AA; + if (flowGpm.Value >= 1000) return RmsHydrantFlowClass.A; + if (flowGpm.Value >= 500) return RmsHydrantFlowClass.B; + return RmsHydrantFlowClass.C; + } + } +} diff --git a/Core/Resgrid.Model/Records/RmsProtectedFields.cs b/Core/Resgrid.Model/Records/RmsProtectedFields.cs index 59eeb0f81..19bdc8601 100644 --- a/Core/Resgrid.Model/Records/RmsProtectedFields.cs +++ b/Core/Resgrid.Model/Records/RmsProtectedFields.cs @@ -188,6 +188,85 @@ private static string TableOf() where T : IEntity /// The rendered export artifact (RmsExportRuns.Data) is a generated copy of record content. public static readonly string ExportRunDataFieldId = FieldId("RmsExportRuns", "Data"); + // ---- RMS-5 prevention and investigations, catalog v13 ---------------------------------------------------- + + /// Occupancy master (RMS-5): access secrets, hazard/tactical text, utility notes, the on-site emergency contact. Structural codes and flags stay plaintext. + public static readonly IReadOnlyDictionary Get, Action Set)> Occupancies = Map( + ("OccupantsNeedingAssistanceNotes", o => o.OccupantsNeedingAssistanceNotes, (o, v) => o.OccupantsNeedingAssistanceNotes = v), + ("UtilityNotes", o => o.UtilityNotes, (o, v) => o.UtilityNotes = v), + ("KnoxBoxLocation", o => o.KnoxBoxLocation, (o, v) => o.KnoxBoxLocation = v), + ("GateCode", o => o.GateCode, (o, v) => o.GateCode = v), + ("AlarmPanelLocation", o => o.AlarmPanelLocation, (o, v) => o.AlarmPanelLocation = v), + ("AlarmCompany", o => o.AlarmCompany, (o, v) => o.AlarmCompany = v), + ("AlarmCompanyPhone", o => o.AlarmCompanyPhone, (o, v) => o.AlarmCompanyPhone = v), + ("AccessNotes", o => o.AccessNotes, (o, v) => o.AccessNotes = v), + ("WaterSupplyNotes", o => o.WaterSupplyNotes, (o, v) => o.WaterSupplyNotes = v), + ("EmergencyContactName", o => o.EmergencyContactName, (o, v) => o.EmergencyContactName = v), + ("EmergencyContactPhone", o => o.EmergencyContactPhone, (o, v) => o.EmergencyContactPhone = v), + ("GeneralHazardNotes", o => o.GeneralHazardNotes, (o, v) => o.GeneralHazardNotes = v), + ("TacticalSummary", o => o.TacticalSummary, (o, v) => o.TacticalSummary = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> OccupancyHazards = Map( + ("Description", h => h.Description, (h, v) => h.Description = v), + ("LocationDescription", h => h.LocationDescription, (h, v) => h.LocationDescription = v), + ("GpsCoordinates", h => h.GpsCoordinates, (h, v) => h.GpsCoordinates = v)); + + /// Inspection notes and the occupant representative's printed name. + public static readonly IReadOnlyDictionary Get, Action Set)> Inspections = Map( + ("Notes", i => i.Notes, (i, v) => i.Notes = v), + ("SignatureName", i => i.SignatureName, (i, v) => i.SignatureName = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> Violations = Map( + ("Description", x => x.Description, (x, v) => x.Description = v), + ("CorrectiveAction", x => x.CorrectiveAction, (x, v) => x.CorrectiveAction = v)); + + /// Permit applicant identity and the reviewer's notes. + public static readonly IReadOnlyDictionary Get, Action Set)> Permits = Map( + ("ApplicantName", p => p.ApplicantName, (p, v) => p.ApplicantName = v), + ("ApplicantPhone", p => p.ApplicantPhone, (p, v) => p.ApplicantPhone = v), + ("ApplicantEmail", p => p.ApplicantEmail, (p, v) => p.ApplicantEmail = v), + ("ReviewNotes", p => p.ReviewNotes, (p, v) => p.ReviewNotes = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> PlanReviews = Map( + ("Comments", p => p.Comments, (p, v) => p.Comments = v)); + + /// Investigation case narrative (Tier 1: restricted and encrypted). + public static readonly IReadOnlyDictionary Get, Action Set)> InvestigationCases = Map( + ("IncidentSummary", c => c.IncidentSummary, (c, v) => c.IncidentSummary = v), + ("CauseDetail", c => c.CauseDetail, (c, v) => c.CauseDetail = v), + ("OriginDescription", c => c.OriginDescription, (c, v) => c.OriginDescription = v), + ("Findings", c => c.Findings, (c, v) => c.Findings = v), + ("ClosureReason", c => c.ClosureReason, (c, v) => c.ClosureReason = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> InvestigationNotes = Map( + ("Subject", n => n.Subject, (n, v) => n.Subject = v), + ("Body", n => n.Body, (n, v) => n.Body = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> InvestigationEvidence = Map( + ("Description", e => e.Description, (e, v) => e.Description = v), + ("CollectedFrom", e => e.CollectedFrom, (e, v) => e.CollectedFrom = v), + ("CurrentCustodianExternal", e => e.CurrentCustodianExternal, (e, v) => e.CurrentCustodianExternal = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> InvestigationCustody = Map( + ("FromExternal", c => c.FromExternal, (c, v) => c.FromExternal = v), + ("ToExternal", c => c.ToExternal, (c, v) => c.ToExternal = v), + ("Reason", c => c.Reason, (c, v) => c.Reason = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> InvestigationReferrals = Map( + ("Reason", r => r.Reason, (r, v) => r.Reason = v)); + + /// QA findings and notes quote the record they review, so they inherit its sensitivity. + public static readonly IReadOnlyDictionary Get, Action Set)> QualityReviews = Map( + ("FindingsJson", q => q.FindingsJson, (q, v) => q.FindingsJson = v), + ("Note", q => q.Note, (q, v) => q.Note = v)); + + /// Prevention/investigation attachment text columns; the bytes ride . + public static readonly IReadOnlyDictionary Get, Action Set)> PreventionAttachments = Map( + ("FileName", a => a.FileName, (a, v) => a.FileName = v), + ("Description", a => a.Description, (a, v) => a.Description = v)); + + public static readonly string PreventionAttachmentDataFieldId = FieldId("RmsPreventionAttachments", "Data"); + /// Every RMS field id the catalog must carry, for the pin test. public static IEnumerable AllFieldIds() { @@ -195,6 +274,20 @@ public static IEnumerable AllFieldIds() foreach (var k in Narratives.Keys) yield return k; foreach (var k in Locations.Keys) yield return k; foreach (var k in LocationCompanions.Keys) yield return k; + foreach (var k in Occupancies.Keys) yield return k; + foreach (var k in OccupancyHazards.Keys) yield return k; + foreach (var k in Inspections.Keys) yield return k; + foreach (var k in Violations.Keys) yield return k; + foreach (var k in Permits.Keys) yield return k; + foreach (var k in PlanReviews.Keys) yield return k; + foreach (var k in InvestigationCases.Keys) yield return k; + foreach (var k in InvestigationNotes.Keys) yield return k; + foreach (var k in InvestigationEvidence.Keys) yield return k; + foreach (var k in InvestigationCustody.Keys) yield return k; + foreach (var k in InvestigationReferrals.Keys) yield return k; + foreach (var k in QualityReviews.Keys) yield return k; + foreach (var k in PreventionAttachments.Keys) yield return k; + yield return PreventionAttachmentDataFieldId; foreach (var k in SourceFacts.Keys) yield return k; foreach (var k in Casualties.Keys) yield return k; foreach (var k in Exposures.Keys) yield return k; diff --git a/Core/Resgrid.Model/Records/RmsQualityReview.cs b/Core/Resgrid.Model/Records/RmsQualityReview.cs new file mode 100644 index 000000000..b0881fe4f --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsQualityReview.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// A department QA rubric (RMS plan section 4.7, post-finalization quality review, RMS-4 optional module): the + /// criteria a reviewer scores a finalized Record against. Criteria are bounded JSON + /// ( list). Editing a rubric never changes a recorded review — each review + /// snapshots the criteria it was scored against. + /// + public class RmsQualityRubric : IEntity + { + public string RmsQualityRubricId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string Name { get; set; } + /// Definition key this rubric applies to; null = any definition. + public string DefinitionKey { get; set; } + public string CriteriaJson { get; set; } + /// Sample size per sampling run. + public int SampleSize { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsQualityRubricId; set => RmsQualityRubricId = (string)value; } + public string TableName => "RmsQualityRubrics"; + public string IdName => "RmsQualityRubricId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class RmsQualityCriterion + { + public string Key { get; set; } + public string Text { get; set; } + /// Relative weight; scores are weighted averages on a 0–100 scale. + public int Weight { get; set; } = 1; + } + + /// The reviewer's score for one criterion (stored in RmsQualityReview.FindingsJson). + public class RmsQualityFinding + { + public string Key { get; set; } + /// 0–100. + public int Score { get; set; } + public string Note { get; set; } + } + + /// + /// One QA review of one finalized revision. Non-mutating by construction: it references the record and revision, + /// never edits either, and a substantive correction is an amendment through the normal lifecycle. + /// + public class RmsQualityReview : IEntity + { + public string RmsQualityReviewId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsQualityRubricId { get; set; } + public string RecordId { get; set; } + /// . + public int RecordKind { get; set; } + public string RevisionId { get; set; } + public string DefinitionKey { get; set; } + public string RecordNumber { get; set; } + public string AuthorUserId { get; set; } + public int? UnitId { get; set; } + public string ReviewerUserId { get; set; } + public DateTime SampledOn { get; set; } + public DateTime? ScoredOn { get; set; } + /// Weighted 0–100; null until scored. + public int? Score { get; set; } + /// Snapshot of the rubric criteria at sampling time. + public string CriteriaJson { get; set; } + public string FindingsJson { get; set; } + public string Note { get; set; } + public bool AmendmentRecommended { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + public object IdValue { get => RmsQualityReviewId; set => RmsQualityReviewId = (string)value; } + public string TableName => "RmsQualityReviews"; + public string IdName => "RmsQualityReviewId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Aggregate line of the QA trend report. + public class RecordsQualityTrendRow + { + public string Key { get; set; } + public string Label { get; set; } + public int Reviews { get; set; } + public double AverageScore { get; set; } + public int AmendmentsRecommended { get; set; } + } + + public class RecordsQualityTrends + { + public DateTime Since { get; set; } + public int Sampled { get; set; } + public int Scored { get; set; } + public double AverageScore { get; set; } + public List ByAuthor { get; set; } = new List(); + public List ByUnit { get; set; } = new List(); + public List ByDefinition { get; set; } = new List(); + public List ByCriterion { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs b/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs index be2b90ab1..799a744e2 100644 --- a/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs +++ b/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs @@ -145,6 +145,12 @@ public class RmsRecordDefinitionVersion : IEntity public int Version { get; set; } public int State { get; set; } public int LifecyclePreset { get; set; } + /// + /// — how many Records of this definition may exist on one Call + /// (plan section 5.2.1). Immutable once published, like everything else on a version: a Record created + /// under the old rule is never retrospectively a duplicate. + /// + public int Cardinality { get; set; } = (int)RmsRecordCardinality.MultiplePerCall; /// Comma-separated PersonnelRole ids that narrow Record_Review for this definition; empty = anyone holding it. public string ReviewerRoleIds { get; set; } /// Comma-separated PersonnelRole ids that narrow Record_Approve; empty = anyone holding it. @@ -575,6 +581,8 @@ public class RecordDefinitionDraftInput public string Description { get; set; } public string PermittedSubjectTypes { get; set; } public RmsLifecyclePreset LifecyclePreset { get; set; } = RmsLifecyclePreset.QuickEntry; + /// How many Records of this definition may exist on one Call (plan section 5.2.1). + public RmsRecordCardinality Cardinality { get; set; } = RmsRecordCardinality.MultiplePerCall; public List ReviewerRoleIds { get; set; } = new List(); public List ApproverRoleIds { get; set; } = new List(); public int? ReviewDueHours { get; set; } @@ -681,6 +689,7 @@ public class RecordDefinitionSummary public int? DraftVersion { get; set; } public bool Retired { get; set; } public string LifecyclePreset { get; set; } + public string Cardinality { get; set; } public string MinimumClientCapability { get; set; } public string TemplateKey { get; set; } public string JurisdictionProfileKey { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsTemplatePacks.cs b/Core/Resgrid.Model/Records/RmsTemplatePacks.cs index a2f6028b8..878211b36 100644 --- a/Core/Resgrid.Model/Records/RmsTemplatePacks.cs +++ b/Core/Resgrid.Model/Records/RmsTemplatePacks.cs @@ -138,6 +138,8 @@ public class RecordTemplateDefinition public string Category { get; set; } public string Description { get; set; } public RmsLifecyclePreset LifecyclePreset { get; set; } = RmsLifecyclePreset.QuickEntry; + /// How many Records of this definition may exist on one Call (plan section 5.2.1). + public RmsRecordCardinality Cardinality { get; set; } = RmsRecordCardinality.MultiplePerCall; public string NumberPrefix { get; set; } /// Template default for ; ICS-style forms number per incident. public bool PerIncidentSequence { get; set; } diff --git a/Core/Resgrid.Model/Repositories/IContactPreplanRepository.cs b/Core/Resgrid.Model/Repositories/IContactPreplanRepository.cs new file mode 100644 index 000000000..ecff806f7 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IContactPreplanRepository.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// Contact pre-plans (Contacts plan Phase A, A3). + public interface IContactPreplanRepository : IRepository + { + /// The live (non-deleted) pre-plan for a contact, or null. + Task GetPreplanByContactIdAsync(string contactId, int departmentId); + + /// Live pre-plans for a set of contacts in one query. + Task> GetPreplansByContactIdsAsync(int departmentId, IEnumerable contactIds); + + /// All live pre-plans for a department (review-currency index badge). + Task> GetPreplansByDepartmentIdAsync(int departmentId); + } + + /// Repeating premise hazards on a contact pre-plan (Contacts plan Phase A, A3). + public interface IContactPreplanHazardRepository : IRepository + { + Task> GetHazardsByPreplanIdAsync(string contactPreplanId, int departmentId); + + Task> GetHazardsByContactIdAsync(string contactId, int departmentId); + + /// Live hazards for a set of contacts in one query. + Task> GetHazardsByContactIdsAsync(int departmentId, IEnumerable contactIds); + } + + /// Contact site attachments (Contacts plan Phase A, A3). Metadata reads never load the blob. + public interface IContactAttachmentRepository : IRepository + { + /// Live attachment rows for a contact WITHOUT the Data column. + Task> GetAttachmentMetaByContactIdAsync(string contactId, int departmentId); + + /// Live attachment rows for a set of contacts WITHOUT the Data column. + Task> GetAttachmentMetaByContactIdsAsync(int departmentId, IEnumerable contactIds); + + /// One attachment including its blob. + Task GetAttachmentByIdAsync(int contactAttachmentId); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs index b39c41668..7214eace5 100644 --- a/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs @@ -91,5 +91,11 @@ public interface IRmsExternalOrderFillsRepository : IRepositoryEvery fill of a set of orders in one pass; the deployment list would otherwise query once per order. Task> GetForOrdersAsync(int departmentId, IEnumerable orderIds); Task GetByIdForDepartmentAsync(int departmentId, string fillId); + + /// + /// Claims the fill at . False when another writer moved first: the + /// in-memory check alone cannot stop two callers from both reading Requested and both writing. + /// + Task TryBumpRowVersionAsync(int departmentId, string fillId, long expectedVersion, CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Repositories/IRmsFieldRolloutRepository.cs b/Core/Resgrid.Model/Repositories/IRmsFieldRolloutRepository.cs index ef983b777..16a8e9a2e 100644 --- a/Core/Resgrid.Model/Repositories/IRmsFieldRolloutRepository.cs +++ b/Core/Resgrid.Model/Repositories/IRmsFieldRolloutRepository.cs @@ -9,7 +9,7 @@ namespace Resgrid.Model.Repositories public interface IRmsFieldRolloutEventsRepository : IRepository { /// Every event in the window, oldest first. Bounded by so a dashboard cannot pull the archive. - Task> GetForWindowAsync(int departmentId, DateTime sinceUtc, int take); + Task> GetForWindowAsync(int departmentId, DateTime sinceUtc, int take, CancellationToken cancellationToken = default); /// Inserts a batch in one round trip; returns how many rows were written. Task InsertBatchAsync(IEnumerable events, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs new file mode 100644 index 000000000..979cca42b --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// Paged, filtered occupancy listing. + public sealed class RmsOccupancyQuery + { + public string Search { get; set; } + public int? Status { get; set; } + public int? OccupancyType { get; set; } + public bool? ReviewOverdue { get; set; } + public bool? HazmatOnSite { get; set; } + public int Skip { get; set; } + public int Take { get; set; } = 50; + } + + public interface IRmsOccupanciesRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string occupancyId); + Task> GetByIdsAsync(int departmentId, IEnumerable occupancyIds); + Task> QueryAsync(int departmentId, RmsOccupancyQuery query); + Task CountAsync(int departmentId, RmsOccupancyQuery query); + Task> GetByNormalizedAddressAsync(int departmentId, string normalizedAddress); + Task> GetAllLiveAsync(int departmentId); + Task CountLiveAsync(int departmentId); + Task CountReviewOverdueAsync(int departmentId, DateTime utcNow); + Task TryBumpRowVersionAsync(int departmentId, string occupancyId, long expectedVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsOccupancyContactLinksRepository : IRepository + { + Task> GetForOccupancyAsync(int departmentId, string occupancyId); + Task> GetForContactAsync(int departmentId, string contactId); + Task> GetForContactsAsync(int departmentId, IEnumerable contactIds); + Task GetByIdForDepartmentAsync(int departmentId, string linkId); + } + + public interface IRmsOccupancyHazardsRepository : IRepository + { + Task> GetForOccupancyAsync(int departmentId, string occupancyId); + Task> GetForOccupanciesAsync(int departmentId, IEnumerable occupancyIds); + Task GetByIdForDepartmentAsync(int departmentId, string hazardId); + } + + public interface IRmsOccupancyCrosswalksRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string crosswalkId); + Task GetBySourceAsync(int departmentId, RmsOccupancyCrosswalkSourceKind sourceKind, string sourceId); + Task> GetByStateAsync(int departmentId, RmsOccupancyCrosswalkState state, int skip, int take); + Task> GetForOccupancyAsync(int departmentId, string occupancyId); + Task> GetForContactAsync(int departmentId, string contactId); + Task> GetForContactsAsync(int departmentId, IEnumerable contactIds); + Task> GetAllForDepartmentAsync(int departmentId); + Task CountByStateAsync(int departmentId, RmsOccupancyCrosswalkState state); + } + + public interface IRmsOccupancyFieldProvenancesRepository : IRepository + { + Task> GetForOccupancyAsync(int departmentId, string occupancyId); + Task DeleteForOccupancyAsync(int departmentId, string occupancyId, CancellationToken cancellationToken = default); + } + + public interface IRmsOccupancyOwnershipsRepository : IRepository + { + Task GetForDepartmentAsync(int departmentId); + } + + public interface IRmsCodeSetsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string codeSetId); + Task> GetForDepartmentAsync(int departmentId, bool includeInactive); + } + + public interface IRmsCodeSectionsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string sectionId); + Task> GetForCodeSetAsync(int departmentId, string codeSetId); + Task> GetByIdsAsync(int departmentId, IEnumerable sectionIds); + } + + public interface IRmsInspectionProgramsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string programId); + Task> GetForDepartmentAsync(int departmentId, bool includeInactive); + } + + public sealed class RmsInspectionQuery + { + public string OccupancyId { get; set; } + public string ProgramId { get; set; } + public IList States { get; set; } + public string InspectorUserId { get; set; } + public DateTime? ScheduledBefore { get; set; } + public int Skip { get; set; } + public int Take { get; set; } = 50; + } + + public interface IRmsInspectionsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string inspectionId); + Task> QueryAsync(int departmentId, RmsInspectionQuery query); + Task CountAsync(int departmentId, RmsInspectionQuery query); + Task> GetForOccupancyAsync(int departmentId, string occupancyId); + /// Latest completed inspection per occupancy for a program; the scheduler's frequency anchor. + Task> GetLastCompletedByOccupancyAsync(int departmentId, string programId); + Task> GetOpenForProgramAsync(int departmentId, string programId); + Task TryBumpRowVersionAsync(int departmentId, string inspectionId, long expectedVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsViolationsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string violationId); + Task> GetForInspectionAsync(int departmentId, string inspectionId); + Task> GetForOccupancyAsync(int departmentId, string occupancyId, bool openOnly); + Task> GetOpenAsync(int departmentId, int take); + Task> GetOverdueNotEmittedAsync(int departmentId, DateTime utcNow, int take); + Task CountOpenAsync(int departmentId); + Task CountOverdueAsync(int departmentId, DateTime utcNow); + Task> CountOpenByOccupancyAsync(int departmentId, IEnumerable occupancyIds); + } + + public interface IRmsHydrantsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string hydrantId); + Task GetByNumberAsync(int departmentId, string hydrantNumber); + Task> GetAllLiveAsync(int departmentId); + Task> GetByIdsAsync(int departmentId, IEnumerable hydrantIds); + Task> GetInBoundsAsync(int departmentId, decimal minLat, decimal maxLat, decimal minLon, decimal maxLon, int take); + Task CountLiveAsync(int departmentId); + Task CountOutOfServiceAsync(int departmentId); + Task CountTestDueAsync(int departmentId, DateTime cutoffUtc); + } + + public interface IRmsHydrantFlowTestsRepository : IRepository + { + Task> GetForHydrantAsync(int departmentId, string hydrantId); + } + + public interface IRmsHydrantMaintenancesRepository : IRepository + { + Task> GetForHydrantAsync(int departmentId, string hydrantId); + } + + public interface IRmsPermitTypesRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string permitTypeId); + Task> GetForDepartmentAsync(int departmentId, bool includeInactive); + } + + public sealed class RmsPermitQuery + { + public string OccupancyId { get; set; } + public string PermitTypeId { get; set; } + public IList States { get; set; } + public DateTime? ExpiresBefore { get; set; } + public int Skip { get; set; } + public int Take { get; set; } = 50; + } + + public interface IRmsPermitsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string permitId); + Task> QueryAsync(int departmentId, RmsPermitQuery query); + Task CountAsync(int departmentId, RmsPermitQuery query); + Task> GetForOccupancyAsync(int departmentId, string occupancyId); + Task> GetExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc, int take); + Task CountExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc); + Task TryBumpRowVersionAsync(int departmentId, string permitId, long expectedVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsPlanReviewsRepository : IRepository + { + Task> GetForPermitAsync(int departmentId, string permitId); + Task GetByIdForDepartmentAsync(int departmentId, string reviewId); + } + + public interface IRmsCrrActivitiesRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string activityId); + Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take); + Task> GetForOccupancyAsync(int departmentId, string occupancyId); + } + + public interface IRmsPreventionAttachmentsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string attachmentId); + Task> GetMetadataForParentAsync(int departmentId, RmsPreventionParentKind parentKind, string parentId); + } + + public interface IRmsPreventionSequencesRepository : IRepository + { + /// Atomically allocates the next number for a department/kind/year; safe under concurrent callers in either dialect. + Task NextAsync(int departmentId, string kind, int year, CancellationToken cancellationToken = default); + } + + public interface IRmsInvestigationCasesRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string caseId); + Task> GetByIdsAsync(int departmentId, IEnumerable caseIds); + Task> GetForDepartmentAsync(int departmentId, bool includeClosed, int skip, int take); + Task CountOpenAsync(int departmentId); + Task TryBumpRowVersionAsync(int departmentId, string caseId, long expectedVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsInvestigationCaseIncidentsRepository : IRepository + { + Task> GetForCaseAsync(int departmentId, string caseId); + Task> GetForRecordAsync(int departmentId, string recordId); + } + + public interface IRmsInvestigationCaseMembersRepository : IRepository + { + Task> GetForCaseAsync(int departmentId, string caseId); + Task> GetActiveForUserAsync(int departmentId, string userId); + Task GetByIdForDepartmentAsync(int departmentId, string memberId); + } + + public interface IRmsInvestigationNotesRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string noteId); + Task> GetForCaseAsync(int departmentId, string caseId); + Task LockForCaseAsync(int departmentId, string caseId, DateTime utcNow, CancellationToken cancellationToken = default); + } + + public interface IRmsInvestigationEvidenceRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string evidenceId); + Task> GetForCaseAsync(int departmentId, string caseId); + } + + public interface IRmsInvestigationCustodyRepository : IRepository + { + Task> GetForEvidenceAsync(int departmentId, string evidenceId); + } + + public interface IRmsInvestigationReferralsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string referralId); + Task> GetForCaseAsync(int departmentId, string caseId); + } + + public interface IRmsQualityRubricsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string rubricId); + Task> GetForDepartmentAsync(int departmentId, bool includeInactive); + } + + public interface IRmsQualityReviewsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string reviewId); + Task> GetForRecordAsync(int departmentId, string recordId); + Task> GetPendingAsync(int departmentId, int take); + Task> GetScoredSinceAsync(int departmentId, DateTime sinceUtc, int take); + Task> GetReviewedRecordIdsAsync(int departmentId, IEnumerable recordIds); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs index 5bf002b15..f75d852bd 100644 --- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs @@ -113,6 +113,8 @@ public interface IRmsRecordAttachmentsRepository : IRepository Task> GetPendingScanAsync(int departmentId, int take); + /// Attachments in a scan state (RMS-4 release telemetry). + Task CountByScanStateAsync(int departmentId, int scanState); } public interface IRmsExternalReferencesRepository : IRepository @@ -130,6 +132,10 @@ public interface IDomainEventOutboxRepository : IRepository MarkDispatchedAsync(long domainEventOutboxId, DateTime utcNow, CancellationToken cancellationToken = default); Task MarkFailedAsync(long domainEventOutboxId, string error, DateTime? nextAttemptOn, bool terminal, CancellationToken cancellationToken = default); Task CountByStateAsync(int state); + /// Per-department outbox rows in a state (RMS-4 release telemetry). + Task CountByStateForDepartmentAsync(int departmentId, int state); + /// Age anchor of the department's oldest undispatched row (RMS-4 release telemetry). + Task GetOldestPendingCreatedOnForDepartmentAsync(int departmentId); Task GetOldestPendingCreatedOnAsync(); Task PurgeDispatchedOlderThanAsync(DateTime cutoffUtc, CancellationToken cancellationToken = default); } @@ -156,6 +162,10 @@ public interface IRmsRevisionsRepository : IRepository public interface IRmsAccessAuditsRepository : IRepository { Task> GetForRecordAsync(int departmentId, string recordId, int take); + /// Audit rows of one action inside a window (RMS-4 release telemetry: legacy-write denials, authorization denials). + Task CountByActionSinceAsync(int departmentId, int action, DateTime sinceUtc); + /// Audit rows for an aggregate that is not a Record (investigation cases), newest first. + Task> GetForAggregateAsync(int departmentId, string aggregateId, int take); } public interface IRmsRecordSearchProjectionsRepository : IRepository diff --git a/Core/Resgrid.Model/Services/ICallsService.cs b/Core/Resgrid.Model/Services/ICallsService.cs index 98dcbcc06..a244cc4ea 100644 --- a/Core/Resgrid.Model/Services/ICallsService.cs +++ b/Core/Resgrid.Model/Services/ICallsService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -434,6 +434,9 @@ Task ClearGroupForDispatchesAsync(int departmentGroupId, Task DeleteCallContactsAsync(int callId, CancellationToken cancellationToken = default(CancellationToken)); + /// Direct accessor for the Call/Contact links of a call (Contacts plan Phase A, A4). + Task> GetCallContactsByCallIdAsync(int callId); + Task SaveCallVideoFeedAsync(CallVideoFeed feed, CancellationToken cancellationToken = default(CancellationToken)); Task GetCallVideoFeedByIdAsync(string callVideoFeedId); diff --git a/Core/Resgrid.Model/Services/IContactsService.cs b/Core/Resgrid.Model/Services/IContactsService.cs index 161bfe389..a02b21024 100644 --- a/Core/Resgrid.Model/Services/IContactsService.cs +++ b/Core/Resgrid.Model/Services/IContactsService.cs @@ -26,5 +26,55 @@ public interface IContactsService Task DeleteContactNoteTypeAsync(ContactNoteType type, CancellationToken cancellationToken = default(CancellationToken)); Task SaveContactNoteAsync(ContactNote note, CancellationToken cancellationToken = default(CancellationToken)); Task DeleteContactAsync(string contactId, string userId, int departmentId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)); + + // ── Pre-plans (Contacts plan Phase A, A4) ───────────────────────────────────────── + + /// The live pre-plan for a contact with its hazards, gate code revealed; null when none exists. + Task GetPreplanByContactIdAsync(string contactId, int departmentId); + + /// Upserts the contact's pre-plan (one live row per contact), encrypts the gate code at rest, and audits. + Task SavePreplanAsync(ContactPreplan preplan, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)); + + /// Soft-deletes the pre-plan and its hazards, and audits. + Task DeletePreplanAsync(string contactId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)); + + /// Live pre-plans whose NextReviewDue has passed. + Task> GetPreplansDueForReviewAsync(int departmentId); + + Task> GetHazardsByContactIdAsync(string contactId, int departmentId); + + Task GetHazardByIdAsync(string contactPreplanHazardId); + + /// Creates or updates a hazard; creates the contact's pre-plan shell first when none exists. + Task SaveHazardAsync(ContactPreplanHazard hazard, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)); + + Task DeleteHazardAsync(string contactPreplanHazardId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)); + + // ── Site attachments ─────────────────────────────────────────────────────────────── + + /// Attachment metadata (never the blob) for a contact, optionally filtered by type. + Task> GetContactAttachmentsAsync(string contactId, int departmentId, ContactAttachmentTypes? type = null); + + /// One attachment including its blob. + Task GetContactAttachmentByIdAsync(int contactAttachmentId); + + Task SaveContactAttachmentAsync(ContactAttachment attachment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)); + + Task DeleteContactAttachmentAsync(int contactAttachmentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)); + + // ── Call surfacing ───────────────────────────────────────────────────────────────── + + /// ShouldAlert notes that are not deleted and not expired, keyed by contact id. + Task>> GetAlertNotesByContactIdsAsync(int departmentId, IEnumerable contactIds); + + /// + /// Composes the linked contacts, pre-plans, hazards, live alert notes and attachment metadata for a call in + /// one round trip. Returns null when the call does not exist or belongs to another department. The RMS NERIS + /// prefill contract (A8): additive changes only. + /// + Task GetCallSiteInfoAsync(int callId, int departmentId); + + /// Lightweight per-contact summary (has pre-plan, alert note count, hazard count) for the calls' linked contacts. + Task>> GetCallContactSummariesAsync(int departmentId, IEnumerable calls); } } diff --git a/Core/Resgrid.Model/Services/IProtectedReadService.cs b/Core/Resgrid.Model/Services/IProtectedReadService.cs index 19eceefda..8bee30be1 100644 --- a/Core/Resgrid.Model/Services/IProtectedReadService.cs +++ b/Core/Resgrid.Model/Services/IProtectedReadService.cs @@ -160,6 +160,22 @@ Task ResolveDistributionListsForReadAsync(int departmentId, Task ResolveContactNotesForReadAsync(int departmentId, IReadOnlyList notes, string grantToken, string userId, CancellationToken cancellationToken = default); + /// Resolves contact pre-plans (catalog v12): the pre-plan's own text fields only; hazards resolve separately. + Task ResolveContactPreplansForReadAsync(int departmentId, + IReadOnlyList preplans, string grantToken, string userId, CancellationToken cancellationToken = default); + + /// Resolves premise hazards (catalog v12): title, description, location text and coordinates. + Task ResolveContactPreplanHazardsForReadAsync(int departmentId, + IReadOnlyList hazards, string grantToken, string userId, CancellationToken cancellationToken = default); + + /// + /// Resolves contact site attachments (catalog v12): name and file name always; includeData additionally + /// decrypts the binary payload, and a concealed payload reads as null (ciphertext bytes are never served). + /// + Task ResolveContactAttachmentsForReadAsync(int departmentId, + IReadOnlyList attachments, string grantToken, string userId, + bool includeData = false, CancellationToken cancellationToken = default); + /// Resolves a member's department-scoped emergency contacts (catalog v4). Task ResolveMemberEmergencyContactsForReadAsync(int departmentId, IReadOnlyList contacts, string grantToken, string userId, diff --git a/Core/Resgrid.Model/Services/IProtectedWriteService.cs b/Core/Resgrid.Model/Services/IProtectedWriteService.cs index c80fa256c..89676a3cd 100644 --- a/Core/Resgrid.Model/Services/IProtectedWriteService.cs +++ b/Core/Resgrid.Model/Services/IProtectedWriteService.cs @@ -137,6 +137,23 @@ Task PrepareCallLogWriteAsync(int departmentId, CallLog lo Task PrepareContactNoteWriteAsync(int departmentId, ContactNote note, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + /// + /// Prepares a contact pre-plan (catalog v12). is the stored row a + /// REDACTED sentinel is restored from on edits (the editor never had that value revealed); null for creates. + /// + Task PrepareContactPreplanWriteAsync(int departmentId, ContactPreplan preplan, + ContactPreplan existingPreplan, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default); + + /// Prepares a premise hazard (catalog v12); restores sentinels on edits. + Task PrepareContactPreplanHazardWriteAsync(int departmentId, ContactPreplanHazard hazard, + ContactPreplanHazard existingHazard, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default); + + /// Prepares a contact site attachment (catalog v12): name, file name and the binary payload. + Task PrepareContactAttachmentWriteAsync(int departmentId, ContactAttachment attachment, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + /// /// Prepares a unit state (catalog v2): note, geolocation text, and the typed /// latitude/longitude which move into their companion envelope columns. diff --git a/Core/Resgrid.Model/Services/IRecordsInvestigationsService.cs b/Core/Resgrid.Model/Services/IRecordsInvestigationsService.cs new file mode 100644 index 000000000..aa0d0a12f --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsInvestigationsService.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public class InvestigationCaseAggregate + { + public RmsInvestigationCase Case { get; set; } + public List Members { get; set; } = new List(); + public List Incidents { get; set; } = new List(); + public List Notes { get; set; } = new List(); + public List Evidence { get; set; } = new List(); + public List Referrals { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + public RmsInvestigationRole? CallerRole { get; set; } + public ProtectedReadResult Protection { get; set; } = new ProtectedReadResult(); + } + + /// + /// Investigation cases (RMS plan section 4.4, RMS-5). Every read and change requires RecordRestricted_View + /// and an active membership on the case; department administrators are not exempt (plan section 5.7). Every read is + /// audited to RmsAccessAudits with purpose "investigation", exports likewise. Findings never touch the linked incident + /// revision; is the only bridge. + /// + public interface IRecordsInvestigationsService + { + Task IsModuleEnabledAsync(int departmentId); + + /// Cases the caller is an active member of. Nobody lists cases they are not on. + Task> ListMyCasesAsync(int departmentId, string userId, bool includeClosed); + Task GetAsync(int departmentId, string userId, string caseId, string ipAddress = null); + /// Opens a case; the opener becomes Lead. Needs RecordRestricted_View. + Task OpenAsync(int departmentId, string userId, string title, string occupancyId, int? callId, string incidentSummary, CancellationToken cancellationToken = default); + Task UpdateAsync(int departmentId, string userId, RmsInvestigationCase input, CancellationToken cancellationToken = default); + + Task AddMemberAsync(int departmentId, string userId, string caseId, string memberUserId, RmsInvestigationRole role, CancellationToken cancellationToken = default); + Task RemoveMemberAsync(int departmentId, string userId, string caseId, string memberId, CancellationToken cancellationToken = default); + + /// Links an incident report the caller can view; pins its current official revision. + Task LinkIncidentAsync(int departmentId, string userId, string caseId, string recordId, CancellationToken cancellationToken = default); + Task UnlinkIncidentAsync(int departmentId, string userId, string caseId, string linkId, CancellationToken cancellationToken = default); + + Task AddNoteAsync(int departmentId, string userId, string caseId, RmsInvestigationNoteKind kind, DateTime occurredOn, string subject, string body, CancellationToken cancellationToken = default); + Task UpdateNoteAsync(int departmentId, string userId, string noteId, string subject, string body, CancellationToken cancellationToken = default); + + Task AddEvidenceAsync(int departmentId, string userId, string caseId, RmsInvestigationEvidence input, CancellationToken cancellationToken = default); + /// Appends a custody transfer; the evidence row's custodian and state follow. Custody history is never edited. + Task TransferCustodyAsync(int departmentId, string userId, string evidenceId, string toUserId, string toExternal, string reason, RmsEvidenceState resultingState, CancellationToken cancellationToken = default); + Task> GetCustodyChainAsync(int departmentId, string userId, string evidenceId); + + Task AddReferralAsync(int departmentId, string userId, string caseId, string agency, string reason, string referenceNumber, CancellationToken cancellationToken = default); + Task UpdateReferralStateAsync(int departmentId, string userId, string referralId, RmsReferralState state, CancellationToken cancellationToken = default); + + /// Records cause, origin and findings (Lead or Investigator); moves the case to PendingReview. + Task RecordFindingsAsync(int departmentId, string userId, string caseId, RmsFireCauseClassification classification, string causeDetail, string originDescription, string findings, bool recommendsIncidentAmendment, CancellationToken cancellationToken = default); + /// Approves findings; the approver must be a Reviewer or Lead and may not be the findings author. + Task ApproveFindingsAsync(int departmentId, string userId, string caseId, CancellationToken cancellationToken = default); + Task ReturnFindingsAsync(int departmentId, string userId, string caseId, string reason, CancellationToken cancellationToken = default); + /// Closes the case (approved findings required) and locks every note. + Task CloseAsync(int departmentId, string userId, string caseId, string closureReason, CancellationToken cancellationToken = default); + Task ReopenAsync(int departmentId, string userId, string caseId, string reason, CancellationToken cancellationToken = default); + + /// Export packet (JSON) of everything the caller may see; audited as Export. + Task ExportAsync(int departmentId, string userId, string caseId, string ipAddress = null, CancellationToken cancellationToken = default); + Task> GetAccessAuditAsync(int departmentId, string userId, string caseId, int take); + } + + /// Post-finalization quality review (RMS plan section 4.7, RMS-4 optional module). + public interface IRecordsQualityReviewService + { + Task IsModuleEnabledAsync(int departmentId); + Task> GetRubricsAsync(int departmentId, string userId, bool includeInactive); + Task GetRubricAsync(int departmentId, string userId, string rubricId); + Task SaveRubricAsync(int departmentId, string userId, RmsQualityRubric input, List criteria, CancellationToken cancellationToken = default); + /// Deterministically samples finalized, not-yet-reviewed records for the rubric (seeded by the rubric id and day) into pending reviews. + Task> SampleAsync(int departmentId, string userId, string rubricId, DateTime sinceUtc, CancellationToken cancellationToken = default); + Task> GetPendingAsync(int departmentId, string userId, int take); + Task GetReviewAsync(int departmentId, string userId, string reviewId); + Task ScoreAsync(int departmentId, string userId, string reviewId, List findings, string note, bool amendmentRecommended, CancellationToken cancellationToken = default); + Task> GetForRecordAsync(int departmentId, string userId, string recordId); + Task GetTrendsAsync(int departmentId, string userId, DateTime sinceUtc); + } + + /// RMS-4 release baseline telemetry. + public interface IRecordsReleaseTelemetryService + { + Task GetAsync(int departmentId, string userId, int windowHours = 24); + /// Builds the snapshot without a caller (worker) and writes it as one structured log line. + Task LogSnapshotAsync(int departmentId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsOccupancyService.cs b/Core/Resgrid.Model/Services/IRecordsOccupancyService.cs new file mode 100644 index 000000000..a336ee183 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsOccupancyService.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Repositories; + +namespace Resgrid.Model.Services +{ + /// An occupancy with its hazards, contact links and provenance, revealed for the ambient caller. + public class OccupancyAggregate + { + public RmsOccupancy Occupancy { get; set; } + public List Hazards { get; set; } = new List(); + public List ContactLinks { get; set; } = new List(); + public List Provenance { get; set; } = new List(); + public List Crosswalks { get; set; } = new List(); + public int OpenViolationCount { get; set; } + public ProtectedReadResult Protection { get; set; } = new ProtectedReadResult(); + } + + /// + /// The occupancy/property master and its transition from Contacts pre-plans (RMS plan section 4.3, RMS-5): + /// CRUD on the master, the ContactPreplan/Contact/POI crosswalk inventory and decisions, merge, field provenance, + /// the department's structure-write ownership switch, and . + /// Reads need Record_View; changes need the PreventionAdmin permission (registry value 69). + /// + public interface IRecordsOccupancyService + { + Task IsModuleEnabledAsync(int departmentId); + + Task> ListAsync(int departmentId, string userId, RmsOccupancyQuery query); + Task CountAsync(int departmentId, string userId, RmsOccupancyQuery query); + Task GetAsync(int departmentId, string userId, string occupancyId); + Task SaveAsync(int departmentId, string userId, RmsOccupancy input, CancellationToken cancellationToken = default); + Task DeleteAsync(int departmentId, string userId, string occupancyId, CancellationToken cancellationToken = default); + Task MarkReviewedAsync(int departmentId, string userId, string occupancyId, int nextReviewMonths, CancellationToken cancellationToken = default); + + Task SaveHazardAsync(int departmentId, string userId, RmsOccupancyHazard input, CancellationToken cancellationToken = default); + Task DeleteHazardAsync(int departmentId, string userId, string hazardId, CancellationToken cancellationToken = default); + + Task LinkContactAsync(int departmentId, string userId, string occupancyId, string contactId, RmsOccupancyContactRole role, bool isPrimary, CancellationToken cancellationToken = default); + Task UnlinkContactAsync(int departmentId, string userId, string linkId, CancellationToken cancellationToken = default); + + /// Scans pre-plans, contacts with site coordinates and POIs into crosswalk candidates grouped by normalized address / proximity. + Task InventoryCandidatesAsync(int departmentId, string userId, CancellationToken cancellationToken = default); + Task> GetCandidatesAsync(int departmentId, string userId, RmsOccupancyCrosswalkState state, int skip, int take); + /// Binds a candidate to an occupancy; with a null occupancy id a new occupancy is created from the source with field provenance. + Task LinkCandidateAsync(int departmentId, string userId, string crosswalkId, string occupancyId, CancellationToken cancellationToken = default); + Task RejectCandidateAsync(int departmentId, string userId, string crosswalkId, string reason, CancellationToken cancellationToken = default); + Task MergeAsync(int departmentId, string userId, string sourceOccupancyId, string targetOccupancyId, CancellationToken cancellationToken = default); + + Task GetReconciliationStatusAsync(int departmentId); + /// Switches structure writes to RMS for the department. Refused while any candidate or unreconciled pre-plan remains. + Task SwitchWriteOwnershipAsync(int departmentId, string userId, string reason, CancellationToken cancellationToken = default); + + Task GetDispatchProjectionAsync(int departmentId, string occupancyId, CancellationToken cancellationToken = default); + Task GetDispatchProjectionForContactAsync(int departmentId, string contactId, CancellationToken cancellationToken = default); + Task> GetDispatchProjectionsForContactsAsync(int departmentId, IEnumerable contactIds, CancellationToken cancellationToken = default); + } + + /// + /// What the Contacts service asks before a pre-plan write and during a site-info read (RMS plan section 4.3 write + /// cutover): after a department switches structure ownership to RMS, Contacts pre-plan writes are refused with a + /// stable reason and Contacts reads project from the occupancy master through the Phase A pre-plan shape. + /// Implemented in Records; Contacts takes it lazily to avoid a construction cycle. + /// + public interface IContactPreplanOwnershipGate + { + public const string RecordsOwnedReason = "contacts_preplan_records_owned"; + + Task IsRecordsOwnedAsync(int departmentId); + /// The occupancy projection for each contact that is linked to one, in the Contacts pre-plan shape; empty when Contacts still owns writes. + Task> GetPreplanProjectionsAsync(int departmentId, IEnumerable contactIds, CancellationToken cancellationToken = default); + /// The occupancy id a contact projects from, when any. + Task GetOccupancyIdForContactAsync(int departmentId, string contactId); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsPreventionServices.cs b/Core/Resgrid.Model/Services/IRecordsPreventionServices.cs new file mode 100644 index 000000000..266a6df32 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsPreventionServices.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Repositories; + +namespace Resgrid.Model.Services +{ + public class InspectionAggregate + { + public RmsInspection Inspection { get; set; } + public RmsInspectionProgram Program { get; set; } + public RmsOccupancy Occupancy { get; set; } + public List Checklist { get; set; } = new List(); + public List Items { get; set; } = new List(); + public List Violations { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + public ProtectedReadResult Protection { get; set; } = new ProtectedReadResult(); + } + + /// Inspection programs, code sets, inspections, violations and re-inspection (RMS plan section 4.3, RMS-5). + public interface IRecordsInspectionsService + { + Task IsModuleEnabledAsync(int departmentId); + + Task> GetCodeSetsAsync(int departmentId, string userId, bool includeInactive); + Task SaveCodeSetAsync(int departmentId, string userId, RmsCodeSet input, CancellationToken cancellationToken = default); + Task> GetCodeSectionsAsync(int departmentId, string userId, string codeSetId); + Task SaveCodeSectionAsync(int departmentId, string userId, RmsCodeSection input, CancellationToken cancellationToken = default); + /// CSV import: section,title,text,severity,days. Returns the number of sections created. + Task ImportCodeSectionsAsync(int departmentId, string userId, string codeSetId, string csv, CancellationToken cancellationToken = default); + + Task> GetProgramsAsync(int departmentId, string userId, bool includeInactive); + Task GetProgramAsync(int departmentId, string userId, string programId); + Task SaveProgramAsync(int departmentId, string userId, RmsInspectionProgram input, List checklist, CancellationToken cancellationToken = default); + + Task> ListAsync(int departmentId, string userId, RmsInspectionQuery query); + Task CountAsync(int departmentId, string userId, RmsInspectionQuery query); + Task GetAsync(int departmentId, string userId, string inspectionId); + Task ScheduleAsync(int departmentId, string userId, string occupancyId, string programId, DateTime scheduledOn, string inspectorUserId, CancellationToken cancellationToken = default); + Task StartAsync(int departmentId, string userId, string inspectionId, CancellationToken cancellationToken = default); + /// Records item results and notes; failed items become Open violations citing the item's code section. + Task CompleteAsync(int departmentId, string userId, string inspectionId, List items, string notes, string signatureName, CancellationToken cancellationToken = default); + Task ScheduleReinspectionAsync(int departmentId, string userId, string inspectionId, DateTime scheduledOn, CancellationToken cancellationToken = default); + Task CloseAsync(int departmentId, string userId, string inspectionId, CancellationToken cancellationToken = default); + Task CancelAsync(int departmentId, string userId, string inspectionId, string reason, CancellationToken cancellationToken = default); + Task IssueNoticeAsync(int departmentId, string userId, string inspectionId, string noticeReference, CancellationToken cancellationToken = default); + + Task> GetViolationsForOccupancyAsync(int departmentId, string userId, string occupancyId, bool openOnly); + Task> GetOpenViolationsAsync(int departmentId, string userId, int take); + Task SaveViolationAsync(int departmentId, string userId, RmsViolation input, CancellationToken cancellationToken = default); + Task TransitionViolationAsync(int departmentId, string userId, string violationId, RmsViolationState target, string note, CancellationToken cancellationToken = default); + + /// Creates Scheduled inspections for occupancies whose program frequency has lapsed; idempotent per occupancy/program. + Task GenerateDueInspectionsAsync(int departmentId, DateTime utcNow, CancellationToken cancellationToken = default); + /// Raises trigger 162 once per violation as it passes its due date. + Task EmitOverdueViolationsAsync(int departmentId, DateTime utcNow, CancellationToken cancellationToken = default); + } + + public class HydrantAggregate + { + public RmsHydrant Hydrant { get; set; } + public List FlowTests { get; set; } = new List(); + public List Maintenance { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + } + + /// Hydrants and water sources, flow tests, maintenance, service state, CSV import and the response-map layer. + public interface IRecordsHydrantsService + { + Task IsModuleEnabledAsync(int departmentId); + Task> ListAsync(int departmentId, string userId); + Task GetAsync(int departmentId, string userId, string hydrantId); + Task SaveAsync(int departmentId, string userId, RmsHydrant input, CancellationToken cancellationToken = default); + Task DeleteAsync(int departmentId, string userId, string hydrantId, CancellationToken cancellationToken = default); + Task SetServiceStateAsync(int departmentId, string userId, string hydrantId, bool inService, string reason, CancellationToken cancellationToken = default); + Task RecordFlowTestAsync(int departmentId, string userId, RmsHydrantFlowTest input, CancellationToken cancellationToken = default); + Task RecordMaintenanceAsync(int departmentId, string userId, RmsHydrantMaintenance input, CancellationToken cancellationToken = default); + Task ImportCsvAsync(int departmentId, string userId, string csv, CancellationToken cancellationToken = default); + Task> GetMapLayerAsync(int departmentId, string userId, decimal? minLat, decimal? maxLat, decimal? minLon, decimal? maxLon); + Task> GetNearestAsync(int departmentId, decimal latitude, decimal longitude, int take, double maxMeters); + Task CountTestDueAsync(int departmentId, DateTime utcNow); + } + + public class PermitAggregate + { + public RmsPermit Permit { get; set; } + public RmsPermitType Type { get; set; } + public RmsOccupancy Occupancy { get; set; } + public List PlanReviews { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + public ProtectedReadResult Protection { get; set; } = new ProtectedReadResult(); + } + + /// Permits, plan review cycles, conditions, expiration and the optional fee reference. + public interface IRecordsPermitsService + { + Task IsModuleEnabledAsync(int departmentId); + Task> GetTypesAsync(int departmentId, string userId, bool includeInactive); + Task SaveTypeAsync(int departmentId, string userId, RmsPermitType input, CancellationToken cancellationToken = default); + Task> ListAsync(int departmentId, string userId, RmsPermitQuery query); + Task CountAsync(int departmentId, string userId, RmsPermitQuery query); + Task GetAsync(int departmentId, string userId, string permitId); + Task ApplyAsync(int departmentId, string userId, RmsPermit input, CancellationToken cancellationToken = default); + Task UpdateAsync(int departmentId, string userId, RmsPermit input, CancellationToken cancellationToken = default); + Task TransitionAsync(int departmentId, string userId, string permitId, RmsPermitState target, string reason, DateTime? effectiveOn, DateTime? expiresOn, CancellationToken cancellationToken = default); + Task RecordPlanReviewAsync(int departmentId, string userId, string permitId, RmsPlanReviewOutcome outcome, string comments, CancellationToken cancellationToken = default); + Task RecordFeePaidAsync(int departmentId, string userId, string permitId, decimal amount, string invoiceReference, CancellationToken cancellationToken = default); + /// Expires issued permits past ExpiresOn and raises trigger 163 once per permit within the notice window. + Task<(int Expired, int Notified)> SweepExpiryAsync(int departmentId, DateTime utcNow, int noticeDays, CancellationToken cancellationToken = default); + } + + public class CrrSummary + { + public DateTime Start { get; set; } + public DateTime End { get; set; } + public int Activities { get; set; } + public int Audience { get; set; } + public int SmokeAlarmsInstalled { get; set; } + public decimal Hours { get; set; } + public Dictionary ByKind { get; set; } = new Dictionary(); + } + + /// Community risk reduction activities and their summary. + public interface IRecordsCrrService + { + Task IsModuleEnabledAsync(int departmentId); + Task> ListAsync(int departmentId, string userId, DateTime startUtc, DateTime endUtc, int take); + Task GetAsync(int departmentId, string userId, string activityId); + Task SaveAsync(int departmentId, string userId, RmsCrrActivity input, CancellationToken cancellationToken = default); + Task DeleteAsync(int departmentId, string userId, string activityId, CancellationToken cancellationToken = default); + Task GetSummaryAsync(int departmentId, string userId, DateTime startUtc, DateTime endUtc); + } + + /// Attachments on prevention and investigation aggregates; hygiene and scanning are the record-attachment pipeline. + public interface IRecordsPreventionAttachmentsService + { + Task AddAsync(int departmentId, string userId, RmsPreventionParentKind parentKind, string parentId, string fileName, string contentType, byte[] data, string description, bool restricted, CancellationToken cancellationToken = default); + Task> GetMetadataAsync(int departmentId, string userId, RmsPreventionParentKind parentKind, string parentId); + Task GetWithDataAsync(int departmentId, string userId, string attachmentId); + Task RemoveAsync(int departmentId, string userId, string attachmentId, CancellationToken cancellationToken = default); + } + + /// Daily prevention sweep, run by worker 42 alongside the record due-state evaluation. + public interface IRecordsPreventionSweepService + { + Task SweepAsync(CancellationToken cancellationToken = default); + Task SweepDepartmentAsync(int departmentId, DateTime utcNow, CancellationToken cancellationToken = default); + Task GetSummaryAsync(int departmentId, string userId); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsProtectionService.cs b/Core/Resgrid.Model/Services/IRecordsProtectionService.cs index 7099491e1..885290b98 100644 --- a/Core/Resgrid.Model/Services/IRecordsProtectionService.cs +++ b/Core/Resgrid.Model/Services/IRecordsProtectionService.cs @@ -70,6 +70,36 @@ public interface IRecordsProtectionService Task RevealDisclosureProductionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); Task RevealLegalHoldsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); Task RevealSubmissionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + + // ---- RMS-5 prevention and investigations, RMS-4 quality review (catalog v13) ---- + Task ProtectOccupancyAsync(int departmentId, RmsOccupancy row, RmsOccupancy existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealOccupanciesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectOccupancyHazardAsync(int departmentId, RmsOccupancyHazard row, RmsOccupancyHazard existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealOccupancyHazardsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectInspectionAsync(int departmentId, RmsInspection row, RmsInspection existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealInspectionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectViolationAsync(int departmentId, RmsViolation row, RmsViolation existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealViolationsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectPermitAsync(int departmentId, RmsPermit row, RmsPermit existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealPermitsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectPlanReviewAsync(int departmentId, RmsPlanReview row, RmsPlanReview existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealPlanReviewsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectInvestigationCaseAsync(int departmentId, RmsInvestigationCase row, RmsInvestigationCase existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealInvestigationCasesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectInvestigationNoteAsync(int departmentId, RmsInvestigationNote row, RmsInvestigationNote existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealInvestigationNotesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectInvestigationEvidenceAsync(int departmentId, RmsInvestigationEvidence row, RmsInvestigationEvidence existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealInvestigationEvidenceAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectInvestigationCustodyAsync(int departmentId, RmsInvestigationCustody row, string userId = null, CancellationToken cancellationToken = default); + Task RevealInvestigationCustodyAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectInvestigationReferralAsync(int departmentId, RmsInvestigationReferral row, RmsInvestigationReferral existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealInvestigationReferralsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task ProtectQualityReviewAsync(int departmentId, RmsQualityReview row, RmsQualityReview existing, string userId = null, CancellationToken cancellationToken = default); + Task RevealQualityReviewsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + /// Seals a prevention/investigation attachment: name and description as text, the bytes as a binary envelope (catalog v13). + Task ProtectPreventionAttachmentAsync(int departmentId, RmsPreventionAttachment row, RmsPreventionAttachment existing, string userId = null, CancellationToken cancellationToken = default); + /// Reveals prevention attachment metadata, and the bytes when includeData; a concealed binary is nulled and named in RedactedFields. + Task RevealPreventionAttachmentsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default); Task RevealExportRunsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default); /// diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 914053561..cb34fc64a 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -282,6 +282,59 @@ private static List GetCommon() => new TemplateVariableDescriptor("export.expires_on", "When the stored copy expires (UTC)", "datetime", false), }; + private static readonly List InspectionVariables = new List + { + new TemplateVariableDescriptor("inspection.id", "Inspection ID", "string", false), + new TemplateVariableDescriptor("inspection.number", "Inspection number (INSP-yyyy-nnnn)", "string", false), + new TemplateVariableDescriptor("inspection.occupancy_id", "Occupancy ID", "string", false), + new TemplateVariableDescriptor("inspection.occupancy_number", "Occupancy number", "string", false), + new TemplateVariableDescriptor("inspection.occupancy_name", "Occupancy name", "string", false), + new TemplateVariableDescriptor("inspection.program_id", "Inspection program ID", "string", false), + new TemplateVariableDescriptor("inspection.program_name", "Inspection program name", "string", false), + new TemplateVariableDescriptor("inspection.state", "Completed or ReinspectionRequired", "string", false), + new TemplateVariableDescriptor("inspection.result", "Pass, Fail or Conditional", "string", false), + new TemplateVariableDescriptor("inspection.scheduled_on", "When it was scheduled (UTC)", "datetime", false), + new TemplateVariableDescriptor("inspection.completed_on", "When it was completed (UTC)", "datetime", false), + new TemplateVariableDescriptor("inspection.inspector_user_id", "Inspector user ID", "string", false), + new TemplateVariableDescriptor("inspection.violation_count", "Violations opened by this inspection", "int", false), + new TemplateVariableDescriptor("inspection.critical_violation_count", "Critical violations opened", "int", false), + new TemplateVariableDescriptor("inspection.is_reinspection", "Whether this re-checks an earlier inspection", "bool", false), + }; + + private static readonly List ViolationVariables = new List + { + new TemplateVariableDescriptor("violation.id", "Violation ID", "string", false), + new TemplateVariableDescriptor("violation.inspection_id", "Inspection ID", "string", false), + new TemplateVariableDescriptor("violation.occupancy_id", "Occupancy ID", "string", false), + new TemplateVariableDescriptor("violation.occupancy_number", "Occupancy number", "string", false), + new TemplateVariableDescriptor("violation.occupancy_name", "Occupancy name", "string", false), + new TemplateVariableDescriptor("violation.code_set_id", "Adopted code set ID", "string", false), + new TemplateVariableDescriptor("violation.code_section_id", "Code section ID", "string", false), + new TemplateVariableDescriptor("violation.code_section_number", "Cited code section", "string", false), + new TemplateVariableDescriptor("violation.severity", "Minor, Moderate, Serious or Critical", "string", false), + new TemplateVariableDescriptor("violation.state", "Open or Escalated", "string", false), + new TemplateVariableDescriptor("violation.due_on", "Correction due date (UTC)", "datetime", false), + new TemplateVariableDescriptor("violation.days_overdue", "Whole days past the due date", "int", false), + }; + + private static readonly List PermitVariables = new List + { + new TemplateVariableDescriptor("permit.id", "Permit ID", "string", false), + new TemplateVariableDescriptor("permit.number", "Permit number (PRM-yyyy-nnnn)", "string", false), + new TemplateVariableDescriptor("permit.type_id", "Permit type ID", "string", false), + new TemplateVariableDescriptor("permit.type_name", "Permit type", "string", false), + new TemplateVariableDescriptor("permit.type_code", "Permit type code", "string", false), + new TemplateVariableDescriptor("permit.occupancy_id", "Occupancy ID", "string", false), + new TemplateVariableDescriptor("permit.occupancy_number", "Occupancy number", "string", false), + new TemplateVariableDescriptor("permit.occupancy_name", "Occupancy name", "string", false), + new TemplateVariableDescriptor("permit.state", "Always Issued", "string", false), + new TemplateVariableDescriptor("permit.issued_on", "Issue date (UTC)", "datetime", false), + new TemplateVariableDescriptor("permit.effective_on", "Effective date (UTC)", "datetime", false), + new TemplateVariableDescriptor("permit.expires_on", "Expiry date (UTC)", "datetime", false), + new TemplateVariableDescriptor("permit.days_until_expiry", "Whole days until expiry", "int", false), + new TemplateVariableDescriptor("permit.fee_paid", "Whether the fee was recorded as paid", "bool", false), + }; + // definition.* (RMS-1B): stable definition identity on every department-definition Record event and on the // definition lifecycle triggers 113/114. Never field values. private static readonly List DefinitionVariables = new List @@ -889,6 +942,30 @@ public static IReadOnlyList GetVariableCatalog(Workf list.AddRange(ProtectionVariables); break; + case WorkflowTriggerEventType.RecordInspectionCompleted: + list.AddRange(RecordEventVariables); + list.Add(new TemplateVariableDescriptor("record.kind", "Always Prevention", "string", false)); + list.Add(new TemplateVariableDescriptor("record.department_id", "Department ID", "int", false)); + list.AddRange(InspectionVariables); + list.AddRange(ProtectionVariables); + break; + + case WorkflowTriggerEventType.RecordViolationOverdue: + list.AddRange(RecordEventVariables); + list.Add(new TemplateVariableDescriptor("record.kind", "Always Prevention", "string", false)); + list.Add(new TemplateVariableDescriptor("record.department_id", "Department ID", "int", false)); + list.AddRange(ViolationVariables); + list.AddRange(ProtectionVariables); + break; + + case WorkflowTriggerEventType.RecordPermitExpiring: + list.AddRange(RecordEventVariables); + list.Add(new TemplateVariableDescriptor("record.kind", "Always Prevention", "string", false)); + list.Add(new TemplateVariableDescriptor("record.department_id", "Department ID", "int", false)); + list.AddRange(PermitVariables); + list.AddRange(ProtectionVariables); + break; + case WorkflowTriggerEventType.RecordSubmissionQueued: case WorkflowTriggerEventType.RecordSubmissionAccepted: case WorkflowTriggerEventType.RecordSubmissionRejected: diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 1ba6e9c88..46844af1e 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -153,7 +153,16 @@ public enum WorkflowTriggerEventType RecordPurged = 159, /// A department export template's schedule came due and its file was rendered for delivery. - RecordExportScheduled = 160 + RecordExportScheduled = 160, + + /// A prevention inspection was completed (RMS-5); carries the inspection identity, result and violation counts, never the notes. + RecordInspectionCompleted = 161, + + /// A code violation passed its correction due date without being corrected (RMS-5); raised once per violation by the daily sweep. + RecordViolationOverdue = 162, + + /// An issued permit is inside its expiry notice window (RMS-5); raised once per permit by the daily sweep. + RecordPermitExpiring = 163 } public static class WorkflowTriggerEventTypes @@ -162,7 +171,7 @@ public static class WorkflowTriggerEventTypes public const int RecordsBlockFirst = 100; public const int RecordsBlockLast = 115; - /// The second Records block, 152-163 (registry section 3.2, allocated 2026-09-05); 161-163 are reserved. + /// The second Records block, 152-163 (registry section 3.2, allocated 2026-09-05); 161-163 were taken by RMS-5 on 2026-09-07. public const int RecordsBlock2First = 152; public const int RecordsBlock2Last = 163; diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index 94700524c..38278bc7a 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -393,7 +393,109 @@ AdpColumnSpec Companion(string table, string column) => AdpTableBinding.Direct("RmsRecordValues", "RmsRecordValueId", pkIsNumeric: false, "DepartmentId", new[] { Packed("RmsRecordValues", "ProtectedEnvelope") - }) with { ProtectedMarkerColumn = "IsProtected", CarrierColumns = RmsRecordValuePack.CarrierColumns, RowFilterColumn = "ProtectionRequired" } + }) with { ProtectedMarkerColumn = "IsProtected", CarrierColumns = RmsRecordValuePack.CarrierColumns, RowFilterColumn = "ProtectionRequired" }, + + // Catalog v12: Contacts pre-plans (Contacts plan Phase A). All three tables carry their own + // DepartmentId, and each was created with the IsProtected marker (M0183/M0184). + AdpTableBinding.Direct("ContactPreplans", "ContactPreplanId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("ContactPreplans", "OccupancyNotes"), Text("ContactPreplans", "OccupancyHours"), + Text("ContactPreplans", "OccupantsNeedingAssistanceNotes"), + Text("ContactPreplans", "GasShutoffLocation"), Text("ContactPreplans", "ElectricShutoffLocation"), + Text("ContactPreplans", "WaterShutoffLocation"), Text("ContactPreplans", "UtilityNotes"), + Text("ContactPreplans", "KnoxBoxLocation"), Text("ContactPreplans", "GateCode"), + Text("ContactPreplans", "AlarmPanelLocation"), Text("ContactPreplans", "AlarmCompany"), + Text("ContactPreplans", "AlarmCompanyPhone"), Text("ContactPreplans", "AccessNotes"), + Text("ContactPreplans", "NearestHydrantLocation"), Text("ContactPreplans", "WaterSupplyNotes"), + Text("ContactPreplans", "EmergencyContactName"), Text("ContactPreplans", "EmergencyContactPhone"), + Text("ContactPreplans", "SecondaryContactName"), Text("ContactPreplans", "SecondaryContactPhone"), + Text("ContactPreplans", "GeneralHazardNotes"), Text("ContactPreplans", "TacticalSummary") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("ContactPreplanHazards", "ContactPreplanHazardId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("ContactPreplanHazards", "Title"), Text("ContactPreplanHazards", "Description"), + Text("ContactPreplanHazards", "LocationDescription"), Text("ContactPreplanHazards", "GpsCoordinates") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("ContactAttachments", "ContactAttachmentId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("ContactAttachments", "Name"), Text("ContactAttachments", "FileName"), Binary("ContactAttachments", "Data") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + // Catalog v13: RMS-5 prevention and investigations plus the RMS-4 quality review (M0185/M0186). Every + // table carries its own DepartmentId, a string GUID key and the IsProtected marker. + AdpTableBinding.Direct("RmsOccupancies", "RmsOccupancyId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsOccupancies", "OccupantsNeedingAssistanceNotes"), Text("RmsOccupancies", "UtilityNotes"), + Text("RmsOccupancies", "KnoxBoxLocation"), Text("RmsOccupancies", "GateCode"), + Text("RmsOccupancies", "AlarmPanelLocation"), Text("RmsOccupancies", "AlarmCompany"), + Text("RmsOccupancies", "AlarmCompanyPhone"), Text("RmsOccupancies", "AccessNotes"), + Text("RmsOccupancies", "WaterSupplyNotes"), Text("RmsOccupancies", "EmergencyContactName"), + Text("RmsOccupancies", "EmergencyContactPhone"), Text("RmsOccupancies", "GeneralHazardNotes"), + Text("RmsOccupancies", "TacticalSummary") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsOccupancyHazards", "RmsOccupancyHazardId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsOccupancyHazards", "Description"), Text("RmsOccupancyHazards", "LocationDescription"), Text("RmsOccupancyHazards", "GpsCoordinates") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsInspections", "RmsInspectionId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsInspections", "Notes"), Text("RmsInspections", "SignatureName") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsViolations", "RmsViolationId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsViolations", "Description"), Text("RmsViolations", "CorrectiveAction") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsPermits", "RmsPermitId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsPermits", "ApplicantName"), Text("RmsPermits", "ApplicantPhone"), Text("RmsPermits", "ApplicantEmail"), Text("RmsPermits", "ReviewNotes") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsPlanReviews", "RmsPlanReviewId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsPlanReviews", "Comments") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsInvestigationCases", "RmsInvestigationCaseId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsInvestigationCases", "IncidentSummary"), Text("RmsInvestigationCases", "CauseDetail"), + Text("RmsInvestigationCases", "OriginDescription"), Text("RmsInvestigationCases", "Findings"), Text("RmsInvestigationCases", "ClosureReason") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsInvestigationNotes", "RmsInvestigationNoteId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsInvestigationNotes", "Subject"), Text("RmsInvestigationNotes", "Body") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsInvestigationEvidence", "RmsInvestigationEvidenceId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsInvestigationEvidence", "Description"), Text("RmsInvestigationEvidence", "CollectedFrom"), Text("RmsInvestigationEvidence", "CurrentCustodianExternal") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsInvestigationCustody", "RmsInvestigationCustodyId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsInvestigationCustody", "FromExternal"), Text("RmsInvestigationCustody", "ToExternal"), Text("RmsInvestigationCustody", "Reason") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsInvestigationReferrals", "RmsInvestigationReferralId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsInvestigationReferrals", "Reason") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsQualityReviews", "RmsQualityReviewId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsQualityReviews", "FindingsJson"), Text("RmsQualityReviews", "Note") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsPreventionAttachments", "RmsPreventionAttachmentId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsPreventionAttachments", "FileName"), Text("RmsPreventionAttachments", "Description"), Binary("RmsPreventionAttachments", "Data") + }) with { ProtectedMarkerColumn = "IsProtected" } }; } } diff --git a/Core/Resgrid.Services/CallsService.cs b/Core/Resgrid.Services/CallsService.cs index fea3560c9..b86d3254c 100644 --- a/Core/Resgrid.Services/CallsService.cs +++ b/Core/Resgrid.Services/CallsService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -785,6 +785,16 @@ public async Task PopulateCallData(Call call, bool getDispatches, bool get return call; } + public async Task> GetCallContactsByCallIdAsync(int callId) + { + var callContacts = await _callContactsRepository.GetCallContactsByCallIdAsync(callId); + + if (callContacts == null) + return new List(); + + return callContacts.ToList(); + } + public async Task DeleteCallContactsAsync(int callId, CancellationToken cancellationToken = default(CancellationToken)) { var callContacts = await _callContactsRepository.GetCallContactsByCallIdAsync(callId); diff --git a/Core/Resgrid.Services/ContactsService.cs b/Core/Resgrid.Services/ContactsService.cs index a76a84b40..ff3375919 100644 --- a/Core/Resgrid.Services/ContactsService.cs +++ b/Core/Resgrid.Services/ContactsService.cs @@ -1,4 +1,4 @@ -using System; +using System; using Resgrid.Model; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -14,24 +14,42 @@ namespace Resgrid.Services { public class ContactsService : IContactsService { + /// Placeholder written into audit snapshots in place of a gate code. + public const string GateCodeAuditMask = "***"; + private readonly IContactsRepository _contactsRepository; private readonly IContactCategoryRepository _contactCategoryRepository; private readonly IContactNotesRepository _contactNotesRepository; private readonly IContactNoteTypesRepository _contactNoteTypesRepository; private readonly IContactAssociationsRepository _contactAssociationsRepository; + private readonly IContactPreplanRepository _contactPreplanRepository; + private readonly IContactPreplanHazardRepository _contactPreplanHazardRepository; + private readonly IContactAttachmentRepository _contactAttachmentRepository; + private readonly ICallsRepository _callsRepository; + private readonly ICallContactsRepository _callContactsRepository; private readonly IEventAggregator _eventAggregator; private readonly Lazy _protectedWriteService; + /// RMS-5: which system owns structure writes; lazy because Records depends on Contacts (RMS plan section 4.3). + private readonly Lazy _ownershipGate; public ContactsService(IContactsRepository contactsRepository, IContactNotesRepository contactNotesRepository, IContactCategoryRepository contactCategoryRepository, IContactNoteTypesRepository contactNoteTypesRepository, - IContactAssociationsRepository contactAssociationsRepository, IEventAggregator eventAggregator, - Lazy protectedWriteService) + IContactAssociationsRepository contactAssociationsRepository, IContactPreplanRepository contactPreplanRepository, + IContactPreplanHazardRepository contactPreplanHazardRepository, IContactAttachmentRepository contactAttachmentRepository, + ICallsRepository callsRepository, ICallContactsRepository callContactsRepository, + IEventAggregator eventAggregator, Lazy protectedWriteService, Lazy ownershipGate) { + _ownershipGate = ownershipGate; _contactsRepository = contactsRepository; _contactCategoryRepository = contactCategoryRepository; _contactNotesRepository = contactNotesRepository; _contactNoteTypesRepository = contactNoteTypesRepository; _contactAssociationsRepository = contactAssociationsRepository; + _contactPreplanRepository = contactPreplanRepository; + _contactPreplanHazardRepository = contactPreplanHazardRepository; + _contactAttachmentRepository = contactAttachmentRepository; + _callsRepository = callsRepository; + _callContactsRepository = callContactsRepository; _eventAggregator = eventAggregator; _protectedWriteService = protectedWriteService; } @@ -202,14 +220,7 @@ public async Task DoesContactNoteTypeAlreadyExistAsync(int departmentId, s public async Task DeleteContactAsync(string contactId, string userId, int departmentId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) { - var auditEvent = new AuditEvent(); - auditEvent.DepartmentId = departmentId; - auditEvent.UserId = userId; - auditEvent.Type = AuditLogTypes.ContactRemoved; - auditEvent.Successful = true; - auditEvent.IpAddress = ipAddress; - auditEvent.ServerName = Environment.MachineName; - auditEvent.UserAgent = userAgent; + var auditEvent = NewAuditEvent(departmentId, userId, AuditLogTypes.ContactRemoved, ipAddress, userAgent); var contact = await _contactsRepository.GetByIdAsync(contactId); auditEvent.Before = contact.CloneJsonToString(); @@ -225,5 +236,561 @@ public async Task DoesContactNoteTypeAlreadyExistAsync(int departmentId, s return true; } + + #region Pre-plans (Contacts plan Phase A) + + public async Task GetPreplanByContactIdAsync(string contactId, int departmentId) + { + if (string.IsNullOrWhiteSpace(contactId)) + return null; + + if (await _ownershipGate.Value.IsRecordsOwnedAsync(departmentId)) + { + var projections = await _ownershipGate.Value.GetPreplanProjectionsAsync(departmentId, new[] { contactId }); + return projections.TryGetValue(contactId, out var projected) ? projected : null; + } + + var preplan = await _contactPreplanRepository.GetPreplanByContactIdAsync(contactId, departmentId); + + if (preplan == null) + return null; + + var hazards = await _contactPreplanHazardRepository.GetHazardsByPreplanIdAsync(preplan.ContactPreplanId, departmentId); + preplan.Hazards = hazards?.Where(x => !x.IsDeleted).ToList() ?? new List(); + + return preplan; + } + + public async Task SavePreplanAsync(ContactPreplan preplan, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) + { + if (preplan == null) + throw new ArgumentNullException(nameof(preplan)); + if (await _ownershipGate.Value.IsRecordsOwnedAsync(preplan.DepartmentId)) + throw new InvalidOperationException(IContactPreplanOwnershipGate.RecordsOwnedReason); + + var contact = await RequireContactAsync(preplan.ContactId, preplan.DepartmentId); + var existing = await _contactPreplanRepository.GetPreplanByContactIdAsync(contact.ContactId, preplan.DepartmentId); + var now = DateTime.UtcNow; + + var auditEvent = NewAuditEvent(preplan.DepartmentId, userId, + existing == null ? AuditLogTypes.ContactPreplanAdded : AuditLogTypes.ContactPreplanUpdated, ipAddress, userAgent); + + if (existing != null) + { + auditEvent.Before = AuditSnapshot(existing); + + // One live row per contact: an incoming save always lands on the existing row. + preplan.ContactPreplanId = existing.ContactPreplanId; + preplan.AddedOn = existing.AddedOn; + preplan.AddedByUserId = existing.AddedByUserId; + preplan.IsDeleted = false; + preplan.EditedOn = now; + preplan.EditedByUserId = userId; + } + else + { + preplan.ContactPreplanId = null; + preplan.AddedOn = now; + preplan.AddedByUserId = userId; + preplan.IsDeleted = false; + preplan.EditedOn = null; + preplan.EditedByUserId = null; + } + + var saved = await _contactPreplanRepository.SaveOrUpdateAsync(preplan, cancellationToken); + + // ADP write safety net (catalog v12): post-save so a repository-assigned id is a valid AAD row key. + // The stored row restores any REDACTED sentinel an editor round-tripped; enveloped values pass + // through untouched. Fails closed by throwing. + var protectedWrite = await _protectedWriteService.Value.PrepareContactPreplanWriteAsync(saved.DepartmentId, + saved, existing, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); contact pre-plan {saved.ContactPreplanId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _contactPreplanRepository.SaveOrUpdateAsync(saved, cancellationToken); + + auditEvent.After = AuditSnapshot(saved); + _eventAggregator.SendMessage(auditEvent); + + var hazards = await _contactPreplanHazardRepository.GetHazardsByPreplanIdAsync(saved.ContactPreplanId, saved.DepartmentId); + saved.Hazards = hazards?.Where(x => !x.IsDeleted).ToList() ?? new List(); + + return saved; + } + + public async Task DeletePreplanAsync(string contactId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) + { + if (await _ownershipGate.Value.IsRecordsOwnedAsync(departmentId)) + throw new InvalidOperationException(IContactPreplanOwnershipGate.RecordsOwnedReason); + var preplan = await _contactPreplanRepository.GetPreplanByContactIdAsync(contactId, departmentId); + + if (preplan == null) + return false; + + var auditEvent = NewAuditEvent(departmentId, userId, AuditLogTypes.ContactPreplanRemoved, ipAddress, userAgent); + auditEvent.Before = AuditSnapshot(preplan); + + var now = DateTime.UtcNow; + var hazards = await _contactPreplanHazardRepository.GetHazardsByPreplanIdAsync(preplan.ContactPreplanId, departmentId); + if (hazards != null) + { + foreach (var hazard in hazards.Where(x => !x.IsDeleted)) + { + hazard.IsDeleted = true; + hazard.EditedOn = now; + hazard.EditedByUserId = userId; + await _contactPreplanHazardRepository.SaveOrUpdateAsync(hazard, cancellationToken); + } + } + + preplan.IsDeleted = true; + preplan.EditedOn = now; + preplan.EditedByUserId = userId; + await _contactPreplanRepository.SaveOrUpdateAsync(preplan, cancellationToken); + + auditEvent.After = AuditSnapshot(preplan); + _eventAggregator.SendMessage(auditEvent); + + return true; + } + + public async Task> GetPreplansDueForReviewAsync(int departmentId) + { + var preplans = await _contactPreplanRepository.GetPreplansByDepartmentIdAsync(departmentId); + + if (preplans == null) + return new List(); + + var now = DateTime.UtcNow; + var due = preplans.Where(x => !x.IsDeleted && x.IsReviewOverdue(now)).ToList(); + + // A review listing never needs the gate code (plaintext or envelope); do not carry it around. + foreach (var preplan in due) + preplan.GateCode = null; + + return due; + } + + public async Task> GetHazardsByContactIdAsync(string contactId, int departmentId) + { + if (string.IsNullOrWhiteSpace(contactId)) + return new List(); + + if (await _ownershipGate.Value.IsRecordsOwnedAsync(departmentId)) + { + var projections = await _ownershipGate.Value.GetPreplanProjectionsAsync(departmentId, new[] { contactId }); + return projections.TryGetValue(contactId, out var projected) ? projected.Hazards ?? new List() : new List(); + } + + var hazards = await _contactPreplanHazardRepository.GetHazardsByContactIdAsync(contactId, departmentId); + + if (hazards == null) + return new List(); + + return hazards.Where(x => !x.IsDeleted).ToList(); + } + + public async Task GetHazardByIdAsync(string contactPreplanHazardId) + { + if (string.IsNullOrWhiteSpace(contactPreplanHazardId)) + return null; + + return await _contactPreplanHazardRepository.GetByIdAsync(contactPreplanHazardId); + } + + public async Task SaveHazardAsync(ContactPreplanHazard hazard, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) + { + if (hazard == null) + throw new ArgumentNullException(nameof(hazard)); + if (await _ownershipGate.Value.IsRecordsOwnedAsync(hazard.DepartmentId)) + throw new InvalidOperationException(IContactPreplanOwnershipGate.RecordsOwnedReason); + if (string.IsNullOrWhiteSpace(hazard.Title)) + throw new ArgumentException("A hazard needs a title.", nameof(hazard)); + + var contact = await RequireContactAsync(hazard.ContactId, hazard.DepartmentId); + var now = DateTime.UtcNow; + + // Hazards hang off the pre-plan row; create the shell when the contact has none yet. + var preplan = await _contactPreplanRepository.GetPreplanByContactIdAsync(contact.ContactId, hazard.DepartmentId); + if (preplan == null) + { + preplan = await SavePreplanAsync(new ContactPreplan + { + ContactId = contact.ContactId, + DepartmentId = hazard.DepartmentId + }, userId, ipAddress, userAgent, cancellationToken); + } + + var auditEvent = NewAuditEvent(hazard.DepartmentId, userId, AuditLogTypes.ContactPreplanUpdated, ipAddress, userAgent); + + ContactPreplanHazard existing = null; + if (!string.IsNullOrWhiteSpace(hazard.ContactPreplanHazardId)) + existing = await _contactPreplanHazardRepository.GetByIdAsync(hazard.ContactPreplanHazardId); + + if (existing != null) + { + if (existing.DepartmentId != hazard.DepartmentId || existing.ContactId != hazard.ContactId) + throw new InvalidOperationException("The hazard does not belong to this contact."); + + auditEvent.Before = existing.CloneJsonToString(); + hazard.AddedOn = existing.AddedOn; + hazard.AddedByUserId = existing.AddedByUserId; + hazard.EditedOn = now; + hazard.EditedByUserId = userId; + } + else + { + hazard.ContactPreplanHazardId = null; + hazard.AddedOn = now; + hazard.AddedByUserId = userId; + hazard.EditedOn = null; + hazard.EditedByUserId = null; + } + + hazard.ContactPreplanId = preplan.ContactPreplanId; + hazard.IsDeleted = false; + + var saved = await _contactPreplanHazardRepository.SaveOrUpdateAsync(hazard, cancellationToken); + + // ADP write safety net (catalog v12); see SavePreplanAsync. + var protectedWrite = await _protectedWriteService.Value.PrepareContactPreplanHazardWriteAsync(saved.DepartmentId, + saved, existing, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); premise hazard {saved.ContactPreplanHazardId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _contactPreplanHazardRepository.SaveOrUpdateAsync(saved, cancellationToken); + + auditEvent.After = saved.CloneJsonToString(); + _eventAggregator.SendMessage(auditEvent); + + return saved; + } + + public async Task DeleteHazardAsync(string contactPreplanHazardId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) + { + if (await _ownershipGate.Value.IsRecordsOwnedAsync(departmentId)) + throw new InvalidOperationException(IContactPreplanOwnershipGate.RecordsOwnedReason); + var hazard = await _contactPreplanHazardRepository.GetByIdAsync(contactPreplanHazardId); + + if (hazard == null || hazard.DepartmentId != departmentId || hazard.IsDeleted) + return false; + + var auditEvent = NewAuditEvent(departmentId, userId, AuditLogTypes.ContactPreplanUpdated, ipAddress, userAgent); + auditEvent.Before = hazard.CloneJsonToString(); + + hazard.IsDeleted = true; + hazard.EditedOn = DateTime.UtcNow; + hazard.EditedByUserId = userId; + await _contactPreplanHazardRepository.SaveOrUpdateAsync(hazard, cancellationToken); + + auditEvent.After = hazard.CloneJsonToString(); + _eventAggregator.SendMessage(auditEvent); + + return true; + } + + #endregion + + #region Site attachments + + public async Task> GetContactAttachmentsAsync(string contactId, int departmentId, ContactAttachmentTypes? type = null) + { + if (string.IsNullOrWhiteSpace(contactId)) + return new List(); + + var attachments = await _contactAttachmentRepository.GetAttachmentMetaByContactIdAsync(contactId, departmentId); + + if (attachments == null) + return new List(); + + var live = attachments.Where(x => !x.IsDeleted); + if (type.HasValue) + live = live.Where(x => x.ContactAttachmentType == (int)type.Value); + + return live.ToList(); + } + + public async Task GetContactAttachmentByIdAsync(int contactAttachmentId) + { + if (contactAttachmentId <= 0) + return null; + + var attachment = await _contactAttachmentRepository.GetAttachmentByIdAsync(contactAttachmentId); + + if (attachment == null || attachment.IsDeleted) + return null; + + return attachment; + } + + public async Task SaveContactAttachmentAsync(ContactAttachment attachment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) + { + if (attachment == null) + throw new ArgumentNullException(nameof(attachment)); + if (attachment.Data == null || attachment.Data.Length == 0) + throw new ArgumentException("An attachment needs file data.", nameof(attachment)); + + await RequireContactAsync(attachment.ContactId, attachment.DepartmentId); + + attachment.ContactAttachmentId = 0; + attachment.Size = attachment.Data.Length; + attachment.IsDeleted = false; + attachment.AddedOn = DateTime.UtcNow; + attachment.AddedByUserId = userId; + + if (string.IsNullOrWhiteSpace(attachment.Name)) + attachment.Name = attachment.FileName; + + var saved = await _contactAttachmentRepository.SaveOrUpdateAsync(attachment, cancellationToken); + + // ADP write safety net (catalog v12): the identity id is the AAD row key, so encryption runs after the + // insert assigned it, then the enveloped row (name, file name, rgdpb bytes) is persisted. + var protectedWrite = await _protectedWriteService.Value.PrepareContactAttachmentWriteAsync(saved.DepartmentId, + saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); contact attachment {saved.ContactAttachmentId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _contactAttachmentRepository.SaveOrUpdateAsync(saved, cancellationToken); + + var auditEvent = NewAuditEvent(attachment.DepartmentId, userId, AuditLogTypes.ContactAttachmentAdded, ipAddress, userAgent); + auditEvent.After = AuditSnapshot(saved); + _eventAggregator.SendMessage(auditEvent); + + return saved; + } + + public async Task DeleteContactAttachmentAsync(int contactAttachmentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) + { + var attachment = await _contactAttachmentRepository.GetAttachmentByIdAsync(contactAttachmentId); + + if (attachment == null || attachment.DepartmentId != departmentId || attachment.IsDeleted) + return false; + + var auditEvent = NewAuditEvent(departmentId, userId, AuditLogTypes.ContactAttachmentRemoved, ipAddress, userAgent); + auditEvent.Before = AuditSnapshot(attachment); + + attachment.IsDeleted = true; + await _contactAttachmentRepository.SaveOrUpdateAsync(attachment, cancellationToken); + + auditEvent.After = AuditSnapshot(attachment); + _eventAggregator.SendMessage(auditEvent); + + return true; + } + + #endregion + + #region Call surfacing + + public async Task>> GetAlertNotesByContactIdsAsync(int departmentId, IEnumerable contactIds) + { + var result = new Dictionary>(); + var now = DateTime.UtcNow; + + foreach (var contactId in (contactIds ?? Enumerable.Empty()).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct()) + { + var notes = await _contactNotesRepository.GetContactNotesByContactIdAsync(contactId); + result[contactId] = (notes ?? Enumerable.Empty()) + .Where(x => IsLiveAlertNote(x, departmentId, now)) + .ToList(); + } + + return result; + } + + /// ShouldAlert, not deleted, in the department, and not expired (a null ExpiresOn never expires). + public static bool IsLiveAlertNote(ContactNote note, int departmentId, DateTime utcNow) + { + if (note == null || note.IsDeleted || !note.ShouldAlert || note.DepartmentId != departmentId) + return false; + + return !note.ExpiresOn.HasValue || note.ExpiresOn.Value > utcNow; + } + + public async Task GetCallSiteInfoAsync(int callId, int departmentId) + { + var call = await _callsRepository.GetByIdAsync(callId); + + if (call == null || call.DepartmentId != departmentId) + return null; + + var info = new CallSiteInfo { CallId = callId, DepartmentId = departmentId }; + + var callContacts = (await _callContactsRepository.GetCallContactsByCallIdAsync(callId))?.ToList() ?? new List(); + if (!callContacts.Any()) + return info; + + var contactIds = callContacts.Where(x => !string.IsNullOrWhiteSpace(x.ContactId)).Select(x => x.ContactId).Distinct().ToList(); + var contacts = await LoadContactsAsync(departmentId, contactIds); + List preplans; + List hazards; + if (await _ownershipGate.Value.IsRecordsOwnedAsync(departmentId)) + { + // RMS owns the structure master (plan section 4.3): project from the occupancy in the Phase A shape. + var projected = await _ownershipGate.Value.GetPreplanProjectionsAsync(departmentId, contactIds); + preplans = projected.Values.ToList(); + hazards = preplans.SelectMany(x => x.Hazards ?? new List()).ToList(); + } + else + { + preplans = (await _contactPreplanRepository.GetPreplansByContactIdsAsync(departmentId, contactIds))?.Where(x => !x.IsDeleted).ToList() ?? new List(); + hazards = (await _contactPreplanHazardRepository.GetHazardsByContactIdsAsync(departmentId, contactIds))?.Where(x => !x.IsDeleted).ToList() ?? new List(); + } + var attachments = (await _contactAttachmentRepository.GetAttachmentMetaByContactIdsAsync(departmentId, contactIds))?.Where(x => !x.IsDeleted).ToList() ?? new List(); + var alertNotes = await GetAlertNotesByContactIdsAsync(departmentId, contactIds); + + // Primary contact first, then additional in link order; one entry per distinct contact. + foreach (var link in callContacts.OrderBy(x => x.CallContactType)) + { + if (!contacts.TryGetValue(link.ContactId ?? string.Empty, out var contact)) + continue; + if (info.Contacts.Any(x => x.Contact.ContactId == contact.ContactId)) + continue; + + var preplan = preplans.FirstOrDefault(x => x.ContactId == contact.ContactId); + if (preplan != null) + preplan.Hazards = hazards.Where(x => x.ContactPreplanId == preplan.ContactPreplanId).ToList(); + + info.Contacts.Add(new CallSiteContactInfo + { + Contact = contact, + CallContactType = link.CallContactType, + Preplan = preplan, + Hazards = hazards.Where(x => x.ContactId == contact.ContactId).ToList(), + AlertNotes = alertNotes.TryGetValue(contact.ContactId, out var notes) ? notes : new List(), + Attachments = attachments.Where(x => x.ContactId == contact.ContactId).ToList() + }); + } + + return info; + } + + public async Task>> GetCallContactSummariesAsync(int departmentId, IEnumerable calls) + { + var result = new Dictionary>(); + var callList = (calls ?? Enumerable.Empty()).Where(x => x != null).ToList(); + + var contactIds = callList + .Where(x => x.Contacts != null) + .SelectMany(x => x.Contacts) + .Where(x => !string.IsNullOrWhiteSpace(x.ContactId)) + .Select(x => x.ContactId) + .Distinct() + .ToList(); + + if (!contactIds.Any()) + return result; + + var contacts = await LoadContactsAsync(departmentId, contactIds); + HashSet preplanContactIds; + Dictionary hazardCounts; + if (await _ownershipGate.Value.IsRecordsOwnedAsync(departmentId)) + { + var projected = await _ownershipGate.Value.GetPreplanProjectionsAsync(departmentId, contactIds); + preplanContactIds = new HashSet(projected.Keys); + hazardCounts = projected.ToDictionary(x => x.Key, x => x.Value.Hazards?.Count ?? 0); + } + else + { + preplanContactIds = new HashSet(((await _contactPreplanRepository.GetPreplansByContactIdsAsync(departmentId, contactIds)) ?? Enumerable.Empty()) + .Where(x => !x.IsDeleted).Select(x => x.ContactId)); + hazardCounts = ((await _contactPreplanHazardRepository.GetHazardsByContactIdsAsync(departmentId, contactIds)) ?? Enumerable.Empty()) + .Where(x => !x.IsDeleted).GroupBy(x => x.ContactId).ToDictionary(g => g.Key, g => g.Count()); + } + var alertNotes = await GetAlertNotesByContactIdsAsync(departmentId, contactIds); + + foreach (var call in callList) + { + if (call.Contacts == null) + continue; + + var summaries = new List(); + foreach (var link in call.Contacts.OrderBy(x => x.CallContactType)) + { + if (!contacts.TryGetValue(link.ContactId ?? string.Empty, out var contact)) + continue; + if (summaries.Any(x => x.Contact.ContactId == contact.ContactId)) + continue; + + summaries.Add(new CallContactSummary + { + Contact = contact, + CallContactType = link.CallContactType, + HasPreplan = preplanContactIds.Contains(contact.ContactId), + HazardCount = hazardCounts.TryGetValue(contact.ContactId, out var count) ? count : 0, + AlertNoteCount = alertNotes.TryGetValue(contact.ContactId, out var notes) ? notes.Count : 0 + }); + } + + result[call.CallId] = summaries; + } + + return result; + } + + #endregion + + #region Helpers + + private async Task RequireContactAsync(string contactId, int departmentId) + { + if (string.IsNullOrWhiteSpace(contactId)) + throw new ArgumentException("A contact id is required.", nameof(contactId)); + + var contact = await _contactsRepository.GetByIdAsync(contactId); + + if (contact == null || contact.IsDeleted || contact.DepartmentId != departmentId) + throw new InvalidOperationException("The contact does not exist in this department."); + + return contact; + } + + private async Task> LoadContactsAsync(int departmentId, IEnumerable contactIds) + { + var contacts = new Dictionary(); + + foreach (var contactId in contactIds.Distinct()) + { + var contact = await _contactsRepository.GetByIdAsync(contactId); + if (contact != null && !contact.IsDeleted && contact.DepartmentId == departmentId) + contacts[contactId] = contact; + } + + return contacts; + } + + /// Pre-plan audit snapshot with the gate code masked (never the plaintext or the ciphertext). + private static string AuditSnapshot(ContactPreplan preplan) + { + var clone = preplan.CloneJson(); + if (!string.IsNullOrEmpty(clone.GateCode)) + clone.GateCode = GateCodeAuditMask; + clone.Hazards = null; + return clone.CloneJsonToString(); + } + + /// Attachment audit snapshot: metadata only, never the blob. + private static string AuditSnapshot(ContactAttachment attachment) + { + var clone = attachment.CloneJson(); + clone.Data = null; + return clone.CloneJsonToString(); + } + + private static AuditEvent NewAuditEvent(int departmentId, string userId, AuditLogTypes type, string ipAddress, string userAgent) + { + return new AuditEvent + { + DepartmentId = departmentId, + UserId = userId, + Type = type, + Successful = true, + IpAddress = ipAddress, + UserAgent = userAgent, + ServerName = Environment.MachineName + }; + } + + #endregion } } diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 5a6f02567..a31171f31 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -65,6 +65,23 @@ public class ProtectedFieldCatalog : IProtectedFieldCatalog /// public const int RecordsTypedValuesCatalogVersion = 11; + /// + /// Catalog version the Contacts pre-plan family was added in (Contacts plan Phase A, A8 / risk 1): the + /// pre-incident plan's access secrets, utility/water/occupancy notes, on-site contact names and numbers, + /// tactical text, the premise hazard text and coordinates, and the site attachment name/file/bytes. + /// Structural columns (enum values, occupant load, fire flow, flags, review dates) stay plaintext so the + /// review-currency badge and NERIS crosswalk still work without a grant. + /// + public const int ContactPreplansCatalogVersion = 12; + + /// + /// Catalog version the RMS-5 prevention and investigation tables (and the RMS-4 quality review) were added in: + /// the occupancy master's access secrets and hazard text, inspection notes, violation text, permit applicant + /// identity and review notes, the whole investigation case narrative (Tier 1), QA findings, and the + /// prevention attachment name/description/bytes. Entries mirror RmsProtectedFields (the seam). + /// + public const int PreventionCatalogVersion = 13; + private static readonly IReadOnlyList Entries = BuildV1(); private static readonly Dictionary ById = Entries.ToDictionary(e => e.FieldId, StringComparer.OrdinalIgnoreCase); @@ -548,6 +565,124 @@ void Records(string table, string column, ProtectedFieldClassification classific ProtectedFieldStorageKind.PackedJson, ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, RecordsTypedValuesCatalogVersion)); + // ---- Contacts pre-plans (Contacts plan Phase A), catalog v12 ----------------------- + // A pre-incident plan is the premises' access and hazard knowledge: gate codes, Knox box and + // alarm panel locations, utility shutoffs, on-site emergency contacts, occupants who need + // help, and the crew's tactical notes. In a protected department that is at least as + // sensitive as the contact's phone number, so the whole text surface is cataloged under the + // Contacts family and the same view permission. Hazard text and coordinates ride with it; + // a site attachment (floor plan, photo, drawing) is cataloged like a call attachment: name, + // file name and bytes together, because the file is the point. + void Preplan(string column, ProtectedFieldClassification classification) => + list.Add(new ProtectedFieldDefinition($"contactpreplans.{column.ToLowerInvariant()}", ContactsFamily, "ContactPreplans", + column, ProtectedFieldStorageKind.Text, classification, PermissionTypes.ViewProtectedContactData, + PermissionTypes.ViewProtectedContactData, ContactPreplansCatalogVersion)); + + Preplan("OccupancyNotes", ProtectedFieldClassification.Sensitive); + Preplan("OccupancyHours", ProtectedFieldClassification.Sensitive); + Preplan("OccupantsNeedingAssistanceNotes", ProtectedFieldClassification.Phi); + Preplan("GasShutoffLocation", ProtectedFieldClassification.Sensitive); + Preplan("ElectricShutoffLocation", ProtectedFieldClassification.Sensitive); + Preplan("WaterShutoffLocation", ProtectedFieldClassification.Sensitive); + Preplan("UtilityNotes", ProtectedFieldClassification.Sensitive); + Preplan("KnoxBoxLocation", ProtectedFieldClassification.Sensitive); + Preplan("GateCode", ProtectedFieldClassification.Sensitive); + Preplan("AlarmPanelLocation", ProtectedFieldClassification.Sensitive); + Preplan("AlarmCompany", ProtectedFieldClassification.Sensitive); + Preplan("AlarmCompanyPhone", ProtectedFieldClassification.Pii); + Preplan("AccessNotes", ProtectedFieldClassification.Sensitive); + Preplan("NearestHydrantLocation", ProtectedFieldClassification.Sensitive); + Preplan("WaterSupplyNotes", ProtectedFieldClassification.Sensitive); + Preplan("EmergencyContactName", ProtectedFieldClassification.Pii); + Preplan("EmergencyContactPhone", ProtectedFieldClassification.Pii); + Preplan("SecondaryContactName", ProtectedFieldClassification.Pii); + Preplan("SecondaryContactPhone", ProtectedFieldClassification.Pii); + Preplan("GeneralHazardNotes", ProtectedFieldClassification.Sensitive); + Preplan("TacticalSummary", ProtectedFieldClassification.Sensitive); + + void Hazard(string column, ProtectedFieldClassification classification) => + list.Add(new ProtectedFieldDefinition($"contactpreplanhazards.{column.ToLowerInvariant()}", ContactsFamily, "ContactPreplanHazards", + column, ProtectedFieldStorageKind.Text, classification, PermissionTypes.ViewProtectedContactData, + PermissionTypes.ViewProtectedContactData, ContactPreplansCatalogVersion)); + + Hazard("Title", ProtectedFieldClassification.Sensitive); + Hazard("Description", ProtectedFieldClassification.Sensitive); + Hazard("LocationDescription", ProtectedFieldClassification.Sensitive); + Hazard("GpsCoordinates", ProtectedFieldClassification.Pii); + + void SiteFile(string column, ProtectedFieldClassification classification, ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) => + list.Add(new ProtectedFieldDefinition($"contactattachments.{column.ToLowerInvariant()}", ContactsFamily, "ContactAttachments", + column, kind, classification, PermissionTypes.ViewProtectedContactData, + PermissionTypes.ViewProtectedContactData, ContactPreplansCatalogVersion)); + + SiteFile("Name", ProtectedFieldClassification.Sensitive); + SiteFile("FileName", ProtectedFieldClassification.Sensitive); + SiteFile("Data", ProtectedFieldClassification.Sensitive, ProtectedFieldStorageKind.Binary); + + // ---- RMS-5 prevention and investigations (+ RMS-4 quality review), catalog v13 ---------- + // The occupancy master absorbs the Contacts pre-plan text (same columns, same classifications), the + // inspection/permit rows carry reviewer notes and applicant identity, and the investigation case is a + // Tier 1 restricted class end to end (plan section 5.9.2). Attachment name/description/bytes follow the + // record-attachment rule. RecordRestricted_View plus case membership still decides who SEES a revealed + // investigation value; encryption at rest is the separate protection this catalog owns. + void Prevention(string table, string column, ProtectedFieldClassification classification, + ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) => + list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", + RmsProtectedFields.Family, table, column, kind, classification, + PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, + PreventionCatalogVersion)); + + Prevention("RmsOccupancies", "OccupantsNeedingAssistanceNotes", ProtectedFieldClassification.Phi); + Prevention("RmsOccupancies", "UtilityNotes", ProtectedFieldClassification.Sensitive); + Prevention("RmsOccupancies", "KnoxBoxLocation", ProtectedFieldClassification.Sensitive); + Prevention("RmsOccupancies", "GateCode", ProtectedFieldClassification.Sensitive); + Prevention("RmsOccupancies", "AlarmPanelLocation", ProtectedFieldClassification.Sensitive); + Prevention("RmsOccupancies", "AlarmCompany", ProtectedFieldClassification.Sensitive); + Prevention("RmsOccupancies", "AlarmCompanyPhone", ProtectedFieldClassification.Pii); + Prevention("RmsOccupancies", "AccessNotes", ProtectedFieldClassification.Sensitive); + Prevention("RmsOccupancies", "WaterSupplyNotes", ProtectedFieldClassification.Sensitive); + Prevention("RmsOccupancies", "EmergencyContactName", ProtectedFieldClassification.Pii); + Prevention("RmsOccupancies", "EmergencyContactPhone", ProtectedFieldClassification.Pii); + Prevention("RmsOccupancies", "GeneralHazardNotes", ProtectedFieldClassification.Sensitive); + Prevention("RmsOccupancies", "TacticalSummary", ProtectedFieldClassification.Sensitive); + + Prevention("RmsOccupancyHazards", "Description", ProtectedFieldClassification.Sensitive); + Prevention("RmsOccupancyHazards", "LocationDescription", ProtectedFieldClassification.Sensitive); + Prevention("RmsOccupancyHazards", "GpsCoordinates", ProtectedFieldClassification.Pii); + + Prevention("RmsInspections", "Notes", ProtectedFieldClassification.Sensitive); + Prevention("RmsInspections", "SignatureName", ProtectedFieldClassification.Pii); + Prevention("RmsViolations", "Description", ProtectedFieldClassification.Sensitive); + Prevention("RmsViolations", "CorrectiveAction", ProtectedFieldClassification.Sensitive); + + Prevention("RmsPermits", "ApplicantName", ProtectedFieldClassification.Pii); + Prevention("RmsPermits", "ApplicantPhone", ProtectedFieldClassification.Pii); + Prevention("RmsPermits", "ApplicantEmail", ProtectedFieldClassification.Pii); + Prevention("RmsPermits", "ReviewNotes", ProtectedFieldClassification.Sensitive); + Prevention("RmsPlanReviews", "Comments", ProtectedFieldClassification.Sensitive); + + Prevention("RmsInvestigationCases", "IncidentSummary", ProtectedFieldClassification.Sensitive); + Prevention("RmsInvestigationCases", "CauseDetail", ProtectedFieldClassification.Sensitive); + Prevention("RmsInvestigationCases", "OriginDescription", ProtectedFieldClassification.Sensitive); + Prevention("RmsInvestigationCases", "Findings", ProtectedFieldClassification.Sensitive); + Prevention("RmsInvestigationCases", "ClosureReason", ProtectedFieldClassification.Sensitive); + Prevention("RmsInvestigationNotes", "Subject", ProtectedFieldClassification.Pii); + Prevention("RmsInvestigationNotes", "Body", ProtectedFieldClassification.Phi); + Prevention("RmsInvestigationEvidence", "Description", ProtectedFieldClassification.Sensitive); + Prevention("RmsInvestigationEvidence", "CollectedFrom", ProtectedFieldClassification.Sensitive); + Prevention("RmsInvestigationEvidence", "CurrentCustodianExternal", ProtectedFieldClassification.Pii); + Prevention("RmsInvestigationCustody", "FromExternal", ProtectedFieldClassification.Pii); + Prevention("RmsInvestigationCustody", "ToExternal", ProtectedFieldClassification.Pii); + Prevention("RmsInvestigationCustody", "Reason", ProtectedFieldClassification.Sensitive); + Prevention("RmsInvestigationReferrals", "Reason", ProtectedFieldClassification.Sensitive); + + Prevention("RmsQualityReviews", "FindingsJson", ProtectedFieldClassification.Sensitive); + Prevention("RmsQualityReviews", "Note", ProtectedFieldClassification.Sensitive); + + Prevention("RmsPreventionAttachments", "FileName", ProtectedFieldClassification.Sensitive); + Prevention("RmsPreventionAttachments", "Description", ProtectedFieldClassification.Sensitive); + Prevention("RmsPreventionAttachments", "Data", ProtectedFieldClassification.Phi, ProtectedFieldStorageKind.Binary); + return list; } } diff --git a/Core/Resgrid.Services/ProtectedReadService.cs b/Core/Resgrid.Services/ProtectedReadService.cs index 21f6281a0..63678ade2 100644 --- a/Core/Resgrid.Services/ProtectedReadService.cs +++ b/Core/Resgrid.Services/ProtectedReadService.cs @@ -152,6 +152,54 @@ public class ProtectedReadService : IProtectedReadService, IProtectedWriteServic ["contactnotes.note"] = (n => n.Note, (n, v) => n.Note = v) }; + /// ContactPreplans text columns (catalog v12, Contacts plan Phase A; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> ContactPreplanFieldAccessors = + new Dictionary, Action)> + { + ["contactpreplans.occupancynotes"] = (p => p.OccupancyNotes, (p, v) => p.OccupancyNotes = v), + ["contactpreplans.occupancyhours"] = (p => p.OccupancyHours, (p, v) => p.OccupancyHours = v), + ["contactpreplans.occupantsneedingassistancenotes"] = (p => p.OccupantsNeedingAssistanceNotes, (p, v) => p.OccupantsNeedingAssistanceNotes = v), + ["contactpreplans.gasshutofflocation"] = (p => p.GasShutoffLocation, (p, v) => p.GasShutoffLocation = v), + ["contactpreplans.electricshutofflocation"] = (p => p.ElectricShutoffLocation, (p, v) => p.ElectricShutoffLocation = v), + ["contactpreplans.watershutofflocation"] = (p => p.WaterShutoffLocation, (p, v) => p.WaterShutoffLocation = v), + ["contactpreplans.utilitynotes"] = (p => p.UtilityNotes, (p, v) => p.UtilityNotes = v), + ["contactpreplans.knoxboxlocation"] = (p => p.KnoxBoxLocation, (p, v) => p.KnoxBoxLocation = v), + ["contactpreplans.gatecode"] = (p => p.GateCode, (p, v) => p.GateCode = v), + ["contactpreplans.alarmpanellocation"] = (p => p.AlarmPanelLocation, (p, v) => p.AlarmPanelLocation = v), + ["contactpreplans.alarmcompany"] = (p => p.AlarmCompany, (p, v) => p.AlarmCompany = v), + ["contactpreplans.alarmcompanyphone"] = (p => p.AlarmCompanyPhone, (p, v) => p.AlarmCompanyPhone = v), + ["contactpreplans.accessnotes"] = (p => p.AccessNotes, (p, v) => p.AccessNotes = v), + ["contactpreplans.nearesthydrantlocation"] = (p => p.NearestHydrantLocation, (p, v) => p.NearestHydrantLocation = v), + ["contactpreplans.watersupplynotes"] = (p => p.WaterSupplyNotes, (p, v) => p.WaterSupplyNotes = v), + ["contactpreplans.emergencycontactname"] = (p => p.EmergencyContactName, (p, v) => p.EmergencyContactName = v), + ["contactpreplans.emergencycontactphone"] = (p => p.EmergencyContactPhone, (p, v) => p.EmergencyContactPhone = v), + ["contactpreplans.secondarycontactname"] = (p => p.SecondaryContactName, (p, v) => p.SecondaryContactName = v), + ["contactpreplans.secondarycontactphone"] = (p => p.SecondaryContactPhone, (p, v) => p.SecondaryContactPhone = v), + ["contactpreplans.generalhazardnotes"] = (p => p.GeneralHazardNotes, (p, v) => p.GeneralHazardNotes = v), + ["contactpreplans.tacticalsummary"] = (p => p.TacticalSummary, (p, v) => p.TacticalSummary = v) + }; + + /// ContactPreplanHazards text columns (catalog v12; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> ContactPreplanHazardFieldAccessors = + new Dictionary, Action)> + { + ["contactpreplanhazards.title"] = (h => h.Title, (h, v) => h.Title = v), + ["contactpreplanhazards.description"] = (h => h.Description, (h, v) => h.Description = v), + ["contactpreplanhazards.locationdescription"] = (h => h.LocationDescription, (h, v) => h.LocationDescription = v), + ["contactpreplanhazards.gpscoordinates"] = (h => h.GpsCoordinates, (h, v) => h.GpsCoordinates = v) + }; + + /// ContactAttachments text columns (catalog v12; parity-pinned with ). + public static readonly IReadOnlyDictionary Get, Action Set)> ContactAttachmentFieldAccessors = + new Dictionary, Action)> + { + ["contactattachments.name"] = (a => a.Name, (a, v) => a.Name = v), + ["contactattachments.filename"] = (a => a.FileName, (a, v) => a.FileName = v) + }; + + /// The rgdpb binary contact attachment payload field id. + public const string ContactAttachmentDataFieldId = "contactattachments.data"; + /// UnitStates text columns (catalog v2 operational family; parity-pinned). public static readonly IReadOnlyDictionary Get, Action Set)> UnitStateFieldAccessors = new Dictionary, Action)> @@ -1477,6 +1525,124 @@ public async Task ResolveContactNotesForReadAsync(int depar return result; } + public async Task ResolveContactPreplansForReadAsync(int departmentId, + IReadOnlyList preplans, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var preplan in (preplans ?? Array.Empty()).Where(p => p != null)) + { + var rowKey = preplan.ContactPreplanId; + foreach (var accessor in ContactPreplanFieldAccessors) + { + var value = accessor.Value.Get(preplan); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + var target = preplan; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(target, plaintext), + Redact = () => set(target, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveContactPreplanHazardsForReadAsync(int departmentId, + IReadOnlyList hazards, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var hazard in (hazards ?? Array.Empty()).Where(h => h != null)) + { + var rowKey = hazard.ContactPreplanHazardId; + foreach (var accessor in ContactPreplanHazardFieldAccessors) + { + var value = accessor.Value.Get(hazard); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + var target = hazard; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(target, plaintext), + Redact = () => set(target, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveContactAttachmentsForReadAsync(int departmentId, + IReadOnlyList attachments, string grantToken, string userId, + bool includeData = false, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var attachment in (attachments ?? Array.Empty()).Where(a => a != null)) + { + var rowKey = attachment.ContactAttachmentId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in ContactAttachmentFieldAccessors) + { + var value = accessor.Value.Get(attachment); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + var target = attachment; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(target, plaintext), + Redact = () => set(target, ProtectedDataEnvelope.RedactionValue) + }); + } + + if (includeData && IsBinaryEnveloped(attachment.Data)) + { + var target = attachment; + slots.Add(new Slot + { + FieldId = ContactAttachmentDataFieldId, + RowKey = rowKey, + IsBinary = true, + WireValue = Convert.ToBase64String(attachment.Data), + Owner = result, + Reveal = base64 => target.Data = Convert.FromBase64String(base64), + // A concealed binary payload is NULL — ciphertext bytes are never served. + Redact = () => target.Data = null + }); + } + else if (!includeData && IsBinaryEnveloped(attachment.Data)) + { + // Metadata-only resolution: strip the ciphertext bytes so a serializer can never carry them out. + attachment.Data = null; + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + private static void CollectContactSlots(ProtectedReadResult owner, Contact contact, List slots) { var rowKey = contact.ContactId; @@ -1886,6 +2052,109 @@ public async Task PrepareContactNoteWriteAsync(int departm return result; } + public async Task PrepareContactPreplanWriteAsync(int departmentId, ContactPreplan preplan, + ContactPreplan existingPreplan, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (preplan == null) + return ProtectedWriteResult.Allowed(); + + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. The stored row restores it. + var sentinelsHandled = ApplySentinelPolicy(preplan, existingPreplan, ContactPreplanFieldAccessors); + + var slots = new List(); + var rowKey = preplan.ContactPreplanId; + foreach (var accessor in ContactPreplanFieldAccessors) + { + var value = accessor.Value.Get(preplan); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(preplan, envelope) }); + } + + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => preplan.IsProtected = true, cancellationToken); + + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; + } + + public async Task PrepareContactPreplanHazardWriteAsync(int departmentId, ContactPreplanHazard hazard, + ContactPreplanHazard existingHazard, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (hazard == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(hazard, existingHazard, ContactPreplanHazardFieldAccessors); + + var slots = new List(); + var rowKey = hazard.ContactPreplanHazardId; + foreach (var accessor in ContactPreplanHazardFieldAccessors) + { + var value = accessor.Value.Get(hazard); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(hazard, envelope) }); + } + + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => hazard.IsProtected = true, cancellationToken); + + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; + } + + public async Task PrepareContactAttachmentWriteAsync(int departmentId, ContactAttachment attachment, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (attachment == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(attachment, null, ContactAttachmentFieldAccessors); + + var slots = new List(); + var rowKey = attachment.ContactAttachmentId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in ContactAttachmentFieldAccessors) + { + var value = accessor.Value.Get(attachment); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(attachment, envelope) }); + } + + if (attachment.Data != null && attachment.Data.Length > 0 && !IsBinaryEnveloped(attachment.Data)) + slots.Add(new WriteSlot + { + FieldId = ContactAttachmentDataFieldId, + RowKey = rowKey, + IsBinary = true, + WireValue = Convert.ToBase64String(attachment.Data), + Apply = envelopeBase64 => attachment.Data = Convert.FromBase64String(envelopeBase64) + }); + + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => attachment.IsProtected = true, cancellationToken); + + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; + } + public async Task PrepareUnitStateWriteAsync(int departmentId, UnitState state, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) { diff --git a/Core/Resgrid.Services/Records/Connectors/ExternalFeedDestination.cs b/Core/Resgrid.Services/Records/Connectors/ExternalFeedDestination.cs new file mode 100644 index 000000000..006201441 --- /dev/null +++ b/Core/Resgrid.Services/Records/Connectors/ExternalFeedDestination.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; + +namespace Resgrid.Services.Records.Connectors +{ + /// + /// Where a connector is allowed to send a request (RMS plan section 4.1). A connector is configured by a + /// department administrator and fetched by a worker, so its URL is an outbound request made from inside the + /// deployment on a person's say-so. Left unchecked that is a way to read whatever the worker can reach — + /// metadata endpoints, an internal admin port, another tenant's service — with the answer handed back in the + /// run log. So a destination is checked twice: when it is saved, and again against the resolved addresses + /// immediately before each connection, because a name that answered publicly once can answer 127.0.0.1 next. + /// + public static class ExternalFeedDestination + { + /// Refuses the URL as a connector feed root, saying why. Does not resolve names. + public static void RequireAllowedUrl(Uri uri) + { + if (uri == null) throw new ArgumentNullException(nameof(uri)); + if (!IsHostAllowed(uri.Host)) + throw new ArgumentException($"'{uri.Host}' is not an approved feed host for this installation."); + // A literal address never gets a DNS check, so it is settled here. + if (IPAddress.TryParse(uri.Host.Trim('[', ']'), out var literal) && !IsAddressAllowed(literal)) + throw new ArgumentException($"'{uri.Host}' is an internal address; a connector reads external ordering systems only."); + } + + /// + /// The same check against what the name resolves to now. Every returned address has to pass: a name that + /// answers with one public and one internal address is refused rather than raced. + /// + public static async Task RequireAllowedDestinationAsync(Uri uri, CancellationToken cancellationToken = default) + { + RequireAllowedUrl(uri); + var host = uri.Host.Trim('[', ']'); + if (IPAddress.TryParse(host, out _)) + return; + if (RecordsConnectorConfig.AllowPrivateNetworkFeeds) + return; + + IPAddress[] addresses; + try + { + addresses = await Dns.GetHostAddressesAsync(host, cancellationToken); + } + catch (Exception ex) when (ex is SocketException || ex is ArgumentException) + { + throw new InvalidOperationException($"The feed host '{uri.Host}' could not be resolved."); + } + + if (addresses == null || addresses.Length == 0) + throw new InvalidOperationException($"The feed host '{uri.Host}' could not be resolved."); + if (addresses.Any(address => !IsAddressAllowed(address))) + throw new InvalidOperationException($"The feed host '{uri.Host}' resolves to an internal address; a connector reads external ordering systems only."); + } + + /// Empty allowlist means any host; a leading dot matches the domain and everything under it. + public static bool IsHostAllowed(string host) + { + var allowed = Hosts(); + if (allowed.Count == 0) + return true; + if (string.IsNullOrWhiteSpace(host)) + return false; + host = host.Trim().TrimEnd('.'); + return allowed.Any(entry => entry.StartsWith(".", StringComparison.Ordinal) + ? host.EndsWith(entry, StringComparison.OrdinalIgnoreCase) || string.Equals(host, entry.Substring(1), StringComparison.OrdinalIgnoreCase) + : string.Equals(host, entry, StringComparison.OrdinalIgnoreCase)); + } + + /// False for anything that is not a routable public address, unless the local-development switch is on. + public static bool IsAddressAllowed(IPAddress address) + { + if (address == null) + return false; + if (RecordsConnectorConfig.AllowPrivateNetworkFeeds) + return true; + if (address.IsIPv4MappedToIPv6) + address = address.MapToIPv4(); + if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)) + return false; + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + var b = address.GetAddressBytes(); + if (b[0] == 0) return false; // 0.0.0.0/8, "this network" + if (b[0] == 10) return false; // 10.0.0.0/8 + if (b[0] == 127) return false; // loopback + if (b[0] == 169 && b[1] == 254) return false; // link-local, including the cloud metadata address + if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return false; // 172.16.0.0/12 + if (b[0] == 192 && b[1] == 168) return false; // 192.168.0.0/16 + if (b[0] == 192 && b[1] == 0 && b[2] == 0) return false; // IETF protocol assignments + if (b[0] == 100 && b[1] >= 64 && b[1] <= 127) return false; // carrier-grade NAT + if (b[0] >= 224) return false; // multicast and reserved + return true; + } + + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + if (address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || address.IsIPv6Multicast || address.IsIPv6UniqueLocal) + return false; + return true; + } + + return false; + } + + private static List Hosts() + { + var configured = RecordsConnectorConfig.AllowedFeedHosts; + if (string.IsNullOrWhiteSpace(configured)) + return new List(); + return configured.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(entry => entry.Trim().TrimEnd('.')) + .Where(entry => entry.Length > 0) + .ToList(); + } + } +} diff --git a/Core/Resgrid.Services/Records/Connectors/ExternalOrderFeedProviders.cs b/Core/Resgrid.Services/Records/Connectors/ExternalOrderFeedProviders.cs index 2c78660d4..84d60cf58 100644 --- a/Core/Resgrid.Services/Records/Connectors/ExternalOrderFeedProviders.cs +++ b/Core/Resgrid.Services/Records/Connectors/ExternalOrderFeedProviders.cs @@ -18,7 +18,15 @@ namespace Resgrid.Services.Records.Connectors /// public abstract class ExternalOrderFeedProviderBase : IExternalOrderFeedProvider { - private static readonly HttpClient SharedHttpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(Math.Max(5, RecordsConnectorConfig.TimeoutSeconds)) }; + /// + /// Redirects are not followed: the destination is checked against its resolved addresses before the + /// request, and a redirect the handler follows on its own would go somewhere nothing checked. A source + /// that answers 3xx is reported as an error so its administrator fixes the feed root instead. + /// + private static readonly HttpClient SharedHttpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) + { + Timeout = TimeSpan.FromSeconds(Math.Max(5, RecordsConnectorConfig.TimeoutSeconds)) + }; private readonly HttpClient _http; @@ -40,6 +48,10 @@ public virtual async Task FetchAsync(RmsExternalOrderConnector connector if (!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) && !(RecordsConnectorConfig.AllowHttp && string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException("The feed root must be https."); + // Checked again here, not only when the connector was saved: the allowlist can have changed, and a name + // that resolved publicly then can resolve to something internal now. + await ExternalFeedDestination.RequireAllowedDestinationAsync(uri, cancellationToken); + var builder = new UriBuilder(uri); if (!string.IsNullOrWhiteSpace(cursor)) { diff --git a/Core/Resgrid.Services/Records/Connectors/RecordDeploymentConnectorsService.cs b/Core/Resgrid.Services/Records/Connectors/RecordDeploymentConnectorsService.cs index 01887b616..4255cb58e 100644 --- a/Core/Resgrid.Services/Records/Connectors/RecordDeploymentConnectorsService.cs +++ b/Core/Resgrid.Services/Records/Connectors/RecordDeploymentConnectorsService.cs @@ -247,6 +247,13 @@ private async Task ExecuteAsync(RmsExternalO if (connector.WriteEnabled) return await FinishAsync(connector, run, result, RmsConnectorRunOutcomes.Rejected, "Write authority is not granted in this release.", cancellationToken); + // One runner at a time. A poll, a manual run and a push can all reach the same connector, and each of + // them spends the hourly budget and writes the cursor back, so the run is claimed on the row first: + // the loser is refused rather than fetching the same pages and overwriting the winner's bookkeeping. + if (!await _connectors.TryBumpRowVersionAsync(connector.DepartmentId, connector.RmsExternalOrderConnectorId, connector.RowVersion, cancellationToken)) + return await RefuseClaimAsync(run, result, cancellationToken); + connector.RowVersion += 1; + var provider = RequireProvider(connector.ProviderKey); var pages = new List(); if (pushedFeed != null) @@ -286,10 +293,14 @@ private async Task ExecuteAsync(RmsExternalO connector.LastCursor = cursor; } + // The department's orders are read once for the whole run rather than once per page: the match is by + // order number and scheme, and a twenty-page run would otherwise re-read the department's entire + // order history twenty times. Orders created by this run are added to the list as they are made. + var known = (await _orders.GetForDepartmentAsync(connector.DepartmentId, true))?.Where(o => !o.DeletedOn.HasValue).ToList() ?? new List(); foreach (var feed in pages) { run.SourceVersion = feed.Source?.Version ?? run.SourceVersion; - await ImportFeedAsync(connector, provider, feed, actor, run, result, cancellationToken); + await ImportFeedAsync(connector, provider, feed, actor, run, result, known, cancellationToken); } connector.LastSuccessOn = DateTime.UtcNow; @@ -315,6 +326,28 @@ private async Task ExecuteAsync(RmsExternalO } } + /// + /// A run that lost the claim leaves its run row and nothing else: the connector row belongs to whoever holds + /// the claim, so writing this run's stale copy of it is exactly what the claim exists to prevent. + /// + private async Task RefuseClaimAsync(RmsExternalOrderConnectorRun run, RecordDeploymentConnectorRunResult result, CancellationToken cancellationToken) + { + const string error = "The connector is already running; this run was skipped."; + run.Outcome = RmsConnectorRunOutcomes.Rejected; + run.Error = error; + run.FinishedOn = DateTime.UtcNow; + try + { + await _runs.InsertAsync(run, cancellationToken, true); + } + catch (Exception ex) + { + Logging.LogException(ex, "Connector run bookkeeping failed for " + run.RmsExternalOrderConnectorId); + } + result.Messages.Add(error); + return result; + } + private async Task FinishAsync(RmsExternalOrderConnector connector, RmsExternalOrderConnectorRun run, RecordDeploymentConnectorRunResult result, string outcome, string error, CancellationToken cancellationToken) { run.Outcome = outcome; @@ -364,10 +397,9 @@ private static bool TakeRequestSlot(RmsExternalOrderConnector connector, DateTim /// transitioned on the source's say-so. So an unseen order is provisioned, a changed order gets a new /// snapshot, an unseen request becomes a Requested fill, and everything else is reconciliation. /// - private async Task ImportFeedAsync(RmsExternalOrderConnector connector, IExternalOrderFeedProvider provider, ExternalOrderFeed feed, string actor, RmsExternalOrderConnectorRun run, RecordDeploymentConnectorRunResult result, CancellationToken cancellationToken) + private async Task ImportFeedAsync(RmsExternalOrderConnector connector, IExternalOrderFeedProvider provider, ExternalOrderFeed feed, string actor, RmsExternalOrderConnectorRun run, RecordDeploymentConnectorRunResult result, List existing, CancellationToken cancellationToken) { var scheme = string.IsNullOrWhiteSpace(connector.SourceScheme) ? provider.DefaultScheme : connector.SourceScheme; - var existing = (await _orders.GetForDepartmentAsync(connector.DepartmentId, true))?.Where(o => !o.DeletedOn.HasValue).ToList() ?? new List(); foreach (var order in feed.Orders) { @@ -587,6 +619,9 @@ private static void Apply(RmsExternalOrderConnector connector, RecordDeploymentC if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)) throw new ArgumentException("The feed root must be an absolute URL.", nameof(input)); if (!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) && !(RecordsConnectorConfig.AllowHttp && string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase))) throw new ArgumentException("The feed root must be https.", nameof(input)); + // Refused at the point a person sets it rather than only at fetch time, so an internal target is an + // error on the form instead of a run that quietly probed something. + ExternalFeedDestination.RequireAllowedUrl(uri); if (!RmsConnectorCredentialKinds.IsKnown(input.CredentialKind)) throw new ArgumentException("Choose a credential kind: none, bearer or header.", nameof(input)); if (string.Equals(input.CredentialKind, RmsConnectorCredentialKinds.Header, StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(input.CredentialHeaderName)) throw new ArgumentException("A header credential needs the header name.", nameof(input)); diff --git a/Core/Resgrid.Services/Records/FieldRecordsService.cs b/Core/Resgrid.Services/Records/FieldRecordsService.cs index 16755e9c0..bf1e0dadd 100644 --- a/Core/Resgrid.Services/Records/FieldRecordsService.cs +++ b/Core/Resgrid.Services/Records/FieldRecordsService.cs @@ -196,7 +196,7 @@ public async Task GetCatalogAsync(int departmentId, string u { // A member who cannot author still gets an empty catalog rather than an error: the app shows read-only. catalog.Ok = true; - return catalog; + return await RecordCatalogOutcomeAsync(departmentId, userId, request, capability, catalog); } var enforced = string.Equals(preflight.ProtectionState, DepartmentDataProtectionState.Enabled.ToString(), StringComparison.Ordinal) @@ -205,9 +205,11 @@ public async Task GetCatalogAsync(int departmentId, string u AddLockedStarters(catalog, request.Origin, context, capability); // A transient listing failure and a department with nothing published look identical to the app, and the // app caches what it is told, so say the catalog is unusable rather than handing back the starters alone. + // It says so under its own reason: a database that hiccuped is a retry, not grounds for every device in + // the department to throw its cache away and re-download together against the store that just failed. if (!await AddDepartmentDefinitionsAsync(departmentId, catalog, request.Origin, request.AppVersion, capability, context, enforced)) { - catalog.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.RecordsNotUsable); + catalog.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.CatalogUnavailable); return await RecordCatalogOutcomeAsync(departmentId, userId, request, capability, catalog); } @@ -490,7 +492,9 @@ public async Task SyncAsync(int departmentId, string user if (!catalog.Ok) { bundle.Reasons.AddRange(catalog.Reasons); - bundle.ResetRequired = request.Since > 0; + // A scope or policy refusal invalidates what the device holds; a transient catalog failure does not, + // so that one answers "retry" rather than telling every syncing device to reset at once. + bundle.ResetRequired = request.Since > 0 && !catalog.Reasons.Contains(FieldRecordCatalogV1.ExclusionReasons.CatalogUnavailable); bundle.ServerTimestampMs = 0; return bundle; } diff --git a/Core/Resgrid.Services/Records/RecordDefinitionsService.cs b/Core/Resgrid.Services/Records/RecordDefinitionsService.cs index e0e1b5ccf..952aed0a4 100644 --- a/Core/Resgrid.Services/Records/RecordDefinitionsService.cs +++ b/Core/Resgrid.Services/Records/RecordDefinitionsService.cs @@ -94,6 +94,7 @@ public async Task> ListAsync(int departmentId, boo Key = definition.DefinitionKey, Name = definition.Name, Category = definition.Category, Owner = RmsDefinitionOwner.Department.ToString(), Locked = false, PublishedVersion = published?.Version, DraftVersion = draft?.Version, Retired = definition.IsRetired, LifecyclePreset = latest == null ? null : ((RmsLifecyclePreset)latest.LifecyclePreset).ToString(), + Cardinality = latest == null ? null : ((RmsRecordCardinality)latest.Cardinality).ToString(), MinimumClientCapability = published?.MinimumClientCapability ?? latest?.MinimumClientCapability ?? RecordsClientCapabilities.Derive(latest?.Schema), TemplateKey = definition.TemplateKey, JurisdictionProfileKey = definition.JurisdictionProfileKey, ArtifactStatus = template == null ? RmsArtifactStatus.DepartmentLocal.ToString() : (template.Overlays.TryGetValue(definition.JurisdictionProfileKey ?? "generic", out var overlay) ? overlay.ArtifactStatus : RmsArtifactStatus.Compatible).ToString() @@ -159,6 +160,7 @@ public async Task CreateAsync(int departmentId, strin profileKey = rendering.ProfileKey; draft.Schema = rendering.Schema; draft.LifecyclePreset = rendering.Template.LifecyclePreset; + draft.Cardinality = rendering.Template.Cardinality; draft.Numbering = new RecordDefinitionNumbering { Prefix = rendering.Template.NumberPrefix, PerIncidentSequence = rendering.Template.PerIncidentSequence }; draft.PermittedSubjectTypes = rendering.Template.PermittedSubjectTypes; draft.Classification = rendering.Template.Classification; @@ -310,6 +312,12 @@ public Task ValidateAsync(int departmentId, RecordDe if (input.Name != null && string.IsNullOrWhiteSpace(input.Name)) issues.Add(RecordDefinitionIssue.Error("name", "required", "A definition name is required.")); if (input.Name?.Length > 200) issues.Add(RecordDefinitionIssue.Error("name", "too_long", "The name is limited to 200 characters.")); if (!Enum.IsDefined(typeof(RmsLifecyclePreset), input.LifecyclePreset)) issues.Add(RecordDefinitionIssue.Error("lifecyclePreset", "unknown", "Choose one of the governed lifecycle presets.")); + if (!Enum.IsDefined(typeof(RmsRecordCardinality), input.Cardinality)) issues.Add(RecordDefinitionIssue.Error("cardinality", "unknown", "Choose one of the governed cardinality rules.")); + // A cardinality rule other than MultiplePerCall is keyed on the Call, so a definition that cannot name + // one has written a rule that never fires (plan 5.2.1). + else if (input.Cardinality != RmsRecordCardinality.MultiplePerCall + && !(input.PermittedSubjectTypes ?? string.Empty).Split(',').Select(t => t.Trim()).Contains("call", StringComparer.OrdinalIgnoreCase)) + issues.Add(RecordDefinitionIssue.Error("cardinality", "no_call_subject", $"{input.Cardinality} is enforced per Call; add the 'call' subject or choose MultiplePerCall.")); if (input.LifecyclePreset == RmsLifecyclePreset.ApprovalAcknowledgement && input.ApproverRoleIds != null && input.ReviewerRoleIds != null && input.ApproverRoleIds.Count > 0 && input.ReviewerRoleIds.Count > 0 && input.ApproverRoleIds.All(input.ReviewerRoleIds.Contains)) issues.Add(RecordDefinitionIssue.Warning("approverRoleIds", "same_roles", "Approvers and reviewers are the same roles; the approver may still never be the author.")); if (input.ReviewDueHours.HasValue && (input.ReviewDueHours <= 0 || input.ReviewDueHours > 24 * 365)) issues.Add(RecordDefinitionIssue.Error("reviewDueHours", "out_of_range", "Review due hours must be between 1 and 8760.")); @@ -630,6 +638,9 @@ public static RecordDefinitionDiff DiffVersions(string definitionKey, RmsRecordD var diff = new RecordDefinitionDiff { DefinitionKey = definitionKey, FromVersion = from.Version, ToVersion = to.Version }; DiffSchemas(diff, from.Schema, to.Schema); if (from.LifecyclePreset != to.LifecyclePreset) diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "policy", Change = "changed", Key = "lifecyclePreset", Detail = $"{(RmsLifecyclePreset)from.LifecyclePreset} -> {(RmsLifecyclePreset)to.LifecyclePreset}", Breaking = false }); + // Tightening cardinality is breaking: Records that were legal under the old rule already exist, and the + // new rule refuses the next one on a Call that already has them. + if (from.Cardinality != to.Cardinality) diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "policy", Change = "changed", Key = "cardinality", Detail = $"{(RmsRecordCardinality)from.Cardinality} -> {(RmsRecordCardinality)to.Cardinality}", Breaking = (RmsRecordCardinality)to.Cardinality != RmsRecordCardinality.MultiplePerCall }); if (!string.Equals(from.NumberingJson, to.NumberingJson, StringComparison.Ordinal)) diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "policy", Change = "changed", Key = "numbering", Detail = "Numbering policy changed; issued numbers never change.", Breaking = false }); if (from.RetentionYears != to.RetentionYears) diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "policy", Change = "changed", Key = "retentionYears", Detail = $"{from.RetentionYears?.ToString() ?? "class default"} -> {to.RetentionYears?.ToString() ?? "class default"}", Breaking = false }); if (from.Classification != to.Classification) diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "policy", Change = "changed", Key = "classification", Detail = $"{(RmsFieldClassification)from.Classification} -> {(RmsFieldClassification)to.Classification}", Breaking = to.Classification < from.Classification }); @@ -746,6 +757,7 @@ private static RmsRecordDefinitionVersion NewVersion(RmsRecordDefinition definit private static void Apply(RmsRecordDefinitionVersion version, RecordDefinitionDraftInput input, string capability) { version.LifecyclePreset = (int)input.LifecyclePreset; + version.Cardinality = (int)input.Cardinality; version.ReviewerRoleIds = string.Join(",", (input.ReviewerRoleIds ?? new List()).Distinct()); version.ApproverRoleIds = string.Join(",", (input.ApproverRoleIds ?? new List()).Distinct()); version.ReviewDueHours = input.ReviewDueHours; @@ -783,7 +795,7 @@ public static RecordDefinitionDraftInput ToDraftInput(RmsRecordDefinitionVersion return new RecordDefinitionDraftInput { Name = definition?.Name, Category = definition?.Category, Description = definition?.Description, PermittedSubjectTypes = definition?.PermittedSubjectTypes, - LifecyclePreset = (RmsLifecyclePreset)version.LifecyclePreset, + LifecyclePreset = (RmsLifecyclePreset)version.LifecyclePreset, Cardinality = (RmsRecordCardinality)version.Cardinality, ReviewerRoleIds = ParseIds(version.ReviewerRoleIds), ApproverRoleIds = ParseIds(version.ApproverRoleIds), ReviewDueHours = version.ReviewDueHours, ApproveDueHours = version.ApproveDueHours, RequireAuthorAttestation = version.RequireAuthorAttestation, Numbering = version.Numbering, RetentionYears = version.RetentionYears, Classification = (RmsFieldClassification)version.Classification, @@ -902,6 +914,7 @@ public static object DefinitionBlock(RmsRecordDefinition definition, RmsRecordDe previous_version = previousVersion, state = version == null ? null : ((RmsDefinitionVersionState)version.State).ToString(), lifecycle_preset = version == null ? null : ((RmsLifecyclePreset)version.LifecyclePreset).ToString(), + cardinality = version == null ? null : ((RmsRecordCardinality)version.Cardinality).ToString(), template_key = definition.TemplateKey, jurisdiction_profile_key = definition.JurisdictionProfileKey, minimum_client_capability = version?.MinimumClientCapability, diff --git a/Core/Resgrid.Services/Records/RecordDeploymentsService.cs b/Core/Resgrid.Services/Records/RecordDeploymentsService.cs index 5c5237e7a..eeee851f7 100644 --- a/Core/Resgrid.Services/Records/RecordDeploymentsService.cs +++ b/Core/Resgrid.Services/Records/RecordDeploymentsService.cs @@ -316,9 +316,11 @@ public async Task TransitionFillAsync(int departmentId, st var fill = await _fills.GetByIdForDepartmentAsync(departmentId, fillId) ?? throw new ArgumentException("Unknown fill.", nameof(fillId)); var order = await RequireEditableAsync(departmentId, userId, fill.RmsExternalOrderId); // Two callers can both read Requested and both pass the state-machine check, and the second write would - // silently drop the first transition along with its audit line. + // silently drop the first transition along with its audit line. This check refuses a caller who says + // which version it saw; the conditional claim below is what actually serializes the two writers. if (input.ExpectedRowVersion.HasValue && fill.RowVersion != input.ExpectedRowVersion.Value) throw new RecordConcurrencyException(fill.RmsExternalOrderFillId, input.ExpectedRowVersion.Value, fill.RowVersion); + var loadedRowVersion = fill.RowVersion; var from = (RmsDeploymentFillStatus)fill.Status; if (!Allowed.TryGetValue(from, out var next) || !next.Contains(input.Status)) throw new InvalidOperationException($"A fill cannot move from {from} to {input.Status}."); @@ -341,10 +343,18 @@ public async Task TransitionFillAsync(int departmentId, st case RmsDeploymentFillStatus.Demobilized: fill.DemobilizedOn = when; break; case RmsDeploymentFillStatus.Returned: fill.ReturnedOn = when; break; } - fill.ModifiedOn = DateTime.UtcNow; fill.ModifiedByUserId = userId; fill.RowVersion += 1; + fill.ModifiedOn = DateTime.UtcNow; fill.ModifiedByUserId = userId; await InTransactionAsync(async () => { + // The row is claimed at the version it was read at, so the loser of a race is refused rather than + // overwriting the winner's transition with a whole-entity update. + if (!await _fills.TryBumpRowVersionAsync(departmentId, fill.RmsExternalOrderFillId, loadedRowVersion, cancellationToken)) + { + var current = await _fills.GetByIdForDepartmentAsync(departmentId, fill.RmsExternalOrderFillId); + throw new RecordConcurrencyException(fill.RmsExternalOrderFillId, loadedRowVersion, current?.RowVersion ?? -1); + } + fill.RowVersion = loadedRowVersion + 1; await _fills.UpdateAsync(fill, cancellationToken, true); var fills = (await _fills.GetForOrderAsync(departmentId, order.RmsExternalOrderId))?.ToList() ?? new List(); var active = fills.Where(f => f.Status != (int)RmsDeploymentFillStatus.Declined).ToList(); diff --git a/Core/Resgrid.Services/Records/RecordTemplateCatalog.IncidentSupport.cs b/Core/Resgrid.Services/Records/RecordTemplateCatalog.IncidentSupport.cs index 40e0a6e47..4484589ab 100644 --- a/Core/Resgrid.Services/Records/RecordTemplateCatalog.IncidentSupport.cs +++ b/Core/Resgrid.Services/Records/RecordTemplateCatalog.IncidentSupport.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Resgrid.Model; @@ -38,6 +39,24 @@ private static RecordTemplateDefinition Ics(string key, string name, string cate return template; } + /// + /// The plan products a period has exactly one of (§10A.2). A second ICS 202 for the same operational period + /// is an error rather than a second opinion, so these declare `OnePerSubjectPerCall`; everything else in the + /// pack — messages, tickets, activity logs, time reports — is legitimately many per incident. + /// + /// + /// A local rather than a static field on purpose: static initializers across the two halves of this partial + /// class run in an order the compiler picks, and `Packs` in the other half would read this one while it was + /// still null. + /// + private static string[] OnePerOperationalPeriod() => new[] + { + "ics-202-objectives", "ics-203-organization", "ics-204-assignment", "ics-207-org-chart", "iap-package", + "ics-205-comms-plan", "ics-205a-comms-list", "ics-217a-frequency-inventory", + "ics-206-medical-plan", "ics-208-safety-message", "ics-215a-hazard-analysis", + "ics-215-planning-worksheet", "ics-220-air-operations" + }; + /// The header every incident product carries: which incident, which operational period, who prepared it. private static RecordSectionSchema Header(bool withPeriod = true) { @@ -99,6 +118,13 @@ private static RecordTemplatePack IncidentSupportPack() .Concat(ResourceForms()).Concat(ActivityAndPlanning()).Concat(IncidentBusinessForms()).Concat(SupportOperations()).Concat(BusinessAdministration())) pack.Definitions.Add(definition); + foreach (var key in OnePerOperationalPeriod()) + { + var definition = pack.Definitions.SingleOrDefault(d => d.Key == IncidentSupportPackKey + "." + key) + ?? throw new InvalidOperationException($"'{key}' is not in the Incident Support pack."); + definition.Cardinality = RmsRecordCardinality.OnePerSubjectPerCall; + } + return pack; } diff --git a/Core/Resgrid.Services/Records/RecordsCrrService.cs b/Core/Resgrid.Services/Records/RecordsCrrService.cs new file mode 100644 index 000000000..4895fba30 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsCrrService.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// Community risk reduction activities (RMS plan section 4.3, RMS-5). Counts only; no attendee identity is stored. + public class RecordsCrrService : IRecordsCrrService + { + private readonly RecordsPreventionGate _gate; + private readonly IRmsCrrActivitiesRepository _activities; + private readonly IRmsOccupanciesRepository _occupancies; + + public RecordsCrrService(RecordsPreventionGate gate, IRmsCrrActivitiesRepository activities, IRmsOccupanciesRepository occupancies) + { + _gate = gate; _activities = activities; _occupancies = occupancies; + } + + public Task IsModuleEnabledAsync(int departmentId) => _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Crr); + private async Task RequireViewAsync(int departmentId, string userId) { await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Crr); await _gate.RequireViewerAsync(departmentId, userId); } + private async Task RequireAdminAsync(int departmentId, string userId) { await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Crr); await _gate.RequireAdminAsync(departmentId, userId); } + + public async Task> ListAsync(int departmentId, string userId, DateTime startUtc, DateTime endUtc, int take) + { + await RequireViewAsync(departmentId, userId); + return (await _activities.GetForRangeAsync(departmentId, startUtc, endUtc, take))?.ToList() ?? new List(); + } + + public async Task GetAsync(int departmentId, string userId, string activityId) + { + await RequireViewAsync(departmentId, userId); + var activity = await _activities.GetByIdForDepartmentAsync(departmentId, activityId); + return activity == null || activity.DeletedOn != null ? null : activity; + } + + public async Task SaveAsync(int departmentId, string userId, RmsCrrActivity input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + if (!string.IsNullOrWhiteSpace(input.RmsOccupancyId)) + { + var occupancy = await _occupancies.GetByIdForDepartmentAsync(departmentId, input.RmsOccupancyId); + if (occupancy == null || occupancy.DeletedOn != null) throw new ArgumentException("The occupancy does not exist."); + } + var now = DateTime.UtcNow; + var entity = string.IsNullOrWhiteSpace(input.RmsCrrActivityId) ? null : await _activities.GetByIdForDepartmentAsync(departmentId, input.RmsCrrActivityId); + if (entity != null && entity.DeletedOn != null) entity = null; + var isNew = entity == null; + if (isNew) entity = new RmsCrrActivity { RmsCrrActivityId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), CreatedOn = now, CreatedByUserId = userId, RowVersion = 0 }; + entity.Kind = input.Kind == 0 ? (int)RmsCrrActivityKind.PublicEducation : input.Kind; + entity.OccurredOn = input.OccurredOn == default ? now : input.OccurredOn; + entity.Title = RecordsPreventionGate.Require(input.Title, 250, "An activity needs a title."); + entity.Description = RecordsPreventionGate.Trim(input.Description, 4000); entity.RmsOccupancyId = RecordsPreventionGate.Trim(input.RmsOccupancyId, 36); + entity.LocationText = RecordsPreventionGate.Trim(input.LocationText, 500); entity.Latitude = input.Latitude; entity.Longitude = input.Longitude; + entity.AudienceCount = Math.Max(0, input.AudienceCount); entity.SmokeAlarmsInstalled = Math.Max(0, input.SmokeAlarmsInstalled); entity.HoursSpent = Math.Max(0, input.HoursSpent); + entity.StaffUserIdsCsv = RecordsPreventionGate.Trim(input.StaffUserIdsCsv, 4000); entity.Outcome = RecordsPreventionGate.Trim(input.Outcome, 4000); entity.NerisSecondaryJson = RecordsPreventionGate.Trim(input.NerisSecondaryJson, 8000); + entity.ModifiedOn = now; entity.RowVersion++; + if (isNew) await _activities.InsertAsync(entity, cancellationToken, true); else await _activities.UpdateAsync(entity, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, isNew ? "CRR activity recorded" : "CRR activity updated", entity.RmsCrrActivityId, new { entity.Title, kind = ((RmsCrrActivityKind)entity.Kind).ToString(), entity.AudienceCount }, cancellationToken: cancellationToken); + return entity; + } + + public async Task DeleteAsync(int departmentId, string userId, string activityId, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var activity = await _activities.GetByIdForDepartmentAsync(departmentId, activityId); + if (activity == null || activity.DeletedOn != null) throw new ArgumentException("The activity does not exist."); + activity.DeletedOn = DateTime.UtcNow; activity.ModifiedOn = activity.DeletedOn.Value; activity.RowVersion++; + await _activities.UpdateAsync(activity, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "CRR activity removed", activityId, new { activity.Title }, cancellationToken: cancellationToken); + } + + public async Task GetSummaryAsync(int departmentId, string userId, DateTime startUtc, DateTime endUtc) + { + await RequireViewAsync(departmentId, userId); + var rows = (await _activities.GetForRangeAsync(departmentId, startUtc, endUtc, 2000))?.ToList() ?? new List(); + var summary = new CrrSummary { Start = startUtc, End = endUtc, Activities = rows.Count, Audience = rows.Sum(r => r.AudienceCount), SmokeAlarmsInstalled = rows.Sum(r => r.SmokeAlarmsInstalled), Hours = rows.Sum(r => r.HoursSpent) }; + foreach (var group in rows.GroupBy(r => r.Kind)) summary.ByKind[group.Key] = group.Count(); + return summary; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsFieldRolloutService.cs b/Core/Resgrid.Services/Records/RecordsFieldRolloutService.cs index 54e4bc50b..ff424a2c3 100644 --- a/Core/Resgrid.Services/Records/RecordsFieldRolloutService.cs +++ b/Core/Resgrid.Services/Records/RecordsFieldRolloutService.cs @@ -133,7 +133,8 @@ public async Task GetAsync(int departmentId, string userId, rollout.MinimumAppVersions[app.ToString()] = MinimumVersionFor(app) ?? string.Empty; } - var events = (await _events.GetForWindowAsync(departmentId, since, MaxWindowEvents))?.ToList() ?? new List(); + // The window read is the large one: an abandoned dashboard should stop it rather than run it out. + var events = (await _events.GetForWindowAsync(departmentId, since, MaxWindowEvents, cancellationToken))?.ToList() ?? new List(); // Records are the ground truth for adoption: an app that reports nothing still shows the work it did. var created = (await _records.GetCreatedSinceAsync(departmentId, since, MaxWindowEvents))?.ToList() ?? new List(); var finalized = (await _records.GetFinalizedSinceAsync(departmentId, since))?.ToList() ?? new List(); @@ -168,7 +169,7 @@ public static RecordsFieldRolloutApp Summarize(RmsOriginClient app, List !string.IsNullOrWhiteSpace(e.UserId)).Select(e => e.UserId).Distinct(StringComparer.OrdinalIgnoreCase).Count(), Events = group.Count() }) - .OrderByDescending(version => FieldRecordCatalogV1.CompareVersions(version.AppVersion, "0")) + .OrderByDescending(version => version.AppVersion, Comparer.Create(FieldRecordCatalogV1.CompareVersions)) .ThenByDescending(version => version.Users) .ToList(); diff --git a/Core/Resgrid.Services/Records/RecordsHydrantsService.cs b/Core/Resgrid.Services/Records/RecordsHydrantsService.cs new file mode 100644 index 000000000..773600087 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsHydrantsService.cs @@ -0,0 +1,244 @@ +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.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// Hydrants and other water sources (RMS plan section 4.3, RMS-5): flow/test history, maintenance, in/out-of-service state, CSV import and the response-map layer. + public class RecordsHydrantsService : IRecordsHydrantsService + { + public const int TestIntervalMonths = 12; + + private readonly RecordsPreventionGate _gate; + private readonly IRmsHydrantsRepository _hydrants; + private readonly IRmsHydrantFlowTestsRepository _flowTests; + private readonly IRmsHydrantMaintenancesRepository _maintenance; + private readonly IRmsPreventionAttachmentsRepository _attachments; + + public RecordsHydrantsService(RecordsPreventionGate gate, IRmsHydrantsRepository hydrants, IRmsHydrantFlowTestsRepository flowTests, IRmsHydrantMaintenancesRepository maintenance, IRmsPreventionAttachmentsRepository attachments) + { + _gate = gate; _hydrants = hydrants; _flowTests = flowTests; _maintenance = maintenance; _attachments = attachments; + } + + public Task IsModuleEnabledAsync(int departmentId) => _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Hydrants); + + private async Task RequireViewAsync(int departmentId, string userId) { await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Hydrants); await _gate.RequireViewerAsync(departmentId, userId); } + private async Task RequireAdminAsync(int departmentId, string userId) { await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Hydrants); await _gate.RequireAdminAsync(departmentId, userId); } + + public async Task> ListAsync(int departmentId, string userId) + { + await RequireViewAsync(departmentId, userId); + return (await _hydrants.GetAllLiveAsync(departmentId))?.ToList() ?? new List(); + } + + public async Task GetAsync(int departmentId, string userId, string hydrantId) + { + await RequireViewAsync(departmentId, userId); + var hydrant = await LiveAsync(departmentId, hydrantId); + if (hydrant == null) return null; + return new HydrantAggregate + { + Hydrant = hydrant, + FlowTests = (await _flowTests.GetForHydrantAsync(departmentId, hydrantId))?.ToList() ?? new List(), + Maintenance = (await _maintenance.GetForHydrantAsync(departmentId, hydrantId))?.ToList() ?? new List(), + Attachments = (await _attachments.GetMetadataForParentAsync(departmentId, RmsPreventionParentKind.Hydrant, hydrantId))?.ToList() ?? new List() + }; + } + + public async Task SaveAsync(int departmentId, string userId, RmsHydrant input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var entity = await UpsertAsync(departmentId, userId, input, "manual", cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Hydrant saved", entity.RmsHydrantId, new { entity.HydrantNumber }, cancellationToken: cancellationToken); + return entity; + } + + private async Task UpsertAsync(int departmentId, string userId, RmsHydrant input, string source, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var number = RecordsPreventionGate.Require(input.HydrantNumber, 64, "A hydrant needs a number."); + if (input.Latitude < -90 || input.Latitude > 90 || input.Longitude < -180 || input.Longitude > 180) throw new ArgumentException("Hydrant coordinates are out of range."); + var existing = string.IsNullOrWhiteSpace(input.RmsHydrantId) ? await _hydrants.GetByNumberAsync(departmentId, number) : await _hydrants.GetByIdForDepartmentAsync(departmentId, input.RmsHydrantId); + if (existing != null && existing.DeletedOn != null) existing = null; + var entity = existing ?? new RmsHydrant { RmsHydrantId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), InService = true, CreatedOn = now, CreatedByUserId = userId, RowVersion = 0, Source = source }; + if (existing != null && !string.Equals(existing.HydrantNumber, number, StringComparison.OrdinalIgnoreCase)) + { + var clash = await _hydrants.GetByNumberAsync(departmentId, number); + if (clash != null && clash.RmsHydrantId != existing.RmsHydrantId) throw new ArgumentException($"Hydrant number {number} is already in use."); + } + entity.HydrantNumber = number; entity.Type = input.Type == 0 ? (int)RmsHydrantType.DryBarrel : input.Type; entity.Latitude = input.Latitude; entity.Longitude = input.Longitude; + entity.AddressText = RecordsPreventionGate.Trim(input.AddressText, 500); entity.OwnerKind = input.OwnerKind == 0 ? (int)RmsHydrantOwnerKind.Municipal : input.OwnerKind; entity.OwnerName = RecordsPreventionGate.Trim(input.OwnerName, 200); + entity.MainSizeInches = input.MainSizeInches; entity.Notes = RecordsPreventionGate.Trim(input.Notes, 4000); entity.PoiId = input.PoiId; + if (input.FlowGpm.HasValue) { entity.FlowGpm = input.FlowGpm; entity.FlowClass = (int)HydrantFlowCalculator.Classify(input.FlowGpm); } + if (input.StaticPressurePsi.HasValue) entity.StaticPressurePsi = input.StaticPressurePsi; + if (input.ResidualPressurePsi.HasValue) entity.ResidualPressurePsi = input.ResidualPressurePsi; + entity.ModifiedOn = now; entity.RowVersion++; + if (existing == null) await _hydrants.InsertAsync(entity, cancellationToken, true); else await _hydrants.UpdateAsync(entity, cancellationToken, true); + return entity; + } + + public async Task DeleteAsync(int departmentId, string userId, string hydrantId, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var hydrant = await LiveAsync(departmentId, hydrantId) ?? throw new ArgumentException("The hydrant does not exist."); + hydrant.DeletedOn = DateTime.UtcNow; hydrant.ModifiedOn = hydrant.DeletedOn.Value; hydrant.RowVersion++; + await _hydrants.UpdateAsync(hydrant, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Hydrant removed", hydrantId, new { hydrant.HydrantNumber }, cancellationToken: cancellationToken); + } + + public async Task SetServiceStateAsync(int departmentId, string userId, string hydrantId, bool inService, string reason, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var hydrant = await LiveAsync(departmentId, hydrantId) ?? throw new ArgumentException("The hydrant does not exist."); + if (!inService && string.IsNullOrWhiteSpace(reason)) throw new ArgumentException("Taking a hydrant out of service needs a reason."); + var now = DateTime.UtcNow; + hydrant.InService = inService; hydrant.OutOfServiceReason = inService ? null : RecordsPreventionGate.Trim(reason, 500); hydrant.OutOfServiceSince = inService ? null : now; + hydrant.ModifiedOn = now; hydrant.RowVersion++; + await _hydrants.UpdateAsync(hydrant, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, inService ? "Hydrant returned to service" : "Hydrant out of service", hydrantId, new { hydrant.HydrantNumber, reason = hydrant.OutOfServiceReason }, cancellationToken: cancellationToken); + return hydrant; + } + + public async Task RecordFlowTestAsync(int departmentId, string userId, RmsHydrantFlowTest input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var hydrant = await LiveAsync(departmentId, input.RmsHydrantId) ?? throw new ArgumentException("The hydrant does not exist."); + if (input.PitotPressurePsi <= 0 || input.OutletDiameterInches <= 0) throw new ArgumentException("A flow test needs the pitot pressure and the outlet diameter."); + var now = DateTime.UtcNow; + var coefficient = input.Coefficient <= 0 ? 0.9m : input.Coefficient; + var test = new RmsHydrantFlowTest + { + RmsHydrantFlowTestId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsHydrantId = hydrant.RmsHydrantId, + TestedOn = input.TestedOn == default ? now : input.TestedOn, TestedByUserId = userId, StaticPressurePsi = input.StaticPressurePsi, ResidualPressurePsi = input.ResidualPressurePsi, + PitotPressurePsi = input.PitotPressurePsi, OutletDiameterInches = input.OutletDiameterInches, Coefficient = coefficient, Notes = RecordsPreventionGate.Trim(input.Notes, 2000), CreatedOn = now + }; + test.FlowGpm = HydrantFlowCalculator.FlowGpm(coefficient, input.OutletDiameterInches, input.PitotPressurePsi); + test.FlowClass = (int)HydrantFlowCalculator.Classify(test.FlowGpm); + await _flowTests.InsertAsync(test, cancellationToken, true); + if (!hydrant.LastTestedOn.HasValue || hydrant.LastTestedOn <= test.TestedOn) + { + hydrant.LastTestedOn = test.TestedOn; hydrant.FlowGpm = test.FlowGpm; hydrant.FlowClass = test.FlowClass; hydrant.StaticPressurePsi = test.StaticPressurePsi; hydrant.ResidualPressurePsi = test.ResidualPressurePsi; + hydrant.ModifiedOn = now; hydrant.RowVersion++; + await _hydrants.UpdateAsync(hydrant, cancellationToken, true); + } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Hydrant flow test recorded", hydrant.RmsHydrantId, new { test.FlowGpm, flow_class = ((RmsHydrantFlowClass)test.FlowClass).ToString() }, cancellationToken: cancellationToken); + return test; + } + + public async Task RecordMaintenanceAsync(int departmentId, string userId, RmsHydrantMaintenance input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var hydrant = await LiveAsync(departmentId, input.RmsHydrantId) ?? throw new ArgumentException("The hydrant does not exist."); + var now = DateTime.UtcNow; + var row = new RmsHydrantMaintenance + { + RmsHydrantMaintenanceId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsHydrantId = hydrant.RmsHydrantId, + PerformedOn = input.PerformedOn == default ? now : input.PerformedOn, PerformedByUserId = userId, Kind = input.Kind == 0 ? (int)RmsHydrantMaintenanceKind.Inspection : input.Kind, + Notes = RecordsPreventionGate.Trim(input.Notes, 2000), ReturnedToService = input.ReturnedToService, CreatedOn = now + }; + await _maintenance.InsertAsync(row, cancellationToken, true); + hydrant.LastMaintainedOn = row.PerformedOn; + if (row.ReturnedToService && !hydrant.InService) { hydrant.InService = true; hydrant.OutOfServiceReason = null; hydrant.OutOfServiceSince = null; } + hydrant.ModifiedOn = now; hydrant.RowVersion++; + await _hydrants.UpdateAsync(hydrant, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Hydrant maintenance recorded", hydrant.RmsHydrantId, new { kind = ((RmsHydrantMaintenanceKind)row.Kind).ToString(), row.ReturnedToService }, cancellationToken: cancellationToken); + return row; + } + + public async Task ImportCsvAsync(int departmentId, string userId, string csv, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (string.IsNullOrWhiteSpace(csv)) throw new ArgumentException("The import file is empty."); + var result = new HydrantImportResult(); + var lines = csv.Split('\n').Select(l => l.TrimEnd('\r')).Where(l => !string.IsNullOrWhiteSpace(l)).ToList(); + if (lines.Count == 0) return result; + var header = RecordsInspectionsService.CsvSplit(lines[0]).Select(h => h.Trim().ToLowerInvariant().Replace(" ", "_")).ToList(); + var hasHeader = header.Contains("latitude") || header.Contains("lat") || header.Contains("number") || header.Contains("hydrant_number"); + int Index(params string[] names) { foreach (var n in names) { var i = header.IndexOf(n); if (i >= 0) return i; } return -1; } + var iNumber = hasHeader ? Index("hydrant_number", "number", "id") : 0; + var iLat = hasHeader ? Index("latitude", "lat") : 1; + var iLon = hasHeader ? Index("longitude", "lon", "lng") : 2; + var iType = hasHeader ? Index("type") : 3; + var iAddress = hasHeader ? Index("address") : 4; + var iMain = hasHeader ? Index("main_size", "main_size_inches", "main") : 5; + var iFlow = hasHeader ? Index("flow_gpm", "flow") : 6; + var iOwner = hasHeader ? Index("owner", "owner_name") : 7; + if (iNumber < 0 || iLat < 0 || iLon < 0) throw new ArgumentException("The CSV needs hydrant number, latitude and longitude columns."); + var existingNumbers = ((await _hydrants.GetAllLiveAsync(departmentId)) ?? Enumerable.Empty()).Select(h => h.HydrantNumber).ToHashSet(StringComparer.OrdinalIgnoreCase); + + for (var lineNo = hasHeader ? 1 : 0; lineNo < lines.Count && result.RowsRead < 20000; lineNo++) + { + var cells = RecordsInspectionsService.CsvSplit(lines[lineNo]); + string Cell(int i) => i >= 0 && i < cells.Count ? cells[i].Trim() : null; + var row = new HydrantImportRow { Line = lineNo + 1, HydrantNumber = Cell(iNumber), Type = Cell(iType), Address = Cell(iAddress), MainSize = Cell(iMain), FlowGpm = Cell(iFlow), Owner = Cell(iOwner) }; + result.RowsRead++; + if (string.IsNullOrWhiteSpace(row.HydrantNumber)) { row.Error = "Missing hydrant number"; result.Rejected.Add(row); continue; } + if (!decimal.TryParse(Cell(iLat), NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) || !decimal.TryParse(Cell(iLon), NumberStyles.Float, CultureInfo.InvariantCulture, out var lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) + { row.Error = "Invalid coordinates"; result.Rejected.Add(row); continue; } + row.Latitude = lat; row.Longitude = lon; + var input = new RmsHydrant + { + HydrantNumber = row.HydrantNumber, Latitude = lat, Longitude = lon, AddressText = row.Address, OwnerName = row.Owner, Type = (int)ParseType(row.Type), + MainSizeInches = decimal.TryParse(row.MainSize, NumberStyles.Float, CultureInfo.InvariantCulture, out var main) ? main : (decimal?)null, + FlowGpm = int.TryParse(row.FlowGpm, out var flow) ? flow : (int?)null, + OwnerKind = string.IsNullOrWhiteSpace(row.Owner) ? (int)RmsHydrantOwnerKind.Municipal : (int)RmsHydrantOwnerKind.Other + }; + var existed = existingNumbers.Contains(row.HydrantNumber); + try + { + await UpsertAsync(departmentId, userId, input, "csv-import", cancellationToken); + if (existed) result.Updated++; else { result.Created++; existingNumbers.Add(row.HydrantNumber); } + } + catch (ArgumentException ex) { row.Error = ex.Message; result.Rejected.Add(row); } + } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, "Hydrants imported", departmentId.ToString(), new { result.RowsRead, result.Created, result.Updated, rejected = result.Rejected.Count }, cancellationToken: cancellationToken); + return result; + } + + private static RmsHydrantType ParseType(string value) + { + if (string.IsNullOrWhiteSpace(value)) return RmsHydrantType.DryBarrel; + var v = value.Trim().ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + if (v.StartsWith("dry")) return RmsHydrantType.DryBarrel; + if (v.StartsWith("wet")) return RmsHydrantType.WetBarrel; + if (v.Contains("standpipe")) return RmsHydrantType.Standpipe; + if (v.Contains("cistern") || v.Contains("tank")) return RmsHydrantType.Cistern; + if (v.Contains("draft") || v.Contains("pond") || v.Contains("lake") || v.Contains("river")) return RmsHydrantType.DraftingSite; + return RmsHydrantType.Other; + } + + public async Task> GetMapLayerAsync(int departmentId, string userId, decimal? minLat, decimal? maxLat, decimal? minLon, decimal? maxLon) + { + await RequireViewAsync(departmentId, userId); + IEnumerable rows = minLat.HasValue && maxLat.HasValue && minLon.HasValue && maxLon.HasValue + ? await _hydrants.GetInBoundsAsync(departmentId, Math.Min(minLat.Value, maxLat.Value), Math.Max(minLat.Value, maxLat.Value), Math.Min(minLon.Value, maxLon.Value), Math.Max(minLon.Value, maxLon.Value), 5000) + : await _hydrants.GetAllLiveAsync(departmentId); + return (rows ?? Enumerable.Empty()).Select(h => new HydrantMapPoint { HydrantId = h.RmsHydrantId, HydrantNumber = h.HydrantNumber, Type = h.Type, Latitude = h.Latitude, Longitude = h.Longitude, FlowClass = h.FlowClass, FlowGpm = h.FlowGpm, InService = h.InService, MainSizeInches = h.MainSizeInches }).ToList(); + } + + public async Task> GetNearestAsync(int departmentId, decimal latitude, decimal longitude, int take, double maxMeters) + { + var rows = (await _hydrants.GetAllLiveAsync(departmentId))?.ToList() ?? new List(); + return rows.Select(h => (Hydrant: h, Meters: GeoMath.HaversineMeters((double)latitude, (double)longitude, (double)h.Latitude, (double)h.Longitude))) + .Where(x => maxMeters <= 0 || x.Meters <= maxMeters).OrderBy(x => x.Meters).Take(Math.Clamp(take, 1, 50)).Select(x => x.Hydrant).ToList(); + } + + public Task CountTestDueAsync(int departmentId, DateTime utcNow) => _hydrants.CountTestDueAsync(departmentId, utcNow.AddMonths(-TestIntervalMonths)); + + private async Task LiveAsync(int departmentId, string hydrantId) + { + if (string.IsNullOrWhiteSpace(hydrantId)) return null; + var hydrant = await _hydrants.GetByIdForDepartmentAsync(departmentId, hydrantId); + return hydrant == null || hydrant.DeletedOn != null ? null : hydrant; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsInspectionsService.cs b/Core/Resgrid.Services/Records/RecordsInspectionsService.cs new file mode 100644 index 000000000..9b1649606 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsInspectionsService.cs @@ -0,0 +1,598 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Inspection programs, adopted code sets, inspections, violations and re-inspection (RMS plan section 4.3, RMS-5). + /// A completed inspection turns every failed checklist item into an Open violation citing the item's code section; + /// the daily sweep raises trigger 162 once per violation as it passes its due date. + /// + public class RecordsInspectionsService : IRecordsInspectionsService + { + public const string InspectionAggregate = "RmsInspection"; + public const string ViolationAggregate = "RmsViolation"; + private const int SweepBatch = 200; + + private readonly RecordsPreventionGate _gate; + private readonly IRmsCodeSetsRepository _codeSets; + private readonly IRmsCodeSectionsRepository _codeSections; + private readonly IRmsInspectionProgramsRepository _programs; + private readonly IRmsInspectionsRepository _inspections; + private readonly IRmsViolationsRepository _violations; + private readonly IRmsOccupanciesRepository _occupancies; + private readonly IRmsPreventionAttachmentsRepository _attachments; + private readonly IRecordsProtectionService _protection; + private readonly IDomainEventOutboxService _outbox; + private readonly IUnitOfWork _unitOfWork; + + public RecordsInspectionsService(RecordsPreventionGate gate, IRmsCodeSetsRepository codeSets, IRmsCodeSectionsRepository codeSections, IRmsInspectionProgramsRepository programs, + IRmsInspectionsRepository inspections, IRmsViolationsRepository violations, IRmsOccupanciesRepository occupancies, IRmsPreventionAttachmentsRepository attachments, + IRecordsProtectionService protection, IDomainEventOutboxService outbox, IUnitOfWork unitOfWork) + { + _gate = gate; _codeSets = codeSets; _codeSections = codeSections; _programs = programs; _inspections = inspections; _violations = violations; + _occupancies = occupancies; _attachments = attachments; _protection = protection; _outbox = outbox; _unitOfWork = unitOfWork; + } + + public Task IsModuleEnabledAsync(int departmentId) => _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Inspections); + + private Task RequireViewAsync(int departmentId, string userId) => Task.WhenAll(_gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Inspections), _gate.RequireViewerAsync(departmentId, userId)); + + private async Task RequireAdminAsync(int departmentId, string userId) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Inspections); + await _gate.RequireAdminAsync(departmentId, userId); + } + + #region Code sets + + public async Task> GetCodeSetsAsync(int departmentId, string userId, bool includeInactive) + { + await RequireViewAsync(departmentId, userId); + return (await _codeSets.GetForDepartmentAsync(departmentId, includeInactive))?.ToList() ?? new List(); + } + + public async Task SaveCodeSetAsync(int departmentId, string userId, RmsCodeSet input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var now = DateTime.UtcNow; + var entity = string.IsNullOrWhiteSpace(input.RmsCodeSetId) ? null : await _codeSets.GetByIdForDepartmentAsync(departmentId, input.RmsCodeSetId); + var isNew = entity == null; + if (isNew) entity = new RmsCodeSet { RmsCodeSetId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), CreatedOn = now, CreatedByUserId = userId, RowVersion = 1 }; + else entity.RowVersion++; + entity.Name = RecordsPreventionGate.Require(input.Name, 200, "A code set needs a name."); + entity.Edition = RecordsPreventionGate.Trim(input.Edition, 100); entity.Jurisdiction = RecordsPreventionGate.Trim(input.Jurisdiction, 200); + entity.IsActive = input.IsActive; entity.ModifiedOn = now; + if (isNew) await _codeSets.InsertAsync(entity, cancellationToken, true); else await _codeSets.UpdateAsync(entity, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, isNew ? "Code set created" : "Code set updated", entity.RmsCodeSetId, new { entity.Name, entity.Edition }, cancellationToken: cancellationToken); + return entity; + } + + public async Task> GetCodeSectionsAsync(int departmentId, string userId, string codeSetId) + { + await RequireViewAsync(departmentId, userId); + return (await _codeSections.GetForCodeSetAsync(departmentId, codeSetId))?.ToList() ?? new List(); + } + + public async Task SaveCodeSectionAsync(int departmentId, string userId, RmsCodeSection input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var set = await _codeSets.GetByIdForDepartmentAsync(departmentId, input.RmsCodeSetId); + if (set == null || set.DeletedOn != null) throw new ArgumentException("The code set does not exist."); + var now = DateTime.UtcNow; + var entity = string.IsNullOrWhiteSpace(input.RmsCodeSectionId) ? null : await _codeSections.GetByIdForDepartmentAsync(departmentId, input.RmsCodeSectionId); + var isNew = entity == null; + if (isNew) entity = new RmsCodeSection { RmsCodeSectionId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsCodeSetId = set.RmsCodeSetId, CreatedOn = now, RowVersion = 1 }; + else entity.RowVersion++; + entity.SectionNumber = RecordsPreventionGate.Require(input.SectionNumber, 64, "A code section needs a section number."); + entity.Title = RecordsPreventionGate.Trim(input.Title, 300); entity.Text = RecordsPreventionGate.Trim(input.Text, 8000); + entity.DefaultSeverity = Math.Clamp(input.DefaultSeverity == 0 ? (int)RmsViolationSeverity.Moderate : input.DefaultSeverity, 1, 4); + entity.DefaultCorrectionDays = Math.Clamp(input.DefaultCorrectionDays <= 0 ? 30 : input.DefaultCorrectionDays, 1, 365); + entity.ModifiedOn = now; + if (isNew) await _codeSections.InsertAsync(entity, cancellationToken, true); else await _codeSections.UpdateAsync(entity, cancellationToken, true); + return entity; + } + + public async Task ImportCodeSectionsAsync(int departmentId, string userId, string codeSetId, string csv, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var set = await _codeSets.GetByIdForDepartmentAsync(departmentId, codeSetId); + if (set == null || set.DeletedOn != null) throw new ArgumentException("The code set does not exist."); + if (string.IsNullOrWhiteSpace(csv)) throw new ArgumentException("The import file is empty."); + var existing = ((await _codeSections.GetForCodeSetAsync(departmentId, codeSetId)) ?? Enumerable.Empty()).Select(s => s.SectionNumber).ToHashSet(StringComparer.OrdinalIgnoreCase); + var created = 0; var now = DateTime.UtcNow; + foreach (var line in csv.Split('\n').Select(l => l.TrimEnd('\r')).Where(l => !string.IsNullOrWhiteSpace(l))) + { + var cells = CsvSplit(line); + if (cells.Count == 0 || cells[0].Equals("section", StringComparison.OrdinalIgnoreCase)) continue; + var number = cells[0].Trim(); + if (number.Length == 0 || number.Length > 64 || existing.Contains(number)) continue; + int.TryParse(cells.Count > 3 ? cells[3] : "", out var severity); + int.TryParse(cells.Count > 4 ? cells[4] : "", out var days); + await _codeSections.InsertAsync(new RmsCodeSection + { + RmsCodeSectionId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsCodeSetId = codeSetId, SectionNumber = number, + Title = RecordsPreventionGate.Trim(cells.Count > 1 ? cells[1] : null, 300), Text = RecordsPreventionGate.Trim(cells.Count > 2 ? cells[2] : null, 8000), + DefaultSeverity = Math.Clamp(severity == 0 ? 2 : severity, 1, 4), DefaultCorrectionDays = Math.Clamp(days <= 0 ? 30 : days, 1, 365), CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }, cancellationToken, true); + existing.Add(number); created++; + if (created >= 5000) break; + } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, "Code sections imported", codeSetId, new { created }, cancellationToken: cancellationToken); + return created; + } + + public static List CsvSplit(string line) + { + var cells = new List(); var current = new System.Text.StringBuilder(); var quoted = false; + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + if (quoted) + { + if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; } + else if (c == '"') quoted = false; + else current.Append(c); + } + else if (c == '"') quoted = true; + else if (c == ',') { cells.Add(current.ToString()); current.Clear(); } + else current.Append(c); + } + cells.Add(current.ToString()); + return cells; + } + + #endregion + + #region Programs + + public async Task> GetProgramsAsync(int departmentId, string userId, bool includeInactive) + { + await RequireViewAsync(departmentId, userId); + return (await _programs.GetForDepartmentAsync(departmentId, includeInactive))?.ToList() ?? new List(); + } + + public async Task GetProgramAsync(int departmentId, string userId, string programId) + { + await RequireViewAsync(departmentId, userId); + var program = await _programs.GetByIdForDepartmentAsync(departmentId, programId); + return program == null || program.DeletedOn != null ? null : program; + } + + public async Task SaveProgramAsync(int departmentId, string userId, RmsInspectionProgram input, List checklist, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var now = DateTime.UtcNow; + var entity = string.IsNullOrWhiteSpace(input.RmsInspectionProgramId) ? null : await _programs.GetByIdForDepartmentAsync(departmentId, input.RmsInspectionProgramId); + var isNew = entity == null; + if (isNew) entity = new RmsInspectionProgram { RmsInspectionProgramId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), CreatedOn = now, CreatedByUserId = userId, RowVersion = 1 }; + else entity.RowVersion++; + entity.Name = RecordsPreventionGate.Require(input.Name, 200, "An inspection program needs a name."); + entity.Description = RecordsPreventionGate.Trim(input.Description, 4000); + entity.OccupancyTypesCsv = RecordsPreventionGate.Trim(input.OccupancyTypesCsv, 500); + entity.FrequencyMonths = Math.Clamp(input.FrequencyMonths, 0, 120); + entity.RmsCodeSetId = RecordsPreventionGate.Trim(input.RmsCodeSetId, 36); + entity.IsActive = input.IsActive; entity.ModifiedOn = now; + var items = (checklist ?? new List()).Where(i => i != null && !string.IsNullOrWhiteSpace(i.Text)).Select((i, index) => new RmsInspectionChecklistItem + { Key = string.IsNullOrWhiteSpace(i.Key) ? $"item{index + 1}" : i.Key.Trim(), Text = i.Text.Trim(), RmsCodeSectionId = RecordsPreventionGate.Trim(i.RmsCodeSectionId, 36), Required = i.Required, Order = index }).ToList(); + if (items.Select(i => i.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != items.Count) throw new ArgumentException("Checklist item keys must be unique."); + if (items.Count > 300) throw new ArgumentException("A checklist holds at most 300 items."); + entity.ChecklistJson = JsonConvert.SerializeObject(items); + if (isNew) await _programs.InsertAsync(entity, cancellationToken, true); else await _programs.UpdateAsync(entity, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, isNew ? "Inspection program created" : "Inspection program updated", entity.RmsInspectionProgramId, new { entity.Name, items = items.Count, entity.FrequencyMonths }, cancellationToken: cancellationToken); + return entity; + } + + public static List ParseChecklist(string json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try { return JsonConvert.DeserializeObject>(json) ?? new List(); } + catch (JsonException) { return new List(); } + } + + public static List ParseItems(string json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try { return JsonConvert.DeserializeObject>(json) ?? new List(); } + catch (JsonException) { return new List(); } + } + + #endregion + + #region Inspections + + public async Task> ListAsync(int departmentId, string userId, RmsInspectionQuery query) + { + await RequireViewAsync(departmentId, userId); + var rows = (await _inspections.QueryAsync(departmentId, query ?? new RmsInspectionQuery()))?.ToList() ?? new List(); + await _protection.RevealInspectionsAsync(departmentId, rows); + return rows; + } + + public async Task CountAsync(int departmentId, string userId, RmsInspectionQuery query) + { + await RequireViewAsync(departmentId, userId); + return await _inspections.CountAsync(departmentId, query ?? new RmsInspectionQuery()); + } + + public async Task GetAsync(int departmentId, string userId, string inspectionId) + { + await RequireViewAsync(departmentId, userId); + var inspection = await LiveAsync(departmentId, inspectionId); + if (inspection == null) return null; + var aggregate = new InspectionAggregate + { + Inspection = inspection, + Program = string.IsNullOrWhiteSpace(inspection.RmsInspectionProgramId) ? null : await _programs.GetByIdForDepartmentAsync(departmentId, inspection.RmsInspectionProgramId), + Occupancy = await _occupancies.GetByIdForDepartmentAsync(departmentId, inspection.RmsOccupancyId), + Items = ParseItems(inspection.ItemsJson), + Violations = (await _violations.GetForInspectionAsync(departmentId, inspectionId))?.ToList() ?? new List(), + Attachments = (await _attachments.GetMetadataForParentAsync(departmentId, RmsPreventionParentKind.Inspection, inspectionId))?.ToList() ?? new List() + }; + aggregate.Checklist = ParseChecklist(aggregate.Program?.ChecklistJson); + aggregate.Protection.Merge(await _protection.RevealInspectionsAsync(departmentId, new[] { inspection })); + aggregate.Protection.Merge(await _protection.RevealViolationsAsync(departmentId, aggregate.Violations)); + aggregate.Protection.Merge(await _protection.RevealPreventionAttachmentsAsync(departmentId, aggregate.Attachments, false)); + return aggregate; + } + + public async Task ScheduleAsync(int departmentId, string userId, string occupancyId, string programId, DateTime scheduledOn, string inspectorUserId, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var occupancy = await _occupancies.GetByIdForDepartmentAsync(departmentId, occupancyId); + if (occupancy == null || occupancy.DeletedOn != null) throw new ArgumentException("The occupancy does not exist."); + RmsInspectionProgram program = null; + if (!string.IsNullOrWhiteSpace(programId)) + { + program = await _programs.GetByIdForDepartmentAsync(departmentId, programId); + if (program == null || program.DeletedOn != null) throw new ArgumentException("The inspection program does not exist."); + } + var inspection = await CreateScheduledAsync(departmentId, userId, occupancy, program, scheduledOn, inspectorUserId, null, cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Inspection scheduled", inspection.RmsInspectionId, new { inspection.InspectionNumber, occupancyId, programId, scheduledOn }, cancellationToken: cancellationToken); + return inspection; + } + + private async Task CreateScheduledAsync(int departmentId, string userId, RmsOccupancy occupancy, RmsInspectionProgram program, DateTime scheduledOn, string inspectorUserId, string parentInspectionId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var inspection = new RmsInspection + { + RmsInspectionId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsOccupancyId = occupancy.RmsOccupancyId, RmsInspectionProgramId = program?.RmsInspectionProgramId, + InspectionNumber = await _gate.NextNumberAsync(departmentId, RmsPreventionNumberKinds.Inspection, now, cancellationToken), State = (int)RmsInspectionState.Scheduled, Result = (int)RmsInspectionResult.NotRecorded, + ScheduledOn = scheduledOn == default ? now : scheduledOn, InspectorUserId = RecordsPreventionGate.Trim(inspectorUserId, 128), ParentInspectionId = parentInspectionId, + CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, RowVersion = 1 + }; + await _inspections.InsertAsync(inspection, cancellationToken, true); + return inspection; + } + + public async Task StartAsync(int departmentId, string userId, string inspectionId, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var inspection = await LiveAsync(departmentId, inspectionId) ?? throw new ArgumentException("The inspection does not exist."); + if (inspection.State != (int)RmsInspectionState.Scheduled) throw new InvalidOperationException("Only a scheduled inspection can be started."); + inspection.State = (int)RmsInspectionState.InProgress; inspection.StartedOn = DateTime.UtcNow; inspection.InspectorUserId ??= userId; inspection.ModifiedOn = inspection.StartedOn.Value; inspection.RowVersion++; + await _inspections.UpdateAsync(inspection, cancellationToken, true); + return inspection; + } + + public async Task CompleteAsync(int departmentId, string userId, string inspectionId, List items, string notes, string signatureName, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var inspection = await LiveAsync(departmentId, inspectionId) ?? throw new ArgumentException("The inspection does not exist."); + if (inspection.State != (int)RmsInspectionState.Scheduled && inspection.State != (int)RmsInspectionState.InProgress) throw new InvalidOperationException("The inspection is not open."); + var occupancy = await _occupancies.GetByIdForDepartmentAsync(departmentId, inspection.RmsOccupancyId) ?? throw new InvalidOperationException("The occupancy no longer exists."); + var program = string.IsNullOrWhiteSpace(inspection.RmsInspectionProgramId) ? null : await _programs.GetByIdForDepartmentAsync(departmentId, inspection.RmsInspectionProgramId); + var checklist = ParseChecklist(program?.ChecklistJson); + items = (items ?? new List()).Where(i => i != null && !string.IsNullOrWhiteSpace(i.Key)).ToList(); + var missingRequired = checklist.Where(c => c.Required && !items.Any(i => i.Key == c.Key && i.Passed.HasValue)).Select(c => c.Key).ToList(); + if (missingRequired.Count > 0) throw new ArgumentException($"Required checklist items were not inspected: {string.Join(", ", missingRequired)}."); + + var now = DateTime.UtcNow; + var failed = items.Where(i => i.Passed == false).ToList(); + var failedRequired = failed.Any(f => checklist.Any(c => c.Key == f.Key && c.Required)); + var sectionIds = checklist.Where(c => failed.Any(f => f.Key == c.Key) && !string.IsNullOrWhiteSpace(c.RmsCodeSectionId)).Select(c => c.RmsCodeSectionId).Distinct().ToList(); + var sections = sectionIds.Count == 0 ? new Dictionary() : ((await _codeSections.GetByIdsAsync(departmentId, sectionIds)) ?? Enumerable.Empty()).ToDictionary(s => s.RmsCodeSectionId); + + inspection.ItemsJson = JsonConvert.SerializeObject(items); + inspection.Notes = RecordsPreventionGate.Trim(notes, 8000); + inspection.SignatureName = RecordsPreventionGate.Trim(signatureName, 200); + inspection.SignedOn = inspection.SignatureName == null ? null : now; + inspection.CompletedOn = now; inspection.StartedOn ??= now; inspection.InspectorUserId ??= userId; + inspection.Result = failed.Count == 0 ? (int)RmsInspectionResult.Pass : failedRequired ? (int)RmsInspectionResult.Fail : (int)RmsInspectionResult.Conditional; + inspection.State = failed.Count == 0 ? (int)RmsInspectionState.Completed : (int)RmsInspectionState.ReinspectionRequired; + inspection.ModifiedOn = now; inspection.RowVersion++; + + var violations = new List(); + foreach (var item in failed) + { + var definition = checklist.FirstOrDefault(c => c.Key == item.Key); + RmsCodeSection section = definition?.RmsCodeSectionId != null && sections.TryGetValue(definition.RmsCodeSectionId, out var s) ? s : null; + violations.Add(new RmsViolation + { + RmsViolationId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInspectionId = inspection.RmsInspectionId, RmsOccupancyId = occupancy.RmsOccupancyId, + RmsCodeSetId = section?.RmsCodeSetId ?? program?.RmsCodeSetId, RmsCodeSectionId = section?.RmsCodeSectionId, ChecklistItemKey = item.Key, + Description = RecordsPreventionGate.Trim(string.IsNullOrWhiteSpace(item.Note) ? definition?.Text : item.Note, 4000) ?? "Checklist item failed", + Severity = section?.DefaultSeverity ?? (int)RmsViolationSeverity.Moderate, CorrectiveAction = section?.Title == null ? null : $"Correct per {section.SectionNumber} {section.Title}", + DueOn = now.AddDays(section?.DefaultCorrectionDays ?? 30), State = (int)RmsViolationState.Open, CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, RowVersion = 1 + }); + } + + var inspectionPlain = PlaintextSnapshot.Take(inspection, RmsProtectedFields.Inspections); + var violationPlain = violations.Select(v => PlaintextSnapshot.Take(v, RmsProtectedFields.Violations)).ToList(); + long outboxId; + _unitOfWork.CreateOrGetConnection(); + try + { + await _protection.ProtectInspectionAsync(departmentId, inspection, null, userId, cancellationToken); + await _inspections.UpdateAsync(inspection, cancellationToken, true); + foreach (var violation in violations) + { + await _protection.ProtectViolationAsync(departmentId, violation, null, userId, cancellationToken); + await _violations.InsertAsync(violation, cancellationToken, true); + } + occupancy.LastInspectedOn = now; occupancy.ModifiedOn = now; occupancy.RowVersion++; + await _occupancies.UpdateAsync(occupancy, cancellationToken, true); + outboxId = await EnqueueInspectionAsync(inspection, occupancy, program, violations.Count, violations.Count(v => v.Severity == (int)RmsViolationSeverity.Critical), cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Inspection completed", inspection.RmsInspectionId, new { inspection.InspectionNumber, result = inspection.Result, violations = violations.Count }, cancellationToken: cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + inspectionPlain.Restore(); + foreach (var p in violationPlain) p.Restore(); + await _outbox.DispatchAfterCommitAsync(new[] { outboxId }, cancellationToken); + + return new InspectionAggregate { Inspection = inspection, Program = program, Occupancy = occupancy, Checklist = checklist, Items = items, Violations = violations }; + } + + public async Task ScheduleReinspectionAsync(int departmentId, string userId, string inspectionId, DateTime scheduledOn, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var parent = await LiveAsync(departmentId, inspectionId) ?? throw new ArgumentException("The inspection does not exist."); + if (parent.State != (int)RmsInspectionState.ReinspectionRequired && parent.State != (int)RmsInspectionState.Completed) throw new InvalidOperationException("Only a completed inspection can be re-inspected."); + var occupancy = await _occupancies.GetByIdForDepartmentAsync(departmentId, parent.RmsOccupancyId) ?? throw new InvalidOperationException("The occupancy no longer exists."); + var program = string.IsNullOrWhiteSpace(parent.RmsInspectionProgramId) ? null : await _programs.GetByIdForDepartmentAsync(departmentId, parent.RmsInspectionProgramId); + var child = await CreateScheduledAsync(departmentId, userId, occupancy, program, scheduledOn, parent.InspectorUserId, parent.RmsInspectionId, cancellationToken); + foreach (var violation in ((await _violations.GetForInspectionAsync(departmentId, inspectionId)) ?? Enumerable.Empty()).Where(v => v.IsOpen)) + { violation.ReinspectionId = child.RmsInspectionId; violation.ModifiedOn = DateTime.UtcNow; violation.RowVersion++; await _violations.UpdateAsync(violation, cancellationToken, true); } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Re-inspection scheduled", child.RmsInspectionId, new { parent = parent.RmsInspectionId, scheduledOn }, cancellationToken: cancellationToken); + return child; + } + + public async Task CloseAsync(int departmentId, string userId, string inspectionId, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var inspection = await LiveAsync(departmentId, inspectionId) ?? throw new ArgumentException("The inspection does not exist."); + if (inspection.State != (int)RmsInspectionState.Completed && inspection.State != (int)RmsInspectionState.ReinspectionRequired) throw new InvalidOperationException("Only a completed inspection can be closed."); + var open = ((await _violations.GetForInspectionAsync(departmentId, inspectionId)) ?? Enumerable.Empty()).Count(v => v.IsOpen || v.State == (int)RmsViolationState.Corrected); + if (open > 0) throw new InvalidOperationException($"{open} violation(s) are not yet verified or waived."); + inspection.State = (int)RmsInspectionState.Closed; inspection.ModifiedOn = DateTime.UtcNow; inspection.RowVersion++; + await _inspections.UpdateAsync(inspection, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Inspection closed", inspectionId, null, cancellationToken: cancellationToken); + return inspection; + } + + public async Task CancelAsync(int departmentId, string userId, string inspectionId, string reason, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var inspection = await LiveAsync(departmentId, inspectionId) ?? throw new ArgumentException("The inspection does not exist."); + if (inspection.State != (int)RmsInspectionState.Scheduled && inspection.State != (int)RmsInspectionState.InProgress) throw new InvalidOperationException("Only an open inspection can be cancelled."); + inspection.State = (int)RmsInspectionState.Cancelled; inspection.ModifiedOn = DateTime.UtcNow; inspection.RowVersion++; + await _inspections.UpdateAsync(inspection, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Inspection cancelled", inspectionId, new { reason = RecordsPreventionGate.Trim(reason, 500) }, cancellationToken: cancellationToken); + return inspection; + } + + public async Task IssueNoticeAsync(int departmentId, string userId, string inspectionId, string noticeReference, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var inspection = await LiveAsync(departmentId, inspectionId) ?? throw new ArgumentException("The inspection does not exist."); + if (inspection.CompletedOn == null) throw new InvalidOperationException("A notice follows a completed inspection."); + inspection.NoticeIssuedOn = DateTime.UtcNow; inspection.NoticeReference = RecordsPreventionGate.Trim(noticeReference, 100); inspection.ModifiedOn = inspection.NoticeIssuedOn.Value; inspection.RowVersion++; + await _inspections.UpdateAsync(inspection, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Notice of violation issued", inspectionId, new { inspection.NoticeReference }, cancellationToken: cancellationToken); + return inspection; + } + + private async Task LiveAsync(int departmentId, string inspectionId) + { + if (string.IsNullOrWhiteSpace(inspectionId)) return null; + var inspection = await _inspections.GetByIdForDepartmentAsync(departmentId, inspectionId); + return inspection == null || inspection.DeletedOn != null ? null : inspection; + } + + #endregion + + #region Violations + + public async Task> GetViolationsForOccupancyAsync(int departmentId, string userId, string occupancyId, bool openOnly) + { + await RequireViewAsync(departmentId, userId); + var rows = (await _violations.GetForOccupancyAsync(departmentId, occupancyId, openOnly))?.ToList() ?? new List(); + await _protection.RevealViolationsAsync(departmentId, rows); + return rows; + } + + public async Task> GetOpenViolationsAsync(int departmentId, string userId, int take) + { + await RequireViewAsync(departmentId, userId); + var rows = (await _violations.GetOpenAsync(departmentId, take))?.ToList() ?? new List(); + await _protection.RevealViolationsAsync(departmentId, rows); + return rows; + } + + public async Task SaveViolationAsync(int departmentId, string userId, RmsViolation input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var inspection = await LiveAsync(departmentId, input.RmsInspectionId) ?? throw new ArgumentException("The inspection does not exist."); + var now = DateTime.UtcNow; + var existing = string.IsNullOrWhiteSpace(input.RmsViolationId) ? null : await _violations.GetByIdForDepartmentAsync(departmentId, input.RmsViolationId); + var entity = existing ?? new RmsViolation { RmsViolationId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInspectionId = inspection.RmsInspectionId, RmsOccupancyId = inspection.RmsOccupancyId, State = (int)RmsViolationState.Open, CreatedOn = now, CreatedByUserId = userId, RowVersion = 0 }; + entity.RmsCodeSetId = RecordsPreventionGate.Trim(input.RmsCodeSetId, 36); entity.RmsCodeSectionId = RecordsPreventionGate.Trim(input.RmsCodeSectionId, 36); + entity.Description = RecordsPreventionGate.Require(input.Description, 4000, "A violation needs a description."); + entity.Severity = Math.Clamp(input.Severity == 0 ? 2 : input.Severity, 1, 4); entity.CorrectiveAction = RecordsPreventionGate.Trim(input.CorrectiveAction, 4000); + entity.DueOn = input.DueOn ?? entity.DueOn ?? now.AddDays(30); entity.ModifiedOn = now; entity.RowVersion++; + var plaintext = PlaintextSnapshot.Take(entity, RmsProtectedFields.Violations); + await _protection.ProtectViolationAsync(departmentId, entity, existing, userId, cancellationToken); + if (existing == null) await _violations.InsertAsync(entity, cancellationToken, true); else await _violations.UpdateAsync(entity, cancellationToken, true); + plaintext.Restore(); + if (existing == null && inspection.State == (int)RmsInspectionState.Completed) + { inspection.State = (int)RmsInspectionState.ReinspectionRequired; inspection.ModifiedOn = now; inspection.RowVersion++; await _inspections.UpdateAsync(inspection, cancellationToken, true); } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, existing == null ? "Violation recorded" : "Violation updated", inspection.RmsInspectionId, new { entity.RmsViolationId, entity.Severity, entity.DueOn }, cancellationToken: cancellationToken); + return entity; + } + + public async Task TransitionViolationAsync(int departmentId, string userId, string violationId, RmsViolationState target, string note, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var violation = await _violations.GetByIdForDepartmentAsync(departmentId, violationId); + if (violation == null || violation.DeletedOn != null) throw new ArgumentException("The violation does not exist."); + var current = (RmsViolationState)violation.State; + var allowed = (current, target) switch + { + (RmsViolationState.Open, RmsViolationState.Corrected) => true, + (RmsViolationState.Open, RmsViolationState.Escalated) => true, + (RmsViolationState.Open, RmsViolationState.Waived) => true, + (RmsViolationState.Escalated, RmsViolationState.Corrected) => true, + (RmsViolationState.Escalated, RmsViolationState.Waived) => true, + (RmsViolationState.Corrected, RmsViolationState.Verified) => true, + (RmsViolationState.Corrected, RmsViolationState.Open) => true, + _ => false + }; + if (!allowed) throw new InvalidOperationException($"A violation cannot move from {current} to {target}."); + if (target == RmsViolationState.Waived && string.IsNullOrWhiteSpace(note)) throw new ArgumentException("Waiving a violation needs a reason."); + var now = DateTime.UtcNow; + violation.State = (int)target; + if (target == RmsViolationState.Corrected) violation.CorrectedOn = now; + if (target == RmsViolationState.Verified) { violation.VerifiedOn = now; violation.VerifiedByUserId = userId; } + if (target == RmsViolationState.Open) { violation.CorrectedOn = null; } + violation.ModifiedOn = now; violation.RowVersion++; + await _violations.UpdateAsync(violation, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, $"Violation {target}", violation.RmsInspectionId, new { violationId, from = current.ToString(), to = target.ToString(), note = RecordsPreventionGate.Trim(note, 1000) }, cancellationToken: cancellationToken); + return violation; + } + + #endregion + + #region Sweeps + + public async Task GenerateDueInspectionsAsync(int departmentId, DateTime utcNow, CancellationToken cancellationToken = default) + { + if (!await IsModuleEnabledAsync(departmentId)) return 0; + var generated = 0; + var programs = ((await _programs.GetForDepartmentAsync(departmentId, false)) ?? Enumerable.Empty()).Where(p => p.FrequencyMonths > 0).ToList(); + if (programs.Count == 0) return 0; + var occupancies = ((await _occupancies.GetAllLiveAsync(departmentId)) ?? Enumerable.Empty()).Where(o => o.Status == (int)RmsOccupancyStatus.Active).ToList(); + foreach (var program in programs) + { + var types = (program.OccupancyTypesCsv ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries).Select(t => int.TryParse(t.Trim(), out var v) ? v : -1).Where(v => v >= 0).ToHashSet(); + var lastCompleted = await _inspections.GetLastCompletedByOccupancyAsync(departmentId, program.RmsInspectionProgramId); + var open = ((await _inspections.GetOpenForProgramAsync(departmentId, program.RmsInspectionProgramId)) ?? Enumerable.Empty()).Select(i => i.RmsOccupancyId).ToHashSet(StringComparer.Ordinal); + foreach (var occupancy in occupancies) + { + if (types.Count > 0 && !types.Contains(occupancy.OccupancyType)) continue; + if (open.Contains(occupancy.RmsOccupancyId)) continue; + var due = !lastCompleted.TryGetValue(occupancy.RmsOccupancyId, out var last) || last.AddMonths(program.FrequencyMonths) <= utcNow; + if (!due) continue; + await CreateScheduledAsync(departmentId, null, occupancy, program, utcNow, null, null, cancellationToken); + generated++; + if (generated >= SweepBatch) return generated; + } + } + return generated; + } + + public async Task EmitOverdueViolationsAsync(int departmentId, DateTime utcNow, CancellationToken cancellationToken = default) + { + if (!await IsModuleEnabledAsync(departmentId)) return 0; + var emitted = 0; + var overdue = (await _violations.GetOverdueNotEmittedAsync(departmentId, utcNow, SweepBatch))?.ToList() ?? new List(); + foreach (var violation in overdue) + { + long outboxId; + _unitOfWork.CreateOrGetConnection(); + try + { + violation.OverdueEmittedOn = utcNow; violation.ModifiedOn = utcNow; violation.RowVersion++; + await _violations.UpdateAsync(violation, cancellationToken, true); + outboxId = await EnqueueViolationAsync(violation, utcNow, cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + await _outbox.DispatchAfterCommitAsync(new[] { outboxId }, cancellationToken); + emitted++; + } + return emitted; + } + + #endregion + + #region Events + + /// inspection.* (trigger 161): identity, program, result and violation counts — never the notes or the signature. + private async Task EnqueueInspectionAsync(RmsInspection inspection, RmsOccupancy occupancy, RmsInspectionProgram program, int violationCount, int criticalCount, CancellationToken cancellationToken) + { + var entry = await _outbox.EnqueueAsync(inspection.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = WorkflowTriggerEventType.RecordInspectionCompleted.ToString(), SchemaVersion = 1, AggregateType = InspectionAggregate, AggregateId = inspection.RmsInspectionId, AggregateVersion = (int)inspection.RowVersion, + Trigger = WorkflowTriggerEventType.RecordInspectionCompleted, + Payload = new Dictionary + { + ["record"] = PreventionRecordBlock(inspection.DepartmentId), + ["inspection"] = new + { + id = inspection.RmsInspectionId, number = inspection.InspectionNumber, occupancy_id = occupancy.RmsOccupancyId, occupancy_number = occupancy.OccupancyNumber, occupancy_name = occupancy.Name, + program_id = program?.RmsInspectionProgramId, program_name = program?.Name, state = ((RmsInspectionState)inspection.State).ToString(), result = ((RmsInspectionResult)inspection.Result).ToString(), + scheduled_on = inspection.ScheduledOn, completed_on = inspection.CompletedOn, inspector_user_id = inspection.InspectorUserId, violation_count = violationCount, critical_violation_count = criticalCount, + is_reinspection = inspection.ParentInspectionId != null + }, + ["protection"] = IncidentReportsService.ProtectionBlock(await _protection.GetCatalogVersionAsync(inspection.DepartmentId)) + }, + CorrelationId = inspection.RmsInspectionId, OriginClient = RmsOriginClient.Web + }, cancellationToken); + return entry.DomainEventOutboxId; + } + + /// violation.* (trigger 162): identity, citation, severity and lateness — never the description or corrective text. + private async Task EnqueueViolationAsync(RmsViolation violation, DateTime utcNow, CancellationToken cancellationToken) + { + RmsCodeSection section = violation.RmsCodeSectionId == null ? null : await _codeSections.GetByIdForDepartmentAsync(violation.DepartmentId, violation.RmsCodeSectionId); + var occupancy = await _occupancies.GetByIdForDepartmentAsync(violation.DepartmentId, violation.RmsOccupancyId); + var entry = await _outbox.EnqueueAsync(violation.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = WorkflowTriggerEventType.RecordViolationOverdue.ToString(), SchemaVersion = 1, AggregateType = ViolationAggregate, AggregateId = violation.RmsViolationId, AggregateVersion = (int)violation.RowVersion, + Trigger = WorkflowTriggerEventType.RecordViolationOverdue, + Payload = new Dictionary + { + ["record"] = PreventionRecordBlock(violation.DepartmentId), + ["violation"] = new + { + id = violation.RmsViolationId, inspection_id = violation.RmsInspectionId, occupancy_id = violation.RmsOccupancyId, occupancy_number = occupancy?.OccupancyNumber, occupancy_name = occupancy?.Name, + code_set_id = violation.RmsCodeSetId, code_section_id = violation.RmsCodeSectionId, code_section_number = section?.SectionNumber, severity = ((RmsViolationSeverity)violation.Severity).ToString(), + state = ((RmsViolationState)violation.State).ToString(), due_on = violation.DueOn, days_overdue = violation.DueOn.HasValue ? (int)Math.Floor((utcNow - violation.DueOn.Value).TotalDays) : 0 + }, + ["protection"] = IncidentReportsService.ProtectionBlock(await _protection.GetCatalogVersionAsync(violation.DepartmentId)) + }, + CorrelationId = violation.RmsViolationId, OriginClient = RmsOriginClient.System + }, cancellationToken); + return entry.DomainEventOutboxId; + } + + /// Prevention events are not Records; the record block carries only the kind and department so templates keep a stable namespace. + public static object PreventionRecordBlock(int departmentId) => new { id = (string)null, kind = "Prevention", department_id = departmentId, state = (string)null }; + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs new file mode 100644 index 000000000..431099d3f --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs @@ -0,0 +1,497 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Investigation cases (RMS plan section 4.4, RMS-5). Case-level authorization sits on top of RecordRestricted_View: + /// every read and change requires an active membership, and department administrator status is not a bypass (plan + /// section 5.7). Every read of a case is audited; every export is audited; custody is append-only; findings never + /// touch the linked incident revision. + /// + public class RecordsInvestigationsService : IRecordsInvestigationsService + { + public const string AuditPurpose = "investigation"; + + private readonly RecordsPreventionGate _gate; + private readonly IRmsInvestigationCasesRepository _cases; + private readonly IRmsInvestigationCaseIncidentsRepository _incidents; + private readonly IRmsInvestigationCaseMembersRepository _members; + private readonly IRmsInvestigationNotesRepository _notes; + private readonly IRmsInvestigationEvidenceRepository _evidence; + private readonly IRmsInvestigationCustodyRepository _custody; + private readonly IRmsInvestigationReferralsRepository _referrals; + private readonly IRmsPreventionAttachmentsRepository _attachments; + private readonly IRmsIncidentReportsRepository _reports; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsProtectionService _protection; + private readonly IUnitOfWork _unitOfWork; + + public RecordsInvestigationsService(RecordsPreventionGate gate, IRmsInvestigationCasesRepository cases, IRmsInvestigationCaseIncidentsRepository incidents, IRmsInvestigationCaseMembersRepository members, + IRmsInvestigationNotesRepository notes, IRmsInvestigationEvidenceRepository evidence, IRmsInvestigationCustodyRepository custody, IRmsInvestigationReferralsRepository referrals, + IRmsPreventionAttachmentsRepository attachments, IRmsIncidentReportsRepository reports, IRmsAccessAuditsRepository audits, IRecordsAuthorizationService authorization, + IRecordsProtectionService protection, IUnitOfWork unitOfWork) + { + _gate = gate; _cases = cases; _incidents = incidents; _members = members; _notes = notes; _evidence = evidence; _custody = custody; _referrals = referrals; + _attachments = attachments; _reports = reports; _audits = audits; _authorization = authorization; _protection = protection; _unitOfWork = unitOfWork; + } + + public Task IsModuleEnabledAsync(int departmentId) => _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Investigations); + + #region Authorization + + private async Task<(RmsInvestigationCase Case, RmsInvestigationRole Role)> RequireMemberAsync(int departmentId, string userId, string caseId, params RmsInvestigationRole[] allowedRoles) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Investigations); + await _gate.RequireRestrictedAsync(departmentId, userId); + var investigation = await _cases.GetByIdForDepartmentAsync(departmentId, caseId); + if (investigation == null || investigation.DeletedOn != null) throw new ArgumentException("The case does not exist."); + var membership = ((await _members.GetForCaseAsync(departmentId, caseId)) ?? Enumerable.Empty()).FirstOrDefault(m => m.UserId == userId && m.IsActive); + if (membership == null) + { + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Denied, AuditPurpose, caseId, new { reason = "not a case member" }, successful: false); + throw new UnauthorizedAccessException("Only members of the case may access it."); + } + var role = (RmsInvestigationRole)membership.Role; + if (allowedRoles != null && allowedRoles.Length > 0 && !allowedRoles.Contains(role)) + { + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Denied, AuditPurpose, caseId, new { reason = $"role {role} may not perform this action" }, successful: false); + throw new UnauthorizedAccessException($"This action needs the {string.Join(" or ", allowedRoles)} role on the case."); + } + return (investigation, role); + } + + private static void RequireOpen(RmsInvestigationCase investigation) + { + if (investigation.IsClosed) throw new InvalidOperationException("The case is closed; reopen it before making changes."); + } + + #endregion + + #region Cases + + public async Task> ListMyCasesAsync(int departmentId, string userId, bool includeClosed) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Investigations); + await _gate.RequireRestrictedAsync(departmentId, userId); + var memberships = (await _members.GetActiveForUserAsync(departmentId, userId))?.Select(m => m.RmsInvestigationCaseId).Distinct().ToList() ?? new List(); + if (memberships.Count == 0) return new List(); + var cases = ((await _cases.GetByIdsAsync(departmentId, memberships)) ?? Enumerable.Empty()).Where(c => includeClosed || !c.IsClosed).ToList(); + await _protection.RevealInvestigationCasesAsync(departmentId, cases); + return cases; + } + + public async Task GetAsync(int departmentId, string userId, string caseId, string ipAddress = null) + { + var (investigation, role) = await RequireMemberAsync(departmentId, userId, caseId); + var aggregate = new InvestigationCaseAggregate + { + Case = investigation, CallerRole = role, + Members = (await _members.GetForCaseAsync(departmentId, caseId))?.ToList() ?? new List(), + Incidents = (await _incidents.GetForCaseAsync(departmentId, caseId))?.ToList() ?? new List(), + Notes = (await _notes.GetForCaseAsync(departmentId, caseId))?.ToList() ?? new List(), + Evidence = (await _evidence.GetForCaseAsync(departmentId, caseId))?.ToList() ?? new List(), + Referrals = (await _referrals.GetForCaseAsync(departmentId, caseId))?.ToList() ?? new List(), + Attachments = (await _attachments.GetMetadataForParentAsync(departmentId, RmsPreventionParentKind.InvestigationCase, caseId))?.ToList() ?? new List() + }; + aggregate.Protection.Merge(await _protection.RevealInvestigationCasesAsync(departmentId, new[] { investigation })); + aggregate.Protection.Merge(await _protection.RevealInvestigationNotesAsync(departmentId, aggregate.Notes)); + aggregate.Protection.Merge(await _protection.RevealInvestigationEvidenceAsync(departmentId, aggregate.Evidence)); + aggregate.Protection.Merge(await _protection.RevealInvestigationReferralsAsync(departmentId, aggregate.Referrals)); + aggregate.Protection.Merge(await _protection.RevealPreventionAttachmentsAsync(departmentId, aggregate.Attachments, false)); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Read, AuditPurpose, caseId, new { investigation.CaseNumber, role = role.ToString(), redacted = aggregate.Protection.RedactedFields.Count }, ipAddress: ipAddress); + return aggregate; + } + + public async Task OpenAsync(int departmentId, string userId, string title, string occupancyId, int? callId, string incidentSummary, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Investigations); + await _gate.RequireRestrictedAsync(departmentId, userId); + var now = DateTime.UtcNow; + var investigation = new RmsInvestigationCase + { + RmsInvestigationCaseId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), + CaseNumber = await _gate.NextNumberAsync(departmentId, RmsPreventionNumberKinds.Investigation, now, cancellationToken), + Title = RecordsPreventionGate.Require(title, 250, "A case needs a title."), State = (int)RmsInvestigationCaseState.Open, OpenedOn = now, OpenedByUserId = userId, LeadInvestigatorUserId = userId, + RmsOccupancyId = RecordsPreventionGate.Trim(occupancyId, 36), CallId = callId, IncidentSummary = RecordsPreventionGate.Trim(incidentSummary, 8000), + CauseClassification = (int)RmsFireCauseClassification.UnderInvestigation, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + var plaintext = PlaintextSnapshot.Take(investigation, RmsProtectedFields.InvestigationCases); + _unitOfWork.CreateOrGetConnection(); + try + { + await _protection.ProtectInvestigationCaseAsync(departmentId, investigation, null, userId, cancellationToken); + await _cases.InsertAsync(investigation, cancellationToken, true); + await _members.InsertAsync(new RmsInvestigationCaseMember { RmsInvestigationCaseMemberId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInvestigationCaseId = investigation.RmsInvestigationCaseId, UserId = userId, Role = (int)RmsInvestigationRole.Lead, AddedOn = now, AddedByUserId = userId }, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, AuditPurpose, investigation.RmsInvestigationCaseId, new { action = "opened", investigation.CaseNumber }, cancellationToken: cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + plaintext.Restore(); + return investigation; + } + + public async Task UpdateAsync(int departmentId, string userId, RmsInvestigationCase input, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + var (investigation, _) = await RequireMemberAsync(departmentId, userId, input.RmsInvestigationCaseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + RequireOpen(investigation); + if (input.RowVersion != 0 && input.RowVersion != investigation.RowVersion) throw new InvalidOperationException("The case changed since it was loaded. Reload it before saving."); + var existing = new RmsInvestigationCase { IncidentSummary = investigation.IncidentSummary, CauseDetail = investigation.CauseDetail, OriginDescription = investigation.OriginDescription, Findings = investigation.Findings, ClosureReason = investigation.ClosureReason }; + investigation.Title = RecordsPreventionGate.Require(input.Title, 250, "A case needs a title."); + investigation.IncidentSummary = RecordsPreventionGate.Trim(input.IncidentSummary, 8000); investigation.RmsOccupancyId = RecordsPreventionGate.Trim(input.RmsOccupancyId, 36); investigation.CallId = input.CallId; + if (!string.IsNullOrWhiteSpace(input.LeadInvestigatorUserId)) investigation.LeadInvestigatorUserId = input.LeadInvestigatorUserId.Trim(); + investigation.ModifiedOn = DateTime.UtcNow; investigation.RowVersion++; + await SealAndUpdateAsync(departmentId, userId, investigation, existing, cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, investigation.RmsInvestigationCaseId, new { action = "updated" }, cancellationToken: cancellationToken); + return investigation; + } + + private async Task SealAndUpdateAsync(int departmentId, string userId, RmsInvestigationCase investigation, RmsInvestigationCase existing, CancellationToken cancellationToken) + { + var plaintext = PlaintextSnapshot.Take(investigation, RmsProtectedFields.InvestigationCases); + await _protection.ProtectInvestigationCaseAsync(departmentId, investigation, existing, userId, cancellationToken); + await _cases.UpdateAsync(investigation, cancellationToken, true); + plaintext.Restore(); + } + + #endregion + + #region Members and incidents + + public async Task AddMemberAsync(int departmentId, string userId, string caseId, string memberUserId, RmsInvestigationRole role, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead); + RequireOpen(investigation); + if (string.IsNullOrWhiteSpace(memberUserId) || !await _authorization.IsActiveMemberAsync(memberUserId, departmentId)) throw new ArgumentException("The member must be an active member of the department."); + var members = (await _members.GetForCaseAsync(departmentId, caseId))?.ToList() ?? new List(); + var existing = members.FirstOrDefault(m => m.UserId == memberUserId && m.IsActive); + var now = DateTime.UtcNow; + if (existing != null) { existing.Role = (int)role; await _members.UpdateAsync(existing, cancellationToken, true); } + else + { + existing = new RmsInvestigationCaseMember { RmsInvestigationCaseMemberId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInvestigationCaseId = caseId, UserId = memberUserId, Role = (int)role, AddedOn = now, AddedByUserId = userId }; + await _members.InsertAsync(existing, cancellationToken, true); + } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, AuditPurpose, caseId, new { action = "member added", memberUserId, role = role.ToString() }, cancellationToken: cancellationToken); + return existing; + } + + public async Task RemoveMemberAsync(int departmentId, string userId, string caseId, string memberId, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead); + RequireOpen(investigation); + var member = await _members.GetByIdForDepartmentAsync(departmentId, memberId); + if (member == null || member.RmsInvestigationCaseId != caseId || !member.IsActive) throw new ArgumentException("The member does not exist on this case."); + var leads = ((await _members.GetForCaseAsync(departmentId, caseId)) ?? Enumerable.Empty()).Count(m => m.IsActive && m.Role == (int)RmsInvestigationRole.Lead); + if (member.Role == (int)RmsInvestigationRole.Lead && leads <= 1) throw new InvalidOperationException("A case keeps at least one lead investigator."); + member.RemovedOn = DateTime.UtcNow; member.RemovedByUserId = userId; + await _members.UpdateAsync(member, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, AuditPurpose, caseId, new { action = "member removed", member.UserId }, cancellationToken: cancellationToken); + } + + public async Task LinkIncidentAsync(int departmentId, string userId, string caseId, string recordId, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + RequireOpen(investigation); + var report = await _reports.GetByIdForDepartmentAsync(departmentId, recordId); + if (report == null || report.DeletedOn != null) throw new ArgumentException("The incident report does not exist."); + if (!await _authorization.CanUserViewRecordAsync(userId, recordId, departmentId)) throw new UnauthorizedAccessException("You cannot view that incident report."); + var existing = ((await _incidents.GetForCaseAsync(departmentId, caseId)) ?? Enumerable.Empty()).FirstOrDefault(i => i.RecordId == recordId); + if (existing != null) return existing; + var link = new RmsInvestigationCaseIncident { RmsInvestigationCaseIncidentId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInvestigationCaseId = caseId, RecordId = recordId, PinnedRevisionId = report.CurrentRevisionId, RecordNumber = report.RecordNumber ?? report.IncidentNumber, LinkedOn = DateTime.UtcNow, LinkedByUserId = userId }; + await _incidents.InsertAsync(link, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, caseId, new { action = "incident linked", recordId, link.PinnedRevisionId }, cancellationToken: cancellationToken); + return link; + } + + public async Task UnlinkIncidentAsync(int departmentId, string userId, string caseId, string linkId, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + RequireOpen(investigation); + var link = ((await _incidents.GetForCaseAsync(departmentId, caseId)) ?? Enumerable.Empty()).FirstOrDefault(i => i.RmsInvestigationCaseIncidentId == linkId) ?? throw new ArgumentException("The link does not exist."); + await _incidents.DeleteAsync(link, cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, caseId, new { action = "incident unlinked", link.RecordId }, cancellationToken: cancellationToken); + } + + #endregion + + #region Notes, evidence, referrals + + public async Task AddNoteAsync(int departmentId, string userId, string caseId, RmsInvestigationNoteKind kind, DateTime occurredOn, string subject, string body, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + RequireOpen(investigation); + var now = DateTime.UtcNow; + var note = new RmsInvestigationNote + { + RmsInvestigationNoteId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInvestigationCaseId = caseId, Kind = (int)kind, + OccurredOn = occurredOn == default ? now : occurredOn, AuthorUserId = userId, Subject = RecordsPreventionGate.Trim(subject, 250), Body = RecordsPreventionGate.Require(body, 32000, "A note needs content."), CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + var plaintext = PlaintextSnapshot.Take(note, RmsProtectedFields.InvestigationNotes); + await _protection.ProtectInvestigationNoteAsync(departmentId, note, null, userId, cancellationToken); + await _notes.InsertAsync(note, cancellationToken, true); + plaintext.Restore(); + await TouchCaseAsync(investigation, cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, caseId, new { action = "note added", note.RmsInvestigationNoteId, kind = kind.ToString() }, cancellationToken: cancellationToken); + return note; + } + + public async Task UpdateNoteAsync(int departmentId, string userId, string noteId, string subject, string body, CancellationToken cancellationToken = default) + { + var note = await _notes.GetByIdForDepartmentAsync(departmentId, noteId); + if (note == null || note.DeletedOn != null) throw new ArgumentException("The note does not exist."); + var (investigation, role) = await RequireMemberAsync(departmentId, userId, note.RmsInvestigationCaseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + RequireOpen(investigation); + if (note.IsLocked) throw new InvalidOperationException("The note is locked and cannot be edited."); + if (note.AuthorUserId != userId && role != RmsInvestigationRole.Lead) throw new UnauthorizedAccessException("Only the author or the lead may edit a note."); + var existing = new RmsInvestigationNote { Subject = note.Subject, Body = note.Body }; + note.Subject = RecordsPreventionGate.Trim(subject, 250); note.Body = RecordsPreventionGate.Require(body, 32000, "A note needs content."); note.ModifiedOn = DateTime.UtcNow; note.RowVersion++; + var plaintext = PlaintextSnapshot.Take(note, RmsProtectedFields.InvestigationNotes); + await _protection.ProtectInvestigationNoteAsync(departmentId, note, existing, userId, cancellationToken); + await _notes.UpdateAsync(note, cancellationToken, true); + plaintext.Restore(); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, note.RmsInvestigationCaseId, new { action = "note updated", noteId }, cancellationToken: cancellationToken); + return note; + } + + public async Task AddEvidenceAsync(int departmentId, string userId, string caseId, RmsInvestigationEvidence input, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + RequireOpen(investigation); + var now = DateTime.UtcNow; + var item = new RmsInvestigationEvidence + { + RmsInvestigationEvidenceId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInvestigationCaseId = caseId, + EvidenceNumber = await _gate.NextNumberAsync(departmentId, RmsPreventionNumberKinds.Evidence, now, cancellationToken), Kind = input.Kind == 0 ? (int)RmsInvestigationEvidenceKind.Physical : input.Kind, + Description = RecordsPreventionGate.Require(input.Description, 4000, "Evidence needs a description."), CollectedOn = input.CollectedOn == default ? now : input.CollectedOn, + CollectedByUserId = string.IsNullOrWhiteSpace(input.CollectedByUserId) ? userId : input.CollectedByUserId.Trim(), CollectedFrom = RecordsPreventionGate.Trim(input.CollectedFrom, 1000), + State = (int)RmsEvidenceState.Collected, StorageLocation = RecordsPreventionGate.Trim(input.StorageLocation, 250), CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + item.CurrentCustodianUserId = item.CollectedByUserId; + var custody = new RmsInvestigationCustody { RmsInvestigationCustodyId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInvestigationEvidenceId = item.RmsInvestigationEvidenceId, Sequence = 1, TransferredOn = item.CollectedOn, ToUserId = item.CollectedByUserId, Reason = "Collected", ResultingState = (int)RmsEvidenceState.Collected, RecordedByUserId = userId, CreatedOn = now }; + var plaintext = PlaintextSnapshot.Take(item, RmsProtectedFields.InvestigationEvidence); + var custodyPlain = PlaintextSnapshot.Take(custody, RmsProtectedFields.InvestigationCustody); + _unitOfWork.CreateOrGetConnection(); + try + { + await _protection.ProtectInvestigationEvidenceAsync(departmentId, item, null, userId, cancellationToken); + await _evidence.InsertAsync(item, cancellationToken, true); + await _protection.ProtectInvestigationCustodyAsync(departmentId, custody, userId, cancellationToken); + await _custody.InsertAsync(custody, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, caseId, new { action = "evidence added", item.RmsInvestigationEvidenceId, item.EvidenceNumber }, cancellationToken: cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + plaintext.Restore(); custodyPlain.Restore(); + await TouchCaseAsync(investigation, cancellationToken); + return item; + } + + public async Task TransferCustodyAsync(int departmentId, string userId, string evidenceId, string toUserId, string toExternal, string reason, RmsEvidenceState resultingState, CancellationToken cancellationToken = default) + { + var item = await _evidence.GetByIdForDepartmentAsync(departmentId, evidenceId); + if (item == null || item.DeletedOn != null) throw new ArgumentException("The evidence item does not exist."); + var (investigation, _) = await RequireMemberAsync(departmentId, userId, item.RmsInvestigationCaseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + RequireOpen(investigation); + if (string.IsNullOrWhiteSpace(toUserId) && string.IsNullOrWhiteSpace(toExternal) && resultingState != RmsEvidenceState.Destroyed) throw new ArgumentException("Name who receives the evidence."); + if (item.State == (int)RmsEvidenceState.Destroyed) throw new InvalidOperationException("Destroyed evidence has no further custody."); + reason = RecordsPreventionGate.Require(reason, 1000, "A custody transfer needs a reason."); + var now = DateTime.UtcNow; + var sequence = ((await _custody.GetForEvidenceAsync(departmentId, evidenceId))?.Count() ?? 0) + 1; + var transfer = new RmsInvestigationCustody + { + RmsInvestigationCustodyId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInvestigationEvidenceId = evidenceId, Sequence = sequence, TransferredOn = now, + FromUserId = item.CurrentCustodianUserId, FromExternal = item.CurrentCustodianExternal, ToUserId = RecordsPreventionGate.Trim(toUserId, 128), ToExternal = RecordsPreventionGate.Trim(toExternal, 250), Reason = reason, + ResultingState = (int)resultingState, RecordedByUserId = userId, CreatedOn = now + }; + var existing = new RmsInvestigationEvidence { Description = item.Description, CollectedFrom = item.CollectedFrom, CurrentCustodianExternal = item.CurrentCustodianExternal }; + item.CurrentCustodianUserId = transfer.ToUserId; item.CurrentCustodianExternal = transfer.ToExternal; item.State = (int)resultingState; item.ModifiedOn = now; item.RowVersion++; + var transferPlain = PlaintextSnapshot.Take(transfer, RmsProtectedFields.InvestigationCustody); + var itemPlain = PlaintextSnapshot.Take(item, RmsProtectedFields.InvestigationEvidence); + _unitOfWork.CreateOrGetConnection(); + try + { + await _protection.ProtectInvestigationCustodyAsync(departmentId, transfer, userId, cancellationToken); + await _custody.InsertAsync(transfer, cancellationToken, true); + await _protection.ProtectInvestigationEvidenceAsync(departmentId, item, existing, userId, cancellationToken); + await _evidence.UpdateAsync(item, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, item.RmsInvestigationCaseId, new { action = "custody transferred", evidenceId, sequence, state = resultingState.ToString() }, cancellationToken: cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + transferPlain.Restore(); itemPlain.Restore(); + return transfer; + } + + public async Task> GetCustodyChainAsync(int departmentId, string userId, string evidenceId) + { + var item = await _evidence.GetByIdForDepartmentAsync(departmentId, evidenceId); + if (item == null || item.DeletedOn != null) throw new ArgumentException("The evidence item does not exist."); + await RequireMemberAsync(departmentId, userId, item.RmsInvestigationCaseId); + var chain = (await _custody.GetForEvidenceAsync(departmentId, evidenceId))?.ToList() ?? new List(); + await _protection.RevealInvestigationCustodyAsync(departmentId, chain); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Read, AuditPurpose, item.RmsInvestigationCaseId, new { action = "custody chain read", evidenceId }); + return chain; + } + + public async Task AddReferralAsync(int departmentId, string userId, string caseId, string agency, string reason, string referenceNumber, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + RequireOpen(investigation); + var now = DateTime.UtcNow; + var referral = new RmsInvestigationReferral + { + RmsInvestigationReferralId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInvestigationCaseId = caseId, + Agency = RecordsPreventionGate.Require(agency, 250, "A referral names the receiving agency."), ReferredOn = now, ReferredByUserId = userId, Reason = RecordsPreventionGate.Trim(reason, 4000), + ReferenceNumber = RecordsPreventionGate.Trim(referenceNumber, 100), State = (int)RmsReferralState.Sent, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + var plaintext = PlaintextSnapshot.Take(referral, RmsProtectedFields.InvestigationReferrals); + await _protection.ProtectInvestigationReferralAsync(departmentId, referral, null, userId, cancellationToken); + await _referrals.InsertAsync(referral, cancellationToken, true); + plaintext.Restore(); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Share, AuditPurpose, caseId, new { action = "referral sent", referral.Agency, referral.ReferenceNumber }, cancellationToken: cancellationToken); + return referral; + } + + public async Task UpdateReferralStateAsync(int departmentId, string userId, string referralId, RmsReferralState state, CancellationToken cancellationToken = default) + { + var referral = await _referrals.GetByIdForDepartmentAsync(departmentId, referralId) ?? throw new ArgumentException("The referral does not exist."); + await RequireMemberAsync(departmentId, userId, referral.RmsInvestigationCaseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + referral.State = (int)state; referral.ModifiedOn = DateTime.UtcNow; referral.RowVersion++; + await _referrals.UpdateAsync(referral, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, referral.RmsInvestigationCaseId, new { action = "referral state", referralId, state = state.ToString() }, cancellationToken: cancellationToken); + return referral; + } + + #endregion + + #region Findings, closure + + public async Task RecordFindingsAsync(int departmentId, string userId, string caseId, RmsFireCauseClassification classification, string causeDetail, string originDescription, string findings, bool recommendsIncidentAmendment, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + RequireOpen(investigation); + var existing = Snapshot(investigation); + var now = DateTime.UtcNow; + investigation.CauseClassification = (int)classification; investigation.CauseDetail = RecordsPreventionGate.Trim(causeDetail, 8000); investigation.OriginDescription = RecordsPreventionGate.Trim(originDescription, 8000); + investigation.Findings = RecordsPreventionGate.Require(findings, 32000, "Findings are required."); investigation.FindingsAuthorUserId = userId; investigation.FindingsRecordedOn = now; + investigation.FindingsApprovedOn = null; investigation.FindingsApprovedByUserId = null; investigation.RecommendsIncidentAmendment = recommendsIncidentAmendment; + investigation.State = (int)RmsInvestigationCaseState.PendingReview; investigation.ModifiedOn = now; investigation.RowVersion++; + await SealAndUpdateAsync(departmentId, userId, investigation, existing, cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, caseId, new { action = "findings recorded", classification = classification.ToString(), recommendsIncidentAmendment }, cancellationToken: cancellationToken); + return investigation; + } + + public async Task ApproveFindingsAsync(int departmentId, string userId, string caseId, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Reviewer); + RequireOpen(investigation); + if (investigation.State != (int)RmsInvestigationCaseState.PendingReview) throw new InvalidOperationException("There are no findings awaiting approval."); + if (string.Equals(investigation.FindingsAuthorUserId, userId, StringComparison.Ordinal)) throw new InvalidOperationException("Findings must be approved by someone other than their author."); + var now = DateTime.UtcNow; + investigation.FindingsApprovedOn = now; investigation.FindingsApprovedByUserId = userId; investigation.State = (int)RmsInvestigationCaseState.Active; investigation.ModifiedOn = now; investigation.RowVersion++; + await _cases.UpdateAsync(investigation, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Sign, AuditPurpose, caseId, new { action = "findings approved", author = investigation.FindingsAuthorUserId }, cancellationToken: cancellationToken); + return investigation; + } + + public async Task ReturnFindingsAsync(int departmentId, string userId, string caseId, string reason, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Reviewer); + RequireOpen(investigation); + if (investigation.State != (int)RmsInvestigationCaseState.PendingReview) throw new InvalidOperationException("There are no findings awaiting approval."); + reason = RecordsPreventionGate.Require(reason, 2000, "Say what needs to change."); + investigation.State = (int)RmsInvestigationCaseState.Active; investigation.ModifiedOn = DateTime.UtcNow; investigation.RowVersion++; + await _cases.UpdateAsync(investigation, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, caseId, new { action = "findings returned", reason }, cancellationToken: cancellationToken); + return investigation; + } + + public async Task CloseAsync(int departmentId, string userId, string caseId, string closureReason, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead); + RequireOpen(investigation); + if (!investigation.FindingsApprovedOn.HasValue) throw new InvalidOperationException("A case closes only after its findings are approved."); + var existing = Snapshot(investigation); + var now = DateTime.UtcNow; + investigation.State = (int)RmsInvestigationCaseState.Closed; investigation.ClosedOn = now; investigation.ClosedByUserId = userId; investigation.ClosureReason = RecordsPreventionGate.Require(closureReason, 4000, "Record why the case is closing."); + investigation.ModifiedOn = now; investigation.RowVersion++; + _unitOfWork.CreateOrGetConnection(); + try + { + await SealAndUpdateAsync(departmentId, userId, investigation, existing, cancellationToken); + await _notes.LockForCaseAsync(departmentId, caseId, now, cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, caseId, new { action = "closed" }, cancellationToken: cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + return investigation; + } + + public async Task ReopenAsync(int departmentId, string userId, string caseId, string reason, CancellationToken cancellationToken = default) + { + var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead); + if (!investigation.IsClosed) throw new InvalidOperationException("The case is not closed."); + reason = RecordsPreventionGate.Require(reason, 2000, "Record why the case is reopening."); + investigation.State = (int)RmsInvestigationCaseState.Active; investigation.ClosedOn = null; investigation.ClosedByUserId = null; investigation.ModifiedOn = DateTime.UtcNow; investigation.RowVersion++; + await _cases.UpdateAsync(investigation, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, caseId, new { action = "reopened", reason }, cancellationToken: cancellationToken); + return investigation; + } + + private static RmsInvestigationCase Snapshot(RmsInvestigationCase c) => new RmsInvestigationCase { IncidentSummary = c.IncidentSummary, CauseDetail = c.CauseDetail, OriginDescription = c.OriginDescription, Findings = c.Findings, ClosureReason = c.ClosureReason }; + + private async Task TouchCaseAsync(RmsInvestigationCase investigation, CancellationToken cancellationToken) + { + if (investigation.State == (int)RmsInvestigationCaseState.Open) investigation.State = (int)RmsInvestigationCaseState.Active; + investigation.ModifiedOn = DateTime.UtcNow; investigation.RowVersion++; + await _cases.UpdateAsync(investigation, cancellationToken, true); + } + + #endregion + + #region Export and audit + + public async Task ExportAsync(int departmentId, string userId, string caseId, string ipAddress = null, CancellationToken cancellationToken = default) + { + var aggregate = await GetAsync(departmentId, userId, caseId, ipAddress); + aggregate.Protection.RequireRevealed("investigation export"); + var custody = new Dictionary>(); + foreach (var item in aggregate.Evidence) + { + var chain = (await _custody.GetForEvidenceAsync(departmentId, item.RmsInvestigationEvidenceId))?.ToList() ?? new List(); + (await _protection.RevealInvestigationCustodyAsync(departmentId, chain)).RequireRevealed("investigation export"); + custody[item.EvidenceNumber ?? item.RmsInvestigationEvidenceId] = chain; + } + var packet = new + { + contract = "resgrid.investigation-packet.v1", exported_on = DateTime.UtcNow, exported_by = userId, department_id = departmentId, + aggregate.Case, aggregate.Members, aggregate.Incidents, aggregate.Notes, aggregate.Evidence, custody, aggregate.Referrals, + attachments = aggregate.Attachments.Select(a => new { a.RmsPreventionAttachmentId, a.FileName, a.ContentType, a.ByteSize, a.Checksum, a.UploadedOn }) + }; + var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(packet, Formatting.Indented)); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Export, AuditPurpose, caseId, new { action = "packet exported", bytes = bytes.Length, checksum = RecordSnapshotSerializer.Checksum(Encoding.UTF8.GetString(bytes)) }, ipAddress: ipAddress, cancellationToken: cancellationToken); + return bytes; + } + + public async Task> GetAccessAuditAsync(int departmentId, string userId, string caseId, int take) + { + await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Reviewer); + return (await _audits.GetForAggregateAsync(departmentId, caseId, take))?.ToList() ?? new List(); + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/RecordsOccupancyService.cs b/Core/Resgrid.Services/Records/RecordsOccupancyService.cs new file mode 100644 index 000000000..d78763f18 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsOccupancyService.cs @@ -0,0 +1,873 @@ +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.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// The occupancy/property master and its transition from Contacts pre-plans (RMS plan section 4.3, RMS-5). + /// + /// Ownership never flips silently: a department starts ContactsOwned, an administrator runs the crosswalk inventory + /// (Reconciling), decides every candidate, and only then switches structure writes to RMS (RecordsOwned). From that + /// moment the Contacts service refuses pre-plan writes and projects reads from here through + /// , so dispatch, the apps and the v4 contract keep their shape. + /// + /// + public class RecordsOccupancyService : IRecordsOccupancyService, IContactPreplanOwnershipGate + { + /// Two sources closer than this describe the same structure for grouping purposes. + public const double ProximityMeters = 50; + public const int DefaultReviewMonths = 12; + + private readonly RecordsPreventionGate _gate; + private readonly IRmsOccupanciesRepository _occupancies; + private readonly IRmsOccupancyContactLinksRepository _links; + private readonly IRmsOccupancyHazardsRepository _hazards; + private readonly IRmsOccupancyCrosswalksRepository _crosswalks; + private readonly IRmsOccupancyFieldProvenancesRepository _provenance; + private readonly IRmsOccupancyOwnershipsRepository _ownerships; + private readonly IRmsViolationsRepository _violations; + private readonly IRmsHydrantsRepository _hydrants; + private readonly IContactPreplanRepository _contactPreplans; + private readonly IContactPreplanHazardRepository _contactHazards; + private readonly IContactsRepository _contacts; + private readonly IAddressRepository _addresses; + private readonly IPoisRepository _pois; + private readonly IProtectedReadService _protectedReads; + private readonly IProtectedGrantContext _grant; + private readonly IRecordsProtectionService _protection; + private readonly IUnitOfWork _unitOfWork; + + public RecordsOccupancyService(RecordsPreventionGate gate, IRmsOccupanciesRepository occupancies, IRmsOccupancyContactLinksRepository links, + IRmsOccupancyHazardsRepository hazards, IRmsOccupancyCrosswalksRepository crosswalks, IRmsOccupancyFieldProvenancesRepository provenance, + IRmsOccupancyOwnershipsRepository ownerships, IRmsViolationsRepository violations, IRmsHydrantsRepository hydrants, + IContactPreplanRepository contactPreplans, IContactPreplanHazardRepository contactHazards, IContactsRepository contacts, IAddressRepository addresses, + IPoisRepository pois, IProtectedReadService protectedReads, IProtectedGrantContext grant, IRecordsProtectionService protection, IUnitOfWork unitOfWork) + { + _gate = gate; + _occupancies = occupancies; + _links = links; + _hazards = hazards; + _crosswalks = crosswalks; + _provenance = provenance; + _ownerships = ownerships; + _violations = violations; + _hydrants = hydrants; + _contactPreplans = contactPreplans; + _contactHazards = contactHazards; + _contacts = contacts; + _addresses = addresses; + _pois = pois; + _protectedReads = protectedReads; + _grant = grant; + _protection = protection; + _unitOfWork = unitOfWork; + } + + public Task IsModuleEnabledAsync(int departmentId) => _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + + #region Master CRUD + + public async Task> ListAsync(int departmentId, string userId, RmsOccupancyQuery query) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireViewerAsync(departmentId, userId); + var rows = (await _occupancies.QueryAsync(departmentId, query ?? new RmsOccupancyQuery()))?.ToList() ?? new List(); + await _protection.RevealOccupanciesAsync(departmentId, rows); + return rows; + } + + public async Task CountAsync(int departmentId, string userId, RmsOccupancyQuery query) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireViewerAsync(departmentId, userId); + return await _occupancies.CountAsync(departmentId, query ?? new RmsOccupancyQuery()); + } + + public async Task GetAsync(int departmentId, string userId, string occupancyId) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireViewerAsync(departmentId, userId); + var occupancy = await LiveAsync(departmentId, occupancyId); + if (occupancy == null) + return null; + + var aggregate = new OccupancyAggregate + { + Occupancy = occupancy, + Hazards = (await _hazards.GetForOccupancyAsync(departmentId, occupancyId))?.ToList() ?? new List(), + ContactLinks = (await _links.GetForOccupancyAsync(departmentId, occupancyId))?.ToList() ?? new List(), + Provenance = (await _provenance.GetForOccupancyAsync(departmentId, occupancyId))?.ToList() ?? new List(), + Crosswalks = (await _crosswalks.GetForOccupancyAsync(departmentId, occupancyId))?.ToList() ?? new List() + }; + var open = await _violations.CountOpenByOccupancyAsync(departmentId, new[] { occupancyId }); + aggregate.OpenViolationCount = open != null && open.TryGetValue(occupancyId, out var count) ? count : 0; + aggregate.Protection.Merge(await _protection.RevealOccupanciesAsync(departmentId, new[] { occupancy })); + aggregate.Protection.Merge(await _protection.RevealOccupancyHazardsAsync(departmentId, aggregate.Hazards)); + return aggregate; + } + + public async Task SaveAsync(int departmentId, string userId, RmsOccupancy input, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + input.Name = RecordsPreventionGate.Require(input.Name, 250, "An occupancy needs a name."); + var now = DateTime.UtcNow; + + RmsOccupancy entity; + RmsOccupancy existing = null; + var isNew = string.IsNullOrWhiteSpace(input.RmsOccupancyId); + if (isNew) + { + entity = new RmsOccupancy + { + RmsOccupancyId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), + OccupancyNumber = await _gate.NextNumberAsync(departmentId, RmsPreventionNumberKinds.Occupancy, now, cancellationToken), + Status = (int)RmsOccupancyStatus.Active, CreatedOn = now, CreatedByUserId = userId, RowVersion = 1 + }; + } + else + { + existing = await LiveAsync(departmentId, input.RmsOccupancyId) ?? throw new ArgumentException("The occupancy does not exist."); + if (input.RowVersion != 0 && input.RowVersion != existing.RowVersion) + throw new InvalidOperationException("The occupancy changed since it was loaded. Reload it before saving."); + entity = existing; + } + + var changed = CopyEditable(input, entity); + entity.NormalizedAddress = AddressNormalizer.Normalize(string.Join(" ", new[] { entity.AddressText, entity.City, entity.StateProvince, entity.PostalCode }.Where(x => !string.IsNullOrWhiteSpace(x)))); + entity.ModifiedOn = now; + entity.ModifiedByUserId = userId; + if (!isNew) entity.RowVersion = existing.RowVersion + 1; + if (input.Status == (int)RmsOccupancyStatus.Vacant || input.Status == (int)RmsOccupancyStatus.Demolished || input.Status == (int)RmsOccupancyStatus.Active) + entity.Status = input.Status; + + // Seal for storage, hand the caller its plaintext back afterwards. + var plaintext = PlaintextSnapshot.Take(entity, RmsProtectedFields.Occupancies); + _unitOfWork.CreateOrGetConnection(); + try + { + await _protection.ProtectOccupancyAsync(departmentId, entity, isNew ? null : existing, userId, cancellationToken); + if (isNew) await _occupancies.InsertAsync(entity, cancellationToken, true); + else await _occupancies.UpdateAsync(entity, cancellationToken, true); + foreach (var field in changed) + await _provenance.InsertAsync(new RmsOccupancyFieldProvenance + { + RmsOccupancyFieldProvenanceId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsOccupancyId = entity.RmsOccupancyId, + FieldKey = field, SourceKind = 0, SourceId = null, CapturedOn = now, CapturedByUserId = userId, ReviewedOn = now, ReviewedByUserId = userId + }, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, isNew ? "Occupancy created" : "Occupancy updated", entity.RmsOccupancyId, new { entity.OccupancyNumber, fields = changed }, cancellationToken: cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + plaintext.Restore(); + return entity; + } + + public async Task DeleteAsync(int departmentId, string userId, string occupancyId, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + var occupancy = await LiveAsync(departmentId, occupancyId) ?? throw new ArgumentException("The occupancy does not exist."); + var open = await _violations.CountOpenByOccupancyAsync(departmentId, new[] { occupancyId }); + if (open != null && open.TryGetValue(occupancyId, out var count) && count > 0) + throw new InvalidOperationException("An occupancy with open violations cannot be removed; correct or waive them first."); + occupancy.DeletedOn = DateTime.UtcNow; occupancy.ModifiedOn = occupancy.DeletedOn.Value; occupancy.ModifiedByUserId = userId; occupancy.RowVersion++; + await _occupancies.UpdateAsync(occupancy, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Occupancy removed", occupancyId, new { occupancy.OccupancyNumber }, cancellationToken: cancellationToken); + } + + public async Task MarkReviewedAsync(int departmentId, string userId, string occupancyId, int nextReviewMonths, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + var occupancy = await LiveAsync(departmentId, occupancyId) ?? throw new ArgumentException("The occupancy does not exist."); + var now = DateTime.UtcNow; + occupancy.LastReviewedOn = now; occupancy.ReviewedByUserId = userId; + occupancy.NextReviewDue = now.AddMonths(nextReviewMonths <= 0 ? DefaultReviewMonths : Math.Min(nextReviewMonths, 120)); + occupancy.ModifiedOn = now; occupancy.ModifiedByUserId = userId; occupancy.RowVersion++; + await _occupancies.UpdateAsync(occupancy, cancellationToken, true); + foreach (var row in await _provenance.GetForOccupancyAsync(departmentId, occupancyId) ?? Enumerable.Empty()) + { + row.ReviewedOn = now; row.ReviewedByUserId = userId; + await _provenance.UpdateAsync(row, cancellationToken, true); + } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Occupancy reviewed", occupancyId, new { occupancy.NextReviewDue }, cancellationToken: cancellationToken); + return occupancy; + } + + /// Copies the editable columns from the posted row and reports which changed (for provenance). + private static List CopyEditable(RmsOccupancy from, RmsOccupancy to) + { + var changed = new List(); + void Set(string key, Func get, Action set) + { + var value = get(from); + if (!EqualityComparer.Default.Equals(get(to), value)) { set(to, value); changed.Add(key); } + } + string T(string s, int max) => RecordsPreventionGate.Trim(s, max); + Set("Name", o => o.Name, (o, v) => o.Name = v); + Set("AddressText", o => T(o.AddressText, 500), (o, v) => o.AddressText = v); + Set("City", o => T(o.City, 120), (o, v) => o.City = v); + Set("StateProvince", o => T(o.StateProvince, 120), (o, v) => o.StateProvince = v); + Set("PostalCode", o => T(o.PostalCode, 20), (o, v) => o.PostalCode = v); + Set("Country", o => T(o.Country, 80), (o, v) => o.Country = v); + Set("Latitude", o => o.Latitude, (o, v) => o.Latitude = v); + Set("Longitude", o => o.Longitude, (o, v) => o.Longitude = v); + Set("ParcelId", o => T(o.ParcelId, 100), (o, v) => o.ParcelId = v); + Set("ConstructionType", o => o.ConstructionType, (o, v) => o.ConstructionType = v); + Set("RoofType", o => o.RoofType, (o, v) => o.RoofType = v); + Set("OccupancyType", o => o.OccupancyType, (o, v) => o.OccupancyType = v); + Set("Stories", o => o.Stories, (o, v) => o.Stories = v); + Set("YearBuilt", o => o.YearBuilt, (o, v) => o.YearBuilt = v); + Set("SquareFeet", o => o.SquareFeet, (o, v) => o.SquareFeet = v); + Set("OccupantLoad", o => o.OccupantLoad, (o, v) => o.OccupantLoad = v); + Set("OccupancyHours", o => T(o.OccupancyHours, 250), (o, v) => o.OccupancyHours = v); + Set("HasOccupantsNeedingAssistance", o => o.HasOccupantsNeedingAssistance, (o, v) => o.HasOccupantsNeedingAssistance = v); + Set("OccupantsNeedingAssistanceNotes", o => T(o.OccupantsNeedingAssistanceNotes, 4000), (o, v) => o.OccupantsNeedingAssistanceNotes = v); + Set("SprinklerType", o => o.SprinklerType, (o, v) => o.SprinklerType = v); + Set("HasStandpipe", o => o.HasStandpipe, (o, v) => o.HasStandpipe = v); + Set("HasFireAlarm", o => o.HasFireAlarm, (o, v) => o.HasFireAlarm = v); + Set("FdcLocation", o => T(o.FdcLocation, 250), (o, v) => o.FdcLocation = v); + Set("GasShutoffLocation", o => T(o.GasShutoffLocation, 500), (o, v) => o.GasShutoffLocation = v); + Set("ElectricShutoffLocation", o => T(o.ElectricShutoffLocation, 500), (o, v) => o.ElectricShutoffLocation = v); + Set("WaterShutoffLocation", o => T(o.WaterShutoffLocation, 500), (o, v) => o.WaterShutoffLocation = v); + Set("UtilityNotes", o => T(o.UtilityNotes, 4000), (o, v) => o.UtilityNotes = v); + Set("KnoxBoxLocation", o => T(o.KnoxBoxLocation, 500), (o, v) => o.KnoxBoxLocation = v); + Set("GateCode", o => T(o.GateCode, 100), (o, v) => o.GateCode = v); + Set("AlarmPanelLocation", o => T(o.AlarmPanelLocation, 500), (o, v) => o.AlarmPanelLocation = v); + Set("AlarmCompany", o => T(o.AlarmCompany, 250), (o, v) => o.AlarmCompany = v); + Set("AlarmCompanyPhone", o => T(o.AlarmCompanyPhone, 50), (o, v) => o.AlarmCompanyPhone = v); + Set("AccessNotes", o => T(o.AccessNotes, 4000), (o, v) => o.AccessNotes = v); + Set("NearestHydrantId", o => T(o.NearestHydrantId, 36), (o, v) => o.NearestHydrantId = v); + Set("RequiredFireFlowGpm", o => o.RequiredFireFlowGpm, (o, v) => o.RequiredFireFlowGpm = v); + Set("WaterSupplyNotes", o => T(o.WaterSupplyNotes, 4000), (o, v) => o.WaterSupplyNotes = v); + Set("EmergencyContactName", o => T(o.EmergencyContactName, 250), (o, v) => o.EmergencyContactName = v); + Set("EmergencyContactPhone", o => T(o.EmergencyContactPhone, 50), (o, v) => o.EmergencyContactPhone = v); + Set("HazmatOnSite", o => o.HazmatOnSite, (o, v) => o.HazmatOnSite = v); + Set("GeneralHazardNotes", o => T(o.GeneralHazardNotes, 4000), (o, v) => o.GeneralHazardNotes = v); + Set("TacticalSummary", o => T(o.TacticalSummary, 4000), (o, v) => o.TacticalSummary = v); + Set("PoiId", o => o.PoiId, (o, v) => o.PoiId = v); + Set("NextReviewDue", o => o.NextReviewDue, (o, v) => o.NextReviewDue = v); + return changed; + } + + private async Task LiveAsync(int departmentId, string occupancyId) + { + if (string.IsNullOrWhiteSpace(occupancyId)) return null; + var occupancy = await _occupancies.GetByIdForDepartmentAsync(departmentId, occupancyId); + return occupancy == null || occupancy.DeletedOn != null ? null : occupancy; + } + + #endregion + + #region Hazards and contact links + + public async Task SaveHazardAsync(int departmentId, string userId, RmsOccupancyHazard input, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var occupancy = await LiveAsync(departmentId, input.RmsOccupancyId) ?? throw new ArgumentException("The occupancy does not exist."); + var now = DateTime.UtcNow; + RmsOccupancyHazard entity, existing = null; + if (string.IsNullOrWhiteSpace(input.RmsOccupancyHazardId)) + entity = new RmsOccupancyHazard { RmsOccupancyHazardId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsOccupancyId = occupancy.RmsOccupancyId, CreatedOn = now, CreatedByUserId = userId, RowVersion = 1 }; + else + { + existing = await _hazards.GetByIdForDepartmentAsync(departmentId, input.RmsOccupancyHazardId); + if (existing == null || existing.DeletedOn != null || existing.RmsOccupancyId != occupancy.RmsOccupancyId) throw new ArgumentException("The hazard does not exist."); + entity = existing; + entity.RowVersion++; + } + entity.Title = RecordsPreventionGate.Require(input.Title, 200, "A hazard needs a title."); + entity.HazardType = input.HazardType; entity.Severity = Math.Clamp(input.Severity, 1, 4); + entity.Description = RecordsPreventionGate.Trim(input.Description, 4000); + entity.LocationDescription = RecordsPreventionGate.Trim(input.LocationDescription, 1000); + entity.GpsCoordinates = RecordsPreventionGate.Trim(input.GpsCoordinates, 100); + entity.ShouldAlert = input.ShouldAlert; entity.ModifiedOn = now; + var plaintext = PlaintextSnapshot.Take(entity, RmsProtectedFields.OccupancyHazards); + await _protection.ProtectOccupancyHazardAsync(departmentId, entity, existing, userId, cancellationToken); + if (existing == null) await _hazards.InsertAsync(entity, cancellationToken, true); else await _hazards.UpdateAsync(entity, cancellationToken, true); + plaintext.Restore(); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, existing == null ? "Occupancy hazard added" : "Occupancy hazard updated", occupancy.RmsOccupancyId, new { entity.RmsOccupancyHazardId, entity.Severity, entity.ShouldAlert }, cancellationToken: cancellationToken); + return entity; + } + + public async Task DeleteHazardAsync(int departmentId, string userId, string hazardId, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + var hazard = await _hazards.GetByIdForDepartmentAsync(departmentId, hazardId); + if (hazard == null || hazard.DeletedOn != null) throw new ArgumentException("The hazard does not exist."); + hazard.DeletedOn = DateTime.UtcNow; hazard.ModifiedOn = hazard.DeletedOn.Value; hazard.RowVersion++; + await _hazards.UpdateAsync(hazard, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Occupancy hazard removed", hazard.RmsOccupancyId, new { hazardId }, cancellationToken: cancellationToken); + } + + public async Task LinkContactAsync(int departmentId, string userId, string occupancyId, string contactId, RmsOccupancyContactRole role, bool isPrimary, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + var occupancy = await LiveAsync(departmentId, occupancyId) ?? throw new ArgumentException("The occupancy does not exist."); + var contact = await _contacts.GetByIdAsync(contactId) as Contact; + if (contact == null || contact.DepartmentId != departmentId || contact.IsDeleted) throw new ArgumentException("The contact does not exist in this department."); + var links = (await _links.GetForOccupancyAsync(departmentId, occupancyId))?.ToList() ?? new List(); + var link = links.FirstOrDefault(l => l.ContactId == contactId && l.Role == (int)role); + var now = DateTime.UtcNow; + if (link == null) + { + link = new RmsOccupancyContactLink { RmsOccupancyContactLinkId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsOccupancyId = occupancyId, ContactId = contactId, Role = (int)role, IsPrimary = isPrimary, CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, RowVersion = 1 }; + await _links.InsertAsync(link, cancellationToken, true); + } + else { link.IsPrimary = isPrimary; link.ModifiedOn = now; link.RowVersion++; await _links.UpdateAsync(link, cancellationToken, true); } + if (isPrimary) + foreach (var other in links.Where(l => l.IsPrimary && l.RmsOccupancyContactLinkId != link.RmsOccupancyContactLinkId)) + { other.IsPrimary = false; other.ModifiedOn = now; other.RowVersion++; await _links.UpdateAsync(other, cancellationToken, true); } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Occupancy contact linked", occupancyId, new { contactId, role = role.ToString(), isPrimary }, cancellationToken: cancellationToken); + return link; + } + + public async Task UnlinkContactAsync(int departmentId, string userId, string linkId, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + var link = await _links.GetByIdForDepartmentAsync(departmentId, linkId); + if (link == null || link.DeletedOn != null) throw new ArgumentException("The link does not exist."); + link.DeletedOn = DateTime.UtcNow; link.ModifiedOn = link.DeletedOn.Value; link.RowVersion++; + await _links.UpdateAsync(link, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Occupancy contact unlinked", link.RmsOccupancyId, new { link.ContactId, link.Role }, cancellationToken: cancellationToken); + } + + #endregion + + #region Crosswalk + + private sealed class SourceCandidate + { + public RmsOccupancyCrosswalkSourceKind Kind; + public string SourceId; + public string ContactId; + public string DisplayName; + public string NormalizedAddress; + public decimal? Latitude; + public decimal? Longitude; + } + + public async Task InventoryCandidatesAsync(int departmentId, string userId, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + var result = new OccupancyCrosswalkInventoryResult(); + var now = DateTime.UtcNow; + + var sources = await CollectSourcesAsync(departmentId); + result.SourcesScanned = sources.Count; + var existingRows = (await _crosswalks.GetAllForDepartmentAsync(departmentId))?.ToList() ?? new List(); + var occupancies = (await _occupancies.GetAllLiveAsync(departmentId))?.ToList() ?? new List(); + + // Group by folded address first, then by proximity for sources that only carry coordinates. + var groups = new List>(); + foreach (var source in sources) + { + List group = null; + if (source.NormalizedAddress != null) + group = groups.FirstOrDefault(g => g.Any(s => s.NormalizedAddress != null && s.NormalizedAddress == source.NormalizedAddress)); + if (group == null && source.Latitude.HasValue && source.Longitude.HasValue) + group = groups.FirstOrDefault(g => g.Any(s => s.Latitude.HasValue && s.Longitude.HasValue && Meters(s, source) <= ProximityMeters)); + if (group == null) { group = new List(); groups.Add(group); } + group.Add(source); + } + result.Groups = groups.Count; + + _unitOfWork.CreateOrGetConnection(); + try + { + var groupIndex = 0; + foreach (var group in groups) + { + groupIndex++; + var groupKey = $"g{groupIndex:0000}"; + foreach (var source in group) + { + var suggestion = Suggest(source, occupancies, out var confidence, out var reason); + var row = existingRows.FirstOrDefault(r => r.SourceKind == (int)source.Kind && r.SourceId == source.SourceId); + if (row != null && row.State != (int)RmsOccupancyCrosswalkState.Candidate) { result.AlreadyDecided++; continue; } + var isNewRow = row == null; + if (isNewRow) + { + row = new RmsOccupancyCrosswalk { RmsOccupancyCrosswalkId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), SourceKind = (int)source.Kind, SourceId = source.SourceId, State = (int)RmsOccupancyCrosswalkState.Candidate, CreatedOn = now, RowVersion = 1 }; + result.CandidatesCreated++; + } + else { result.CandidatesUpdated++; row.RowVersion++; } + row.ContactId = source.ContactId; row.SourceDisplayName = RecordsPreventionGate.Trim(source.DisplayName, 250); row.NormalizedAddress = RecordsPreventionGate.Trim(source.NormalizedAddress, 500); + row.Latitude = source.Latitude; row.Longitude = source.Longitude; row.MatchConfidence = confidence; row.MatchReason = reason; row.SuggestedOccupancyId = suggestion?.RmsOccupancyId; + row.GroupKey = groupKey; row.InventoriedOn = now; row.ModifiedOn = now; + if (isNewRow) await _crosswalks.InsertAsync(row, cancellationToken, true); + else await _crosswalks.UpdateAsync(row, cancellationToken, true); + } + } + + var ownership = await _ownerships.GetForDepartmentAsync(departmentId) ?? new RmsOccupancyOwnership { RmsOccupancyOwnershipId = Guid.NewGuid().ToString(), DepartmentId = departmentId, State = (int)RmsOccupancyOwnershipState.ContactsOwned, CreatedOn = now, RowVersion = 0 }; + ownership.InventoriedOn = now; + if (ownership.State == (int)RmsOccupancyOwnershipState.ContactsOwned) ownership.State = (int)RmsOccupancyOwnershipState.Reconciling; + ownership.CandidateCount = await _crosswalks.CountByStateAsync(departmentId, RmsOccupancyCrosswalkState.Candidate); + ownership.LinkedCount = await _crosswalks.CountByStateAsync(departmentId, RmsOccupancyCrosswalkState.Linked); + ownership.RejectedCount = await _crosswalks.CountByStateAsync(departmentId, RmsOccupancyCrosswalkState.Rejected); + ownership.ModifiedOn = now; + if (ownership.RowVersion == 0) { ownership.RowVersion = 1; await _ownerships.InsertAsync(ownership, cancellationToken, true); } + else { ownership.RowVersion++; await _ownerships.UpdateAsync(ownership, cancellationToken, true); } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, "Occupancy crosswalk inventoried", departmentId.ToString(), result, cancellationToken: cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + return result; + } + + private static double Meters(SourceCandidate a, SourceCandidate b) + => GeoMath.HaversineMeters((double)a.Latitude.Value, (double)a.Longitude.Value, (double)b.Latitude.Value, (double)b.Longitude.Value); + + private static RmsOccupancy Suggest(SourceCandidate source, List occupancies, out int confidence, out string reason) + { + if (source.NormalizedAddress != null) + { + var byAddress = occupancies.FirstOrDefault(o => o.NormalizedAddress != null && o.NormalizedAddress == source.NormalizedAddress); + if (byAddress != null) { confidence = 90; reason = "Same address"; return byAddress; } + } + if (source.Latitude.HasValue && source.Longitude.HasValue) + { + RmsOccupancy best = null; var bestMeters = double.MaxValue; + foreach (var o in occupancies.Where(o => o.Latitude.HasValue && o.Longitude.HasValue)) + { + var m = GeoMath.HaversineMeters((double)source.Latitude.Value, (double)source.Longitude.Value, (double)o.Latitude.Value, (double)o.Longitude.Value); + if (m < bestMeters) { bestMeters = m; best = o; } + } + if (best != null && bestMeters <= 25) { confidence = 75; reason = $"Within {Math.Round(bestMeters)} m"; return best; } + if (best != null && bestMeters <= ProximityMeters) { confidence = 60; reason = $"Within {Math.Round(bestMeters)} m"; return best; } + } + confidence = 0; reason = "No matching occupancy"; + return null; + } + + private async Task> CollectSourcesAsync(int departmentId) + { + var list = new List(); + var contacts = ((await _contacts.GetAllByDepartmentIdAsync(departmentId)) ?? Enumerable.Empty()).Where(c => !c.IsDeleted).ToList(); + var preplans = ((await _contactPreplans.GetPreplansByDepartmentIdAsync(departmentId)) ?? Enumerable.Empty()).Where(p => !p.IsDeleted).ToList(); + var preplanContacts = new HashSet(preplans.Select(p => p.ContactId), StringComparer.Ordinal); + var addressCache = new Dictionary(); + + async Task AddressOf(Contact c) + { + if (!c.PhysicalAddressId.HasValue) return null; + if (addressCache.TryGetValue(c.PhysicalAddressId.Value, out var cached)) return cached; + var address = await _addresses.GetByIdAsync(c.PhysicalAddressId.Value) as Address; + var folded = address == null ? null : AddressNormalizer.Normalize(string.Join(" ", new[] { address.Address1, address.City, address.State, address.PostalCode }.Where(x => !string.IsNullOrWhiteSpace(x)))); + addressCache[c.PhysicalAddressId.Value] = folded; + return folded; + } + + foreach (var preplan in preplans) + { + var contact = contacts.FirstOrDefault(c => c.ContactId == preplan.ContactId); + var point = GeoMath.ParseLatLonString(contact?.LocationGpsCoordinates); + list.Add(new SourceCandidate { Kind = RmsOccupancyCrosswalkSourceKind.ContactPreplan, SourceId = preplan.ContactPreplanId, ContactId = preplan.ContactId, DisplayName = contact?.Name ?? preplan.ContactId, + NormalizedAddress = contact == null ? null : await AddressOf(contact), Latitude = point.HasValue ? (decimal?)(decimal)point.Value.Latitude : null, Longitude = point.HasValue ? (decimal?)(decimal)point.Value.Longitude : null }); + } + foreach (var contact in contacts.Where(c => c.ContactType == 1 && !preplanContacts.Contains(c.ContactId))) + { + var point = GeoMath.ParseLatLonString(contact.LocationGpsCoordinates); + var address = await AddressOf(contact); + if (!point.HasValue && address == null) continue; // a company with no site is not a structure candidate + list.Add(new SourceCandidate { Kind = RmsOccupancyCrosswalkSourceKind.Contact, SourceId = contact.ContactId, ContactId = contact.ContactId, DisplayName = contact.Name, + NormalizedAddress = address, Latitude = point.HasValue ? (decimal?)(decimal)point.Value.Latitude : null, Longitude = point.HasValue ? (decimal?)(decimal)point.Value.Longitude : null }); + } + foreach (var poi in (await _pois.GetAllByDepartmentIdAsync(departmentId)) ?? Enumerable.Empty()) + { + list.Add(new SourceCandidate { Kind = RmsOccupancyCrosswalkSourceKind.Poi, SourceId = poi.PoiId.ToString(), DisplayName = poi.Name, NormalizedAddress = AddressNormalizer.Normalize(poi.Address), + Latitude = poi.Latitude == 0 && poi.Longitude == 0 ? (decimal?)null : (decimal)poi.Latitude, Longitude = poi.Latitude == 0 && poi.Longitude == 0 ? (decimal?)null : (decimal)poi.Longitude }); + } + return list; + } + + public async Task> GetCandidatesAsync(int departmentId, string userId, RmsOccupancyCrosswalkState state, int skip, int take) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + return (await _crosswalks.GetByStateAsync(departmentId, state, skip, take))?.ToList() ?? new List(); + } + + public async Task LinkCandidateAsync(int departmentId, string userId, string crosswalkId, string occupancyId, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + var candidate = await _crosswalks.GetByIdForDepartmentAsync(departmentId, crosswalkId) ?? throw new ArgumentException("The candidate does not exist."); + if (candidate.State != (int)RmsOccupancyCrosswalkState.Candidate) throw new InvalidOperationException("The candidate was already decided."); + var now = DateTime.UtcNow; + + RmsOccupancy occupancy; + var created = false; + var hazards = new List(); + var provenanceRows = new List(); + if (!string.IsNullOrWhiteSpace(occupancyId)) + occupancy = await LiveAsync(departmentId, occupancyId) ?? throw new ArgumentException("The occupancy does not exist."); + else + { + var built = await BuildFromSourceAsync(departmentId, userId, candidate, now, cancellationToken); + occupancy = built.Occupancy; hazards = built.Hazards; provenanceRows = built.Provenance; created = true; + } + + _unitOfWork.CreateOrGetConnection(); + try + { + if (created) + { + var plaintext = PlaintextSnapshot.Take(occupancy, RmsProtectedFields.Occupancies); + await _protection.ProtectOccupancyAsync(departmentId, occupancy, null, userId, cancellationToken); + await _occupancies.InsertAsync(occupancy, cancellationToken, true); + plaintext.Restore(); + foreach (var hazard in hazards) + { + var hp = PlaintextSnapshot.Take(hazard, RmsProtectedFields.OccupancyHazards); + await _protection.ProtectOccupancyHazardAsync(departmentId, hazard, null, userId, cancellationToken); + await _hazards.InsertAsync(hazard, cancellationToken, true); + hp.Restore(); + } + foreach (var row in provenanceRows) await _provenance.InsertAsync(row, cancellationToken, true); + } + if (!string.IsNullOrWhiteSpace(candidate.ContactId)) + { + var links = (await _links.GetForOccupancyAsync(departmentId, occupancy.RmsOccupancyId))?.ToList() ?? new List(); + if (!links.Any(l => l.ContactId == candidate.ContactId)) + await _links.InsertAsync(new RmsOccupancyContactLink { RmsOccupancyContactLinkId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsOccupancyId = occupancy.RmsOccupancyId, ContactId = candidate.ContactId, Role = (int)RmsOccupancyContactRole.Site, IsPrimary = !links.Any(l => l.IsPrimary), CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, RowVersion = 1 }, cancellationToken, true); + } + candidate.State = (int)RmsOccupancyCrosswalkState.Linked; candidate.RmsOccupancyId = occupancy.RmsOccupancyId; candidate.DecidedOn = now; candidate.DecidedByUserId = userId; candidate.ModifiedOn = now; candidate.RowVersion++; + await _crosswalks.UpdateAsync(candidate, cancellationToken, true); + await RefreshOwnershipCountsAsync(departmentId, now, cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, created ? "Crosswalk candidate linked to a new occupancy" : "Crosswalk candidate linked", occupancy.RmsOccupancyId, new { crosswalkId, candidate.SourceKind, candidate.SourceId }, cancellationToken: cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + return occupancy; + } + + /// + /// Materializes an occupancy from a crosswalk source with field-level provenance. A protected pre-plan is read + /// through the Contacts protected-read seam with the caller's grant; a concealed field is never copied as the + /// REDACTED sentinel — the link is refused until the administrator steps up. + /// + private sealed class BuiltOccupancy + { + public RmsOccupancy Occupancy; + public List Hazards = new List(); + public List Provenance = new List(); + } + + private async Task BuildFromSourceAsync(int departmentId, string userId, RmsOccupancyCrosswalk candidate, DateTime now, CancellationToken cancellationToken) + { + var built = new BuiltOccupancy(); + var occupancy = new RmsOccupancy + { + RmsOccupancyId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), + OccupancyNumber = await _gate.NextNumberAsync(departmentId, RmsPreventionNumberKinds.Occupancy, now, cancellationToken), + Name = candidate.SourceDisplayName ?? "Occupancy", Status = (int)RmsOccupancyStatus.Active, Latitude = candidate.Latitude, Longitude = candidate.Longitude, + NormalizedAddress = candidate.NormalizedAddress, CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, ModifiedByUserId = userId, RowVersion = 1 + }; + var kind = (RmsOccupancyCrosswalkSourceKind)candidate.SourceKind; + var provenance = new List(); + void Prov(string field) => provenance.Add(new RmsOccupancyFieldProvenance { RmsOccupancyFieldProvenanceId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsOccupancyId = occupancy.RmsOccupancyId, FieldKey = field, SourceKind = (int)kind, SourceId = candidate.SourceId, CapturedOn = now, CapturedByUserId = userId }); + Prov("Name"); + if (candidate.Latitude.HasValue) { Prov("Latitude"); Prov("Longitude"); } + + if (kind == RmsOccupancyCrosswalkSourceKind.ContactPreplan) + { + var preplan = await _contactPreplans.GetPreplanByContactIdAsync(candidate.ContactId, departmentId) ?? throw new InvalidOperationException("The source pre-plan no longer exists."); + var read = await _protectedReads.ResolveContactPreplansForReadAsync(departmentId, new[] { preplan }, _grant.GrantToken, _grant.UserId ?? userId, cancellationToken); + if (read.RedactedFields.Count > 0) throw new RecordProtectedContentException("step_up_required", "occupancy crosswalk"); + void Copy(string field, string value, Action set) { if (!string.IsNullOrWhiteSpace(value)) { set(value); Prov(field); } } + occupancy.ConstructionType = preplan.ConstructionType; occupancy.RoofType = preplan.RoofType; occupancy.OccupancyType = preplan.OccupancyType; Prov("ConstructionType"); Prov("RoofType"); Prov("OccupancyType"); + occupancy.OccupantLoad = preplan.OccupantLoad; occupancy.HasOccupantsNeedingAssistance = preplan.HasOccupantsNeedingAssistance; occupancy.HazmatOnSite = preplan.HazmatOnSite; occupancy.RequiredFireFlowGpm = preplan.RequiredFireFlowGpm; + Copy("OccupancyHours", preplan.OccupancyHours, v => occupancy.OccupancyHours = v); + Copy("OccupantsNeedingAssistanceNotes", preplan.OccupantsNeedingAssistanceNotes, v => occupancy.OccupantsNeedingAssistanceNotes = v); + Copy("GasShutoffLocation", preplan.GasShutoffLocation, v => occupancy.GasShutoffLocation = v); + Copy("ElectricShutoffLocation", preplan.ElectricShutoffLocation, v => occupancy.ElectricShutoffLocation = v); + Copy("WaterShutoffLocation", preplan.WaterShutoffLocation, v => occupancy.WaterShutoffLocation = v); + Copy("UtilityNotes", preplan.UtilityNotes, v => occupancy.UtilityNotes = v); + Copy("KnoxBoxLocation", preplan.KnoxBoxLocation, v => occupancy.KnoxBoxLocation = v); + Copy("GateCode", preplan.GateCode, v => occupancy.GateCode = v); + Copy("AlarmPanelLocation", preplan.AlarmPanelLocation, v => occupancy.AlarmPanelLocation = v); + Copy("AlarmCompany", preplan.AlarmCompany, v => occupancy.AlarmCompany = v); + Copy("AlarmCompanyPhone", preplan.AlarmCompanyPhone, v => occupancy.AlarmCompanyPhone = v); + Copy("AccessNotes", preplan.AccessNotes, v => occupancy.AccessNotes = v); + Copy("WaterSupplyNotes", preplan.WaterSupplyNotes, v => occupancy.WaterSupplyNotes = string.IsNullOrWhiteSpace(preplan.NearestHydrantLocation) ? v : $"Nearest hydrant: {preplan.NearestHydrantLocation}\n{v}"); + if (string.IsNullOrWhiteSpace(preplan.WaterSupplyNotes) && !string.IsNullOrWhiteSpace(preplan.NearestHydrantLocation)) { occupancy.WaterSupplyNotes = $"Nearest hydrant: {preplan.NearestHydrantLocation}"; Prov("WaterSupplyNotes"); } + Copy("EmergencyContactName", preplan.EmergencyContactName, v => occupancy.EmergencyContactName = v); + Copy("EmergencyContactPhone", preplan.EmergencyContactPhone, v => occupancy.EmergencyContactPhone = v); + Copy("GeneralHazardNotes", preplan.GeneralHazardNotes, v => occupancy.GeneralHazardNotes = v); + Copy("TacticalSummary", preplan.TacticalSummary, v => occupancy.TacticalSummary = v); + occupancy.LastReviewedOn = preplan.LastReviewedOn; occupancy.ReviewedByUserId = preplan.ReviewedByUserId; occupancy.NextReviewDue = preplan.NextReviewDue; + + var contactHazards = ((await _contactHazards.GetHazardsByContactIdAsync(candidate.ContactId, departmentId)) ?? Enumerable.Empty()).Where(h => !h.IsDeleted).ToList(); + var hazardRead = await _protectedReads.ResolveContactPreplanHazardsForReadAsync(departmentId, contactHazards, _grant.GrantToken, _grant.UserId ?? userId, cancellationToken); + if (hazardRead.RedactedFields.Count > 0) throw new RecordProtectedContentException("step_up_required", "occupancy crosswalk"); + built.Hazards = contactHazards.Select(h => new RmsOccupancyHazard + { + RmsOccupancyHazardId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsOccupancyId = occupancy.RmsOccupancyId, + HazardType = h.HazardType, Severity = h.Severity, Title = h.Title, Description = h.Description, LocationDescription = h.LocationDescription, GpsCoordinates = h.GpsCoordinates, ShouldAlert = h.ShouldAlert, + SourceContactPreplanHazardId = h.ContactPreplanHazardId, CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, RowVersion = 1 + }).ToList(); + if (built.Hazards.Count > 0) Prov("hazards"); + } + else if (kind == RmsOccupancyCrosswalkSourceKind.Poi && int.TryParse(candidate.SourceId, out var poiId)) + { + var poi = await _pois.GetByIdAsync(poiId) as Poi; + if (poi != null) { occupancy.PoiId = poi.PoiId; occupancy.AddressText = RecordsPreventionGate.Trim(poi.Address, 500); if (occupancy.AddressText != null) Prov("AddressText"); } + } + else if (kind == RmsOccupancyCrosswalkSourceKind.Contact) + { + var contact = await _contacts.GetByIdAsync(candidate.ContactId) as Contact; + if (contact?.PhysicalAddressId != null && await _addresses.GetByIdAsync(contact.PhysicalAddressId.Value) is Address address) + { + occupancy.AddressText = RecordsPreventionGate.Trim(address.Address1, 500); occupancy.City = RecordsPreventionGate.Trim(address.City, 120); occupancy.StateProvince = RecordsPreventionGate.Trim(address.State, 120); occupancy.PostalCode = RecordsPreventionGate.Trim(address.PostalCode, 20); occupancy.Country = RecordsPreventionGate.Trim(address.Country, 80); + Prov("AddressText"); + } + } + built.Occupancy = occupancy; + built.Provenance = provenance; + return built; + } + + public async Task RejectCandidateAsync(int departmentId, string userId, string crosswalkId, string reason, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + var candidate = await _crosswalks.GetByIdForDepartmentAsync(departmentId, crosswalkId) ?? throw new ArgumentException("The candidate does not exist."); + if (candidate.State != (int)RmsOccupancyCrosswalkState.Candidate) throw new InvalidOperationException("The candidate was already decided."); + var now = DateTime.UtcNow; + candidate.State = (int)RmsOccupancyCrosswalkState.Rejected; candidate.MatchReason = RecordsPreventionGate.Trim(reason, 250) ?? candidate.MatchReason; candidate.DecidedOn = now; candidate.DecidedByUserId = userId; candidate.ModifiedOn = now; candidate.RowVersion++; + await _crosswalks.UpdateAsync(candidate, cancellationToken, true); + await RefreshOwnershipCountsAsync(departmentId, now, cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, "Crosswalk candidate rejected", departmentId.ToString(), new { crosswalkId, candidate.SourceKind, candidate.SourceId }, cancellationToken: cancellationToken); + } + + public async Task MergeAsync(int departmentId, string userId, string sourceOccupancyId, string targetOccupancyId, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + if (sourceOccupancyId == targetOccupancyId) throw new ArgumentException("Choose two different occupancies."); + var source = await LiveAsync(departmentId, sourceOccupancyId) ?? throw new ArgumentException("The source occupancy does not exist."); + var target = await LiveAsync(departmentId, targetOccupancyId) ?? throw new ArgumentException("The target occupancy does not exist."); + if (source.Status == (int)RmsOccupancyStatus.Merged || target.Status == (int)RmsOccupancyStatus.Merged) throw new InvalidOperationException("A merged occupancy cannot take part in another merge."); + var now = DateTime.UtcNow; + _unitOfWork.CreateOrGetConnection(); + try + { + foreach (var h in (await _hazards.GetForOccupancyAsync(departmentId, sourceOccupancyId)) ?? Enumerable.Empty()) { h.RmsOccupancyId = targetOccupancyId; h.ModifiedOn = now; h.RowVersion++; await _hazards.UpdateAsync(h, cancellationToken, true); } + var targetLinks = (await _links.GetForOccupancyAsync(departmentId, targetOccupancyId))?.ToList() ?? new List(); + foreach (var l in (await _links.GetForOccupancyAsync(departmentId, sourceOccupancyId)) ?? Enumerable.Empty()) + { + if (targetLinks.Any(t => t.ContactId == l.ContactId && t.Role == l.Role)) { l.DeletedOn = now; } + else { l.RmsOccupancyId = targetOccupancyId; l.IsPrimary = l.IsPrimary && !targetLinks.Any(t => t.IsPrimary); } + l.ModifiedOn = now; l.RowVersion++; await _links.UpdateAsync(l, cancellationToken, true); + } + foreach (var c in (await _crosswalks.GetForOccupancyAsync(departmentId, sourceOccupancyId)) ?? Enumerable.Empty()) { c.RmsOccupancyId = targetOccupancyId; c.ModifiedOn = now; c.RowVersion++; await _crosswalks.UpdateAsync(c, cancellationToken, true); } + foreach (var p in (await _provenance.GetForOccupancyAsync(departmentId, sourceOccupancyId)) ?? Enumerable.Empty()) { p.RmsOccupancyId = targetOccupancyId; await _provenance.UpdateAsync(p, cancellationToken, true); } + source.Status = (int)RmsOccupancyStatus.Merged; source.MergedIntoOccupancyId = targetOccupancyId; source.ModifiedOn = now; source.ModifiedByUserId = userId; source.RowVersion++; + await _occupancies.UpdateAsync(source, cancellationToken, true); + target.ModifiedOn = now; target.ModifiedByUserId = userId; target.RowVersion++; + await _occupancies.UpdateAsync(target, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, "Occupancies merged", targetOccupancyId, new { sourceOccupancyId, targetOccupancyId }, cancellationToken: cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + return target; + } + + private async Task RefreshOwnershipCountsAsync(int departmentId, DateTime now, CancellationToken cancellationToken) + { + var ownership = await _ownerships.GetForDepartmentAsync(departmentId); + if (ownership == null) return; + ownership.CandidateCount = await _crosswalks.CountByStateAsync(departmentId, RmsOccupancyCrosswalkState.Candidate); + ownership.LinkedCount = await _crosswalks.CountByStateAsync(departmentId, RmsOccupancyCrosswalkState.Linked); + ownership.RejectedCount = await _crosswalks.CountByStateAsync(departmentId, RmsOccupancyCrosswalkState.Rejected); + ownership.ModifiedOn = now; ownership.RowVersion++; + await _ownerships.UpdateAsync(ownership, cancellationToken, true); + } + + public async Task GetReconciliationStatusAsync(int departmentId) + { + var ownership = await _ownerships.GetForDepartmentAsync(departmentId); + var status = new OccupancyReconciliationStatus + { + State = ownership == null ? RmsOccupancyOwnershipState.ContactsOwned : (RmsOccupancyOwnershipState)ownership.State, + InventoriedOn = ownership?.InventoriedOn, SwitchedOn = ownership?.SwitchedOn, + Candidates = await _crosswalks.CountByStateAsync(departmentId, RmsOccupancyCrosswalkState.Candidate), + Linked = await _crosswalks.CountByStateAsync(departmentId, RmsOccupancyCrosswalkState.Linked), + Rejected = await _crosswalks.CountByStateAsync(departmentId, RmsOccupancyCrosswalkState.Rejected), + Occupancies = await _occupancies.CountLiveAsync(departmentId) + }; + var preplans = ((await _contactPreplans.GetPreplansByDepartmentIdAsync(departmentId)) ?? Enumerable.Empty()).Where(p => !p.IsDeleted).ToList(); + if (preplans.Count > 0) + { + var decided = ((await _crosswalks.GetAllForDepartmentAsync(departmentId)) ?? Enumerable.Empty()) + .Where(c => c.SourceKind == (int)RmsOccupancyCrosswalkSourceKind.ContactPreplan && (c.State == (int)RmsOccupancyCrosswalkState.Linked || c.State == (int)RmsOccupancyCrosswalkState.Rejected || c.State == (int)RmsOccupancyCrosswalkState.Merged)) + .Select(c => c.SourceId).ToHashSet(StringComparer.Ordinal); + status.UnreconciledPreplans = preplans.Count(p => !decided.Contains(p.ContactPreplanId)); + } + return status; + } + + public async Task SwitchWriteOwnershipAsync(int departmentId, string userId, string reason, CancellationToken cancellationToken = default) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); + await _gate.RequireAdminAsync(departmentId, userId); + reason = RecordsPreventionGate.Require(reason, 1000, "Record why structure ownership is moving to Records."); + var status = await GetReconciliationStatusAsync(departmentId); + if (status.State == RmsOccupancyOwnershipState.RecordsOwned) throw new InvalidOperationException("Records already owns structure writes for this department."); + if (!status.CanSwitchToRecords) + throw new InvalidOperationException($"Reconcile first: {status.Candidates} undecided candidate(s) and {status.UnreconciledPreplans} pre-plan(s) without a decision remain{(status.InventoriedOn.HasValue ? "" : "; run the inventory")}."); + var now = DateTime.UtcNow; + var ownership = await _ownerships.GetForDepartmentAsync(departmentId) ?? throw new InvalidOperationException("Run the inventory before switching ownership."); + ownership.State = (int)RmsOccupancyOwnershipState.RecordsOwned; ownership.SwitchedOn = now; ownership.SwitchedByUserId = userId; ownership.Reason = reason; ownership.ModifiedOn = now; ownership.RowVersion++; + await _ownerships.UpdateAsync(ownership, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, "Structure write ownership switched to Records", departmentId.ToString(), new { reason, status.Linked, status.Rejected, status.Occupancies }, cancellationToken: cancellationToken); + return ownership; + } + + #endregion + + #region Dispatch projection and the Contacts gate + + public async Task GetDispatchProjectionAsync(int departmentId, string occupancyId, CancellationToken cancellationToken = default) + { + var occupancy = await LiveAsync(departmentId, occupancyId); + if (occupancy == null) return null; + var projections = await BuildProjectionsAsync(departmentId, new List { occupancy }, cancellationToken); + return projections.TryGetValue(occupancyId, out var projection) ? projection : null; + } + + public async Task GetDispatchProjectionForContactAsync(int departmentId, string contactId, CancellationToken cancellationToken = default) + { + var map = await GetDispatchProjectionsForContactsAsync(departmentId, new[] { contactId }, cancellationToken); + return map.TryGetValue(contactId, out var projection) ? projection : null; + } + + public async Task> GetDispatchProjectionsForContactsAsync(int departmentId, IEnumerable contactIds, CancellationToken cancellationToken = default) + { + var result = new Dictionary(StringComparer.Ordinal); + var ids = (contactIds ?? Enumerable.Empty()).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); + if (ids.Count == 0) return result; + var byContact = await OccupancyIdsForContactsAsync(departmentId, ids); + if (byContact.Count == 0) return result; + var occupancies = ((await _occupancies.GetByIdsAsync(departmentId, byContact.Values.Distinct())) ?? Enumerable.Empty()).Where(o => o.DeletedOn == null).ToList(); + var projections = await BuildProjectionsAsync(departmentId, occupancies, cancellationToken); + foreach (var pair in byContact) + if (projections.TryGetValue(pair.Value, out var projection)) result[pair.Key] = projection; + return result; + } + + private async Task> OccupancyIdsForContactsAsync(int departmentId, List contactIds) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var link in (await _links.GetForContactsAsync(departmentId, contactIds)) ?? Enumerable.Empty()) + if (link.Role == (int)RmsOccupancyContactRole.Site && !map.ContainsKey(link.ContactId)) map[link.ContactId] = link.RmsOccupancyId; + foreach (var cw in (await _crosswalks.GetForContactsAsync(departmentId, contactIds)) ?? Enumerable.Empty()) + if (cw.State == (int)RmsOccupancyCrosswalkState.Linked && cw.RmsOccupancyId != null && cw.ContactId != null && !map.ContainsKey(cw.ContactId)) map[cw.ContactId] = cw.RmsOccupancyId; + // Follow merges so a contact linked to a merged-away occupancy projects from the survivor. + var live = ((await _occupancies.GetByIdsAsync(departmentId, map.Values.Distinct())) ?? Enumerable.Empty()).ToDictionary(o => o.RmsOccupancyId); + foreach (var key in map.Keys.ToList()) + if (live.TryGetValue(map[key], out var o) && o.Status == (int)RmsOccupancyStatus.Merged && !string.IsNullOrWhiteSpace(o.MergedIntoOccupancyId)) map[key] = o.MergedIntoOccupancyId; + return map; + } + + private async Task> BuildProjectionsAsync(int departmentId, List occupancies, CancellationToken cancellationToken) + { + var result = new Dictionary(StringComparer.Ordinal); + if (occupancies.Count == 0) return result; + var ids = occupancies.Select(o => o.RmsOccupancyId).ToList(); + var hazards = ((await _hazards.GetForOccupanciesAsync(departmentId, ids)) ?? Enumerable.Empty()).ToList(); + var links = new List(); + foreach (var id in ids) links.AddRange((await _links.GetForOccupancyAsync(departmentId, id)) ?? Enumerable.Empty()); + var open = await _violations.CountOpenByOccupancyAsync(departmentId, ids) ?? new Dictionary(); + var provenance = new List(); + foreach (var id in ids) provenance.AddRange((await _provenance.GetForOccupancyAsync(departmentId, id)) ?? Enumerable.Empty()); + var hydrantIds = occupancies.Where(o => !string.IsNullOrWhiteSpace(o.NearestHydrantId)).Select(o => o.NearestHydrantId).Distinct().ToList(); + var hydrants = hydrantIds.Count == 0 ? new Dictionary() : ((await _hydrants.GetByIdsAsync(departmentId, hydrantIds)) ?? Enumerable.Empty()).ToDictionary(h => h.RmsHydrantId); + + var read = await _protection.RevealOccupanciesAsync(departmentId, occupancies, cancellationToken); + read.Merge(await _protection.RevealOccupancyHazardsAsync(departmentId, hazards, cancellationToken)); + var now = DateTime.UtcNow; + foreach (var o in occupancies) + { + var p = new OccupancyDispatchProjectionV1 + { + OccupancyId = o.RmsOccupancyId, OccupancyNumber = o.OccupancyNumber, Name = o.Name, Status = o.Status, + AddressText = string.Join(", ", new[] { o.AddressText, o.City, o.StateProvince, o.PostalCode }.Where(x => !string.IsNullOrWhiteSpace(x))), Latitude = o.Latitude, Longitude = o.Longitude, + ConstructionType = o.ConstructionType, RoofType = o.RoofType, OccupancyType = o.OccupancyType, Stories = o.Stories, OccupantLoad = o.OccupantLoad, OccupancyHours = o.OccupancyHours, + HasOccupantsNeedingAssistance = o.HasOccupantsNeedingAssistance, OccupantsNeedingAssistanceNotes = o.OccupantsNeedingAssistanceNotes, + SprinklerType = o.SprinklerType, HasStandpipe = o.HasStandpipe, HasFireAlarm = o.HasFireAlarm, FdcLocation = o.FdcLocation, + GasShutoffLocation = o.GasShutoffLocation, ElectricShutoffLocation = o.ElectricShutoffLocation, WaterShutoffLocation = o.WaterShutoffLocation, UtilityNotes = o.UtilityNotes, + KnoxBoxLocation = o.KnoxBoxLocation, GateCode = o.GateCode, AlarmPanelLocation = o.AlarmPanelLocation, AlarmCompany = o.AlarmCompany, AlarmCompanyPhone = o.AlarmCompanyPhone, AccessNotes = o.AccessNotes, + NearestHydrantId = o.NearestHydrantId, RequiredFireFlowGpm = o.RequiredFireFlowGpm, WaterSupplyNotes = o.WaterSupplyNotes, + EmergencyContactName = o.EmergencyContactName, EmergencyContactPhone = o.EmergencyContactPhone, + HazmatOnSite = o.HazmatOnSite, GeneralHazardNotes = o.GeneralHazardNotes, TacticalSummary = o.TacticalSummary, + LastReviewedOn = o.LastReviewedOn, NextReviewDue = o.NextReviewDue, IsReviewOverdue = o.IsReviewOverdue(now), LastInspectedOn = o.LastInspectedOn, + OpenViolationCount = open.TryGetValue(o.RmsOccupancyId, out var count) ? count : 0, + IsProtected = o.IsProtected || read.IsProtected, ProtectedReason = read.ProtectedReason + }; + if (o.NearestHydrantId != null && hydrants.TryGetValue(o.NearestHydrantId, out var hydrant)) { p.NearestHydrantNumber = hydrant.HydrantNumber; p.NearestHydrantLocation = hydrant.AddressText ?? hydrant.HydrantNumber; } + p.Hazards = hazards.Where(h => h.RmsOccupancyId == o.RmsOccupancyId).Select(h => new OccupancyDispatchHazardV1 + { + HazardId = h.RmsOccupancyHazardId, HazardType = h.HazardType, Severity = h.Severity, Title = h.Title, Description = h.Description, LocationDescription = h.LocationDescription, GpsCoordinates = h.GpsCoordinates, ShouldAlert = h.ShouldAlert, + RedactedFields = read.RedactedFields.Where(f => f.StartsWith("rmsoccupancyhazards.", StringComparison.Ordinal)).ToList() + }).ToList(); + p.Contacts = links.Where(l => l.RmsOccupancyId == o.RmsOccupancyId).Select(l => new OccupancyDispatchContactV1 { ContactId = l.ContactId, Role = l.Role, IsPrimary = l.IsPrimary }).ToList(); + p.Sources = provenance.Where(x => x.RmsOccupancyId == o.RmsOccupancyId).Select(x => x.SourceKind == 0 ? "Records" : ((RmsOccupancyCrosswalkSourceKind)x.SourceKind).ToString()).Distinct().OrderBy(x => x).ToList(); + p.RedactedFields = read.RedactedFields.Where(f => f.StartsWith("rmsoccupancies.", StringComparison.Ordinal)).Distinct().ToList(); + result[o.RmsOccupancyId] = p; + } + return result; + } + + public async Task IsRecordsOwnedAsync(int departmentId) + { + try + { + var ownership = await _ownerships.GetForDepartmentAsync(departmentId); + return ownership != null && ownership.State == (int)RmsOccupancyOwnershipState.RecordsOwned; + } + catch (Exception ex) + { + // A missing table (module never migrated) or a transient failure must never take Contacts down; Contacts stays the owner. + Logging.LogException(ex, $"Occupancy ownership lookup failed for department {departmentId}; treating structure writes as Contacts-owned."); + return false; + } + } + + public async Task> GetPreplanProjectionsAsync(int departmentId, IEnumerable contactIds, CancellationToken cancellationToken = default) + { + var result = new Dictionary(StringComparer.Ordinal); + if (!await IsRecordsOwnedAsync(departmentId)) return result; + foreach (var pair in await GetDispatchProjectionsForContactsAsync(departmentId, contactIds, cancellationToken)) + result[pair.Key] = pair.Value.ToContactPreplanView(pair.Key, departmentId); + return result; + } + + public async Task GetOccupancyIdForContactAsync(int departmentId, string contactId) + { + if (string.IsNullOrWhiteSpace(contactId)) return null; + var map = await OccupancyIdsForContactsAsync(departmentId, new List { contactId }); + return map.TryGetValue(contactId, out var id) ? id : null; + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/RecordsPermitsService.cs b/Core/Resgrid.Services/Records/RecordsPermitsService.cs new file mode 100644 index 000000000..2fcfaa90e --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsPermitsService.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// Permits, plan review cycles, conditions, expiration and the optional fee reference (RMS plan section 4.3, RMS-5). + public class RecordsPermitsService : IRecordsPermitsService + { + public const string PermitAggregate = "RmsPermit"; + private const int SweepBatch = 500; + + private readonly RecordsPreventionGate _gate; + private readonly IRmsPermitTypesRepository _types; + private readonly IRmsPermitsRepository _permits; + private readonly IRmsPlanReviewsRepository _reviews; + private readonly IRmsOccupanciesRepository _occupancies; + private readonly IRmsPreventionAttachmentsRepository _attachments; + private readonly IRecordsProtectionService _protection; + private readonly IDomainEventOutboxService _outbox; + private readonly IUnitOfWork _unitOfWork; + + public RecordsPermitsService(RecordsPreventionGate gate, IRmsPermitTypesRepository types, IRmsPermitsRepository permits, IRmsPlanReviewsRepository reviews, IRmsOccupanciesRepository occupancies, + IRmsPreventionAttachmentsRepository attachments, IRecordsProtectionService protection, IDomainEventOutboxService outbox, IUnitOfWork unitOfWork) + { + _gate = gate; _types = types; _permits = permits; _reviews = reviews; _occupancies = occupancies; _attachments = attachments; _protection = protection; _outbox = outbox; _unitOfWork = unitOfWork; + } + + public Task IsModuleEnabledAsync(int departmentId) => _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Permits); + private async Task RequireViewAsync(int departmentId, string userId) { await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Permits); await _gate.RequireViewerAsync(departmentId, userId); } + private async Task RequireAdminAsync(int departmentId, string userId) { await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Permits); await _gate.RequireAdminAsync(departmentId, userId); } + + public async Task> GetTypesAsync(int departmentId, string userId, bool includeInactive) + { + await RequireViewAsync(departmentId, userId); + return (await _types.GetForDepartmentAsync(departmentId, includeInactive))?.ToList() ?? new List(); + } + + public async Task SaveTypeAsync(int departmentId, string userId, RmsPermitType input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var now = DateTime.UtcNow; + var entity = string.IsNullOrWhiteSpace(input.RmsPermitTypeId) ? null : await _types.GetByIdForDepartmentAsync(departmentId, input.RmsPermitTypeId); + var isNew = entity == null; + if (isNew) entity = new RmsPermitType { RmsPermitTypeId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), CreatedOn = now, CreatedByUserId = userId, RowVersion = 1 }; + else entity.RowVersion++; + entity.Name = RecordsPreventionGate.Require(input.Name, 200, "A permit type needs a name."); + entity.Code = RecordsPreventionGate.Trim(input.Code, 32); entity.Description = RecordsPreventionGate.Trim(input.Description, 4000); + entity.DefaultValidityDays = Math.Clamp(input.DefaultValidityDays <= 0 ? 365 : input.DefaultValidityDays, 1, 3650); + entity.RequiresPlanReview = input.RequiresPlanReview; entity.FeeAmount = input.FeeAmount; entity.ConditionsTemplate = RecordsPreventionGate.Trim(input.ConditionsTemplate, 8000); + entity.IsActive = input.IsActive; entity.ModifiedOn = now; + if (isNew) await _types.InsertAsync(entity, cancellationToken, true); else await _types.UpdateAsync(entity, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, isNew ? "Permit type created" : "Permit type updated", entity.RmsPermitTypeId, new { entity.Name, entity.Code }, cancellationToken: cancellationToken); + return entity; + } + + public async Task> ListAsync(int departmentId, string userId, RmsPermitQuery query) + { + await RequireViewAsync(departmentId, userId); + var rows = (await _permits.QueryAsync(departmentId, query ?? new RmsPermitQuery()))?.ToList() ?? new List(); + await _protection.RevealPermitsAsync(departmentId, rows); + return rows; + } + + public async Task CountAsync(int departmentId, string userId, RmsPermitQuery query) + { + await RequireViewAsync(departmentId, userId); + return await _permits.CountAsync(departmentId, query ?? new RmsPermitQuery()); + } + + public async Task GetAsync(int departmentId, string userId, string permitId) + { + await RequireViewAsync(departmentId, userId); + var permit = await LiveAsync(departmentId, permitId); + if (permit == null) return null; + var aggregate = new PermitAggregate + { + Permit = permit, + Type = await _types.GetByIdForDepartmentAsync(departmentId, permit.RmsPermitTypeId), + Occupancy = string.IsNullOrWhiteSpace(permit.RmsOccupancyId) ? null : await _occupancies.GetByIdForDepartmentAsync(departmentId, permit.RmsOccupancyId), + PlanReviews = (await _reviews.GetForPermitAsync(departmentId, permitId))?.ToList() ?? new List(), + Attachments = (await _attachments.GetMetadataForParentAsync(departmentId, RmsPreventionParentKind.Permit, permitId))?.ToList() ?? new List() + }; + aggregate.Protection.Merge(await _protection.RevealPermitsAsync(departmentId, new[] { permit })); + aggregate.Protection.Merge(await _protection.RevealPlanReviewsAsync(departmentId, aggregate.PlanReviews)); + aggregate.Protection.Merge(await _protection.RevealPreventionAttachmentsAsync(departmentId, aggregate.Attachments, false)); + return aggregate; + } + + public async Task ApplyAsync(int departmentId, string userId, RmsPermit input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var type = await _types.GetByIdForDepartmentAsync(departmentId, input.RmsPermitTypeId); + if (type == null || type.DeletedOn != null || !type.IsActive) throw new ArgumentException("Choose an active permit type."); + if (!string.IsNullOrWhiteSpace(input.RmsOccupancyId)) + { + var occupancy = await _occupancies.GetByIdForDepartmentAsync(departmentId, input.RmsOccupancyId); + if (occupancy == null || occupancy.DeletedOn != null) throw new ArgumentException("The occupancy does not exist."); + } + var now = DateTime.UtcNow; + var permit = new RmsPermit + { + RmsPermitId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsPermitTypeId = type.RmsPermitTypeId, RmsOccupancyId = RecordsPreventionGate.Trim(input.RmsOccupancyId, 36), + PermitNumber = await _gate.NextNumberAsync(departmentId, RmsPreventionNumberKinds.Permit, now, cancellationToken), + ApplicantContactId = RecordsPreventionGate.Trim(input.ApplicantContactId, 128), ApplicantName = RecordsPreventionGate.Trim(input.ApplicantName, 250), ApplicantPhone = RecordsPreventionGate.Trim(input.ApplicantPhone, 50), ApplicantEmail = RecordsPreventionGate.Trim(input.ApplicantEmail, 250), + Description = RecordsPreventionGate.Trim(input.Description, 4000), State = type.RequiresPlanReview ? (int)RmsPermitState.UnderReview : (int)RmsPermitState.Applied, AppliedOn = input.AppliedOn == default ? now : input.AppliedOn, + Conditions = RecordsPreventionGate.Trim(string.IsNullOrWhiteSpace(input.Conditions) ? type.ConditionsTemplate : input.Conditions, 8000), FeeAmount = input.FeeAmount ?? type.FeeAmount, + CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, RowVersion = 1 + }; + var plaintext = PlaintextSnapshot.Take(permit, RmsProtectedFields.Permits); + await _protection.ProtectPermitAsync(departmentId, permit, null, userId, cancellationToken); + await _permits.InsertAsync(permit, cancellationToken, true); + plaintext.Restore(); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Permit application recorded", permit.RmsPermitId, new { permit.PermitNumber, type = type.Name }, cancellationToken: cancellationToken); + return permit; + } + + public async Task UpdateAsync(int departmentId, string userId, RmsPermit input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var permit = await LiveAsync(departmentId, input.RmsPermitId) ?? throw new ArgumentException("The permit does not exist."); + if (permit.State == (int)RmsPermitState.Closed) throw new InvalidOperationException("A closed permit cannot be edited."); + if (input.RowVersion != 0 && input.RowVersion != permit.RowVersion) throw new InvalidOperationException("The permit changed since it was loaded. Reload it before saving."); + var existing = new RmsPermit { ApplicantName = permit.ApplicantName, ApplicantPhone = permit.ApplicantPhone, ApplicantEmail = permit.ApplicantEmail, ReviewNotes = permit.ReviewNotes }; + permit.ApplicantContactId = RecordsPreventionGate.Trim(input.ApplicantContactId, 128); permit.ApplicantName = RecordsPreventionGate.Trim(input.ApplicantName, 250); permit.ApplicantPhone = RecordsPreventionGate.Trim(input.ApplicantPhone, 50); permit.ApplicantEmail = RecordsPreventionGate.Trim(input.ApplicantEmail, 250); + permit.Description = RecordsPreventionGate.Trim(input.Description, 4000); permit.Conditions = RecordsPreventionGate.Trim(input.Conditions, 8000); permit.ReviewNotes = RecordsPreventionGate.Trim(input.ReviewNotes, 8000); + permit.RmsOccupancyId = RecordsPreventionGate.Trim(input.RmsOccupancyId, 36); permit.FeeAmount = input.FeeAmount; + if (input.ExpiresOn.HasValue) permit.ExpiresOn = input.ExpiresOn; if (input.EffectiveOn.HasValue) permit.EffectiveOn = input.EffectiveOn; + permit.ModifiedOn = DateTime.UtcNow; permit.RowVersion++; + var plaintext = PlaintextSnapshot.Take(permit, RmsProtectedFields.Permits); + await _protection.ProtectPermitAsync(departmentId, permit, existing, userId, cancellationToken); + await _permits.UpdateAsync(permit, cancellationToken, true); + plaintext.Restore(); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Permit updated", permit.RmsPermitId, new { permit.PermitNumber }, cancellationToken: cancellationToken); + return permit; + } + + public async Task TransitionAsync(int departmentId, string userId, string permitId, RmsPermitState target, string reason, DateTime? effectiveOn, DateTime? expiresOn, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var permit = await LiveAsync(departmentId, permitId) ?? throw new ArgumentException("The permit does not exist."); + var current = (RmsPermitState)permit.State; + var allowed = (current, target) switch + { + (RmsPermitState.Applied, RmsPermitState.UnderReview) => true, + (RmsPermitState.Applied, RmsPermitState.Approved) => true, + (RmsPermitState.Applied, RmsPermitState.Denied) => true, + (RmsPermitState.UnderReview, RmsPermitState.Approved) => true, + (RmsPermitState.UnderReview, RmsPermitState.Denied) => true, + (RmsPermitState.Approved, RmsPermitState.Issued) => true, + (RmsPermitState.Approved, RmsPermitState.Denied) => true, + (RmsPermitState.Issued, RmsPermitState.Revoked) => true, + (RmsPermitState.Issued, RmsPermitState.Expired) => true, + (RmsPermitState.Issued, RmsPermitState.Closed) => true, + (RmsPermitState.Expired, RmsPermitState.Closed) => true, + (RmsPermitState.Revoked, RmsPermitState.Closed) => true, + (RmsPermitState.Denied, RmsPermitState.Closed) => true, + _ => false + }; + if (!allowed) throw new InvalidOperationException($"A permit cannot move from {current} to {target}."); + if ((target == RmsPermitState.Denied || target == RmsPermitState.Revoked) && string.IsNullOrWhiteSpace(reason)) throw new ArgumentException($"{target} needs a reason."); + var now = DateTime.UtcNow; + var type = await _types.GetByIdForDepartmentAsync(departmentId, permit.RmsPermitTypeId); + switch (target) + { + case RmsPermitState.Approved: permit.ReviewedOn = now; permit.ReviewedByUserId = userId; break; + case RmsPermitState.Denied: permit.ReviewedOn = now; permit.ReviewedByUserId = userId; permit.DecisionReason = RecordsPreventionGate.Trim(reason, 1000); break; + case RmsPermitState.Issued: + permit.IssuedOn = now; permit.IssuedByUserId = userId; permit.EffectiveOn = effectiveOn ?? permit.EffectiveOn ?? now; + permit.ExpiresOn = expiresOn ?? permit.ExpiresOn ?? permit.EffectiveOn.Value.AddDays(type?.DefaultValidityDays ?? 365); + if (permit.ExpiresOn <= permit.EffectiveOn) throw new ArgumentException("The expiry must fall after the effective date."); + break; + case RmsPermitState.Revoked: permit.DecisionReason = RecordsPreventionGate.Trim(reason, 1000); break; + } + permit.State = (int)target; permit.ModifiedOn = now; permit.RowVersion++; + await _permits.UpdateAsync(permit, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, $"Permit {target}", permit.RmsPermitId, new { permit.PermitNumber, from = current.ToString(), to = target.ToString(), permit.ExpiresOn }, cancellationToken: cancellationToken); + return permit; + } + + public async Task RecordPlanReviewAsync(int departmentId, string userId, string permitId, RmsPlanReviewOutcome outcome, string comments, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var permit = await LiveAsync(departmentId, permitId) ?? throw new ArgumentException("The permit does not exist."); + if (permit.State != (int)RmsPermitState.Applied && permit.State != (int)RmsPermitState.UnderReview) throw new InvalidOperationException("Plan review applies to a permit under review."); + var now = DateTime.UtcNow; + var cycle = ((await _reviews.GetForPermitAsync(departmentId, permitId))?.Count() ?? 0) + 1; + var review = new RmsPlanReview + { + RmsPlanReviewId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsPermitId = permit.RmsPermitId, CycleNumber = cycle, SubmittedOn = now, ReviewerUserId = userId, + ReviewedOn = outcome == RmsPlanReviewOutcome.Pending ? null : now, Outcome = (int)outcome, Comments = RecordsPreventionGate.Trim(comments, 8000), CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + var plaintext = PlaintextSnapshot.Take(review, RmsProtectedFields.PlanReviews); + await _protection.ProtectPlanReviewAsync(departmentId, review, null, userId, cancellationToken); + await _reviews.InsertAsync(review, cancellationToken, true); + plaintext.Restore(); + var target = outcome switch { RmsPlanReviewOutcome.Approved => RmsPermitState.Approved, RmsPlanReviewOutcome.Rejected => RmsPermitState.Denied, _ => RmsPermitState.UnderReview }; + if ((int)target != permit.State) + { + permit.State = (int)target; permit.ReviewedOn = outcome == RmsPlanReviewOutcome.Pending ? permit.ReviewedOn : now; permit.ReviewedByUserId = userId; + if (outcome == RmsPlanReviewOutcome.Rejected) permit.DecisionReason = "Plan review rejected"; + permit.ModifiedOn = now; permit.RowVersion++; + await _permits.UpdateAsync(permit, cancellationToken, true); + } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Plan review recorded", permit.RmsPermitId, new { cycle, outcome = outcome.ToString() }, cancellationToken: cancellationToken); + return review; + } + + public async Task RecordFeePaidAsync(int departmentId, string userId, string permitId, decimal amount, string invoiceReference, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var permit = await LiveAsync(departmentId, permitId) ?? throw new ArgumentException("The permit does not exist."); + if (amount < 0) throw new ArgumentException("The fee cannot be negative."); + permit.FeeAmount = amount; permit.FeePaidOn = DateTime.UtcNow; permit.InvoiceReference = RecordsPreventionGate.Trim(invoiceReference, 100); permit.ModifiedOn = permit.FeePaidOn.Value; permit.RowVersion++; + await _permits.UpdateAsync(permit, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Permit fee recorded", permit.RmsPermitId, new { amount, permit.InvoiceReference }, cancellationToken: cancellationToken); + return permit; + } + + public async Task<(int Expired, int Notified)> SweepExpiryAsync(int departmentId, DateTime utcNow, int noticeDays, CancellationToken cancellationToken = default) + { + if (!await IsModuleEnabledAsync(departmentId)) return (0, 0); + var expired = 0; var notified = 0; + var horizon = utcNow.AddDays(Math.Clamp(noticeDays, 1, 365)); + foreach (var permit in (await _permits.GetExpiringAsync(departmentId, utcNow, horizon, SweepBatch)) ?? Enumerable.Empty()) + { + if (permit.ExpiresOn <= utcNow) + { + permit.State = (int)RmsPermitState.Expired; permit.DecisionReason = "Expired"; permit.ModifiedOn = utcNow; permit.RowVersion++; + await _permits.UpdateAsync(permit, cancellationToken, true); + expired++; + continue; + } + if (permit.ExpiringEmittedOn.HasValue) continue; + long outboxId; + _unitOfWork.CreateOrGetConnection(); + try + { + permit.ExpiringEmittedOn = utcNow; permit.ModifiedOn = utcNow; permit.RowVersion++; + await _permits.UpdateAsync(permit, cancellationToken, true); + outboxId = await EnqueueExpiringAsync(permit, utcNow, cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + await _outbox.DispatchAfterCommitAsync(new[] { outboxId }, cancellationToken); + notified++; + } + return (expired, notified); + } + + /// permit.* (trigger 163): identity, type, occupancy, dates — never the applicant identity or review notes. + private async Task EnqueueExpiringAsync(RmsPermit permit, DateTime utcNow, CancellationToken cancellationToken) + { + var type = await _types.GetByIdForDepartmentAsync(permit.DepartmentId, permit.RmsPermitTypeId); + var occupancy = string.IsNullOrWhiteSpace(permit.RmsOccupancyId) ? null : await _occupancies.GetByIdForDepartmentAsync(permit.DepartmentId, permit.RmsOccupancyId); + var entry = await _outbox.EnqueueAsync(permit.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = WorkflowTriggerEventType.RecordPermitExpiring.ToString(), SchemaVersion = 1, AggregateType = PermitAggregate, AggregateId = permit.RmsPermitId, AggregateVersion = (int)permit.RowVersion, + Trigger = WorkflowTriggerEventType.RecordPermitExpiring, + Payload = new Dictionary + { + ["record"] = RecordsInspectionsService.PreventionRecordBlock(permit.DepartmentId), + ["permit"] = new + { + id = permit.RmsPermitId, number = permit.PermitNumber, type_id = permit.RmsPermitTypeId, type_name = type?.Name, type_code = type?.Code, + occupancy_id = permit.RmsOccupancyId, occupancy_number = occupancy?.OccupancyNumber, occupancy_name = occupancy?.Name, state = ((RmsPermitState)permit.State).ToString(), + issued_on = permit.IssuedOn, effective_on = permit.EffectiveOn, expires_on = permit.ExpiresOn, days_until_expiry = permit.ExpiresOn.HasValue ? (int)Math.Ceiling((permit.ExpiresOn.Value - utcNow).TotalDays) : 0, + fee_paid = permit.FeePaidOn.HasValue + }, + ["protection"] = IncidentReportsService.ProtectionBlock(await _protection.GetCatalogVersionAsync(permit.DepartmentId)) + }, + CorrelationId = permit.RmsPermitId, OriginClient = RmsOriginClient.System + }, cancellationToken); + return entry.DomainEventOutboxId; + } + + private async Task LiveAsync(int departmentId, string permitId) + { + if (string.IsNullOrWhiteSpace(permitId)) return null; + var permit = await _permits.GetByIdForDepartmentAsync(departmentId, permitId); + return permit == null || permit.DeletedOn != null ? null : permit; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsPreventionAttachmentsService.cs b/Core/Resgrid.Services/Records/RecordsPreventionAttachmentsService.cs new file mode 100644 index 000000000..4a36c080e --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsPreventionAttachmentsService.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Files on prevention and investigation aggregates. The pipeline is the record-attachment pipeline (hygiene, + /// scanner, checksum, ADP seal). Authorization follows the parent: prevention parents need the module flag and + /// PreventionAdmin to write / Record_View to read; investigation parents need RecordRestricted_View plus active + /// membership on the owning case, and every read of one is audited. + /// + public class RecordsPreventionAttachmentsService : IRecordsPreventionAttachmentsService + { + private readonly RecordsPreventionGate _gate; + private readonly IRmsPreventionAttachmentsRepository _attachments; + private readonly IRmsInvestigationCaseMembersRepository _members; + private readonly IRmsInvestigationEvidenceRepository _evidence; + private readonly IRecordAttachmentScanner _scanner; + private readonly IRecordsProtectionService _protection; + + public RecordsPreventionAttachmentsService(RecordsPreventionGate gate, IRmsPreventionAttachmentsRepository attachments, IRmsInvestigationCaseMembersRepository members, + IRmsInvestigationEvidenceRepository evidence, IRecordAttachmentScanner scanner, IRecordsProtectionService protection) + { + _gate = gate; _attachments = attachments; _members = members; _evidence = evidence; _scanner = scanner; _protection = protection; + } + + private static bool IsInvestigation(RmsPreventionParentKind kind) => kind == RmsPreventionParentKind.InvestigationCase || kind == RmsPreventionParentKind.InvestigationEvidence; + + private static RecordsPreventionModule ModuleOf(RmsPreventionParentKind kind) + { + switch (kind) + { + case RmsPreventionParentKind.Occupancy: return RecordsPreventionModule.Occupancy; + case RmsPreventionParentKind.Inspection: case RmsPreventionParentKind.Violation: return RecordsPreventionModule.Inspections; + case RmsPreventionParentKind.Hydrant: return RecordsPreventionModule.Hydrants; + case RmsPreventionParentKind.Permit: return RecordsPreventionModule.Permits; + case RmsPreventionParentKind.CrrActivity: return RecordsPreventionModule.Crr; + default: return RecordsPreventionModule.Investigations; + } + } + + private async Task RequireAccessAsync(int departmentId, string userId, RmsPreventionParentKind kind, string parentId, bool write) + { + await _gate.RequireEnabledAsync(departmentId, ModuleOf(kind)); + if (!IsInvestigation(kind)) + { + if (write) await _gate.RequireAdminAsync(departmentId, userId); else await _gate.RequireViewerAsync(departmentId, userId); + return; + } + await _gate.RequireRestrictedAsync(departmentId, userId); + var caseId = parentId; + if (kind == RmsPreventionParentKind.InvestigationEvidence) + caseId = (await _evidence.GetByIdForDepartmentAsync(departmentId, parentId))?.RmsInvestigationCaseId ?? throw new ArgumentException("The evidence item does not exist."); + var membership = ((await _members.GetForCaseAsync(departmentId, caseId)) ?? Enumerable.Empty()).FirstOrDefault(m => m.UserId == userId && m.IsActive); + if (membership == null) throw new UnauthorizedAccessException("Only members of the case may access its files."); + if (write && membership.Role == (int)RmsInvestigationRole.ReadOnly) throw new UnauthorizedAccessException("A read-only member may not add files."); + } + + public async Task AddAsync(int departmentId, string userId, RmsPreventionParentKind parentKind, string parentId, string fileName, string contentType, byte[] data, string description, bool restricted, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(parentId)) throw new ArgumentException("The parent is required."); + await RequireAccessAsync(departmentId, userId, parentKind, parentId, true); + var hygiene = RecordAttachmentHygiene.Sanitize(fileName, contentType, data); + var scan = await _scanner.ScanAsync(hygiene.FileName, hygiene.ContentType, hygiene.Data, cancellationToken); + if (scan.State == RmsAttachmentScanState.Rejected) throw new RecordAttachmentRejectedException("The file was rejected by the malware scanner."); + var now = DateTime.UtcNow; + var attachment = new RmsPreventionAttachment + { + RmsPreventionAttachmentId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), ParentKind = (int)parentKind, ParentId = parentId, + FileName = hygiene.FileName, ContentType = hygiene.ContentType, ByteSize = hygiene.Data.LongLength, Checksum = Checksum(hygiene.Data), Data = hygiene.Data, + Description = RecordsPreventionGate.Trim(description, 1000), UploadedByUserId = userId, UploadedOn = now, ScanState = (int)scan.State, MetadataStripped = hygiene.MetadataStripped, + Classification = restricted || IsInvestigation(parentKind) ? (int)RmsEvidenceClassification.Restricted : (int)RmsEvidenceClassification.Unrestricted, + CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + var plaintext = PlaintextSnapshot.Take(attachment, RmsProtectedFields.PreventionAttachments); + var bytes = attachment.Data; + await _protection.ProtectPreventionAttachmentAsync(departmentId, attachment, null, userId, cancellationToken); + await _attachments.InsertAsync(attachment, cancellationToken, true); + plaintext.Restore(); + attachment.Data = bytes; + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Attachment added", parentId, new { attachment.RmsPreventionAttachmentId, parentKind = parentKind.ToString(), attachment.ByteSize, attachment.Checksum, scan = scan.State.ToString() }, cancellationToken: cancellationToken); + return attachment; + } + + public async Task> GetMetadataAsync(int departmentId, string userId, RmsPreventionParentKind parentKind, string parentId) + { + await RequireAccessAsync(departmentId, userId, parentKind, parentId, false); + var rows = (await _attachments.GetMetadataForParentAsync(departmentId, parentKind, parentId))?.ToList() ?? new List(); + await _protection.RevealPreventionAttachmentsAsync(departmentId, rows, false); + return rows; + } + + public async Task GetWithDataAsync(int departmentId, string userId, string attachmentId) + { + var attachment = await _attachments.GetByIdForDepartmentAsync(departmentId, attachmentId); + if (attachment == null || attachment.DeletedOn != null) return null; + var kind = (RmsPreventionParentKind)attachment.ParentKind; + await RequireAccessAsync(departmentId, userId, kind, attachment.ParentId, false); + if (attachment.ScanState == (int)RmsAttachmentScanState.Rejected) throw new InvalidOperationException("The file was rejected by the malware scanner and cannot be downloaded."); + var read = await _protection.RevealPreventionAttachmentsAsync(departmentId, new[] { attachment }, true); + read.RequireRevealed("attachment download"); + if (IsInvestigation(kind)) + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Export, "Investigation file downloaded", attachment.ParentId, new { attachmentId, attachment.ByteSize, attachment.Checksum }); + return attachment; + } + + public async Task RemoveAsync(int departmentId, string userId, string attachmentId, CancellationToken cancellationToken = default) + { + var attachment = await _attachments.GetByIdForDepartmentAsync(departmentId, attachmentId); + if (attachment == null || attachment.DeletedOn != null) throw new ArgumentException("The file does not exist."); + await RequireAccessAsync(departmentId, userId, (RmsPreventionParentKind)attachment.ParentKind, attachment.ParentId, true); + attachment.DeletedOn = DateTime.UtcNow; attachment.ModifiedOn = attachment.DeletedOn.Value; attachment.RowVersion++; + await _attachments.UpdateAsync(attachment, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Attachment removed", attachment.ParentId, new { attachmentId }, cancellationToken: cancellationToken); + } + + private static string Checksum(byte[] data) + { + using var sha = SHA256.Create(); + return BitConverter.ToString(sha.ComputeHash(data ?? Array.Empty())).Replace("-", string.Empty).ToLowerInvariant(); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsPreventionGate.cs b/Core/Resgrid.Services/Records/RecordsPreventionGate.cs new file mode 100644 index 000000000..506a825d8 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsPreventionGate.cs @@ -0,0 +1,128 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// The checks every RMS-5 module service repeats (RMS plan section 6, RMS-5: "each module includes permissions, + /// audit ..."): the module flag on top of the Records cutover, the viewer / prevention-administrator / + /// restricted-viewer permissions, the per-department number sequences and the audit row. One class so a module + /// cannot ship a weaker gate than its siblings. + /// + public sealed class RecordsPreventionGate + { + private readonly IRecordsCutoverService _cutover; + private readonly IFeatureToggleService _flags; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRmsPreventionSequencesRepository _sequences; + private readonly IRmsAccessAuditsRepository _audits; + + public RecordsPreventionGate(IRecordsCutoverService cutover, IFeatureToggleService flags, IRecordsAuthorizationService authorization, + IRmsPreventionSequencesRepository sequences, IRmsAccessAuditsRepository audits) + { + _cutover = cutover; + _flags = flags; + _authorization = authorization; + _sequences = sequences; + _audits = audits; + } + + /// A module is usable only when Records itself is usable for the department and the module flag is on. + public async Task IsEnabledAsync(int departmentId, RecordsPreventionModule module) + { + var state = await _cutover.GetModuleStateAsync(departmentId); + if (state == null || !state.RecordsUsable) + return false; + return await _flags.IsEnabledAsync(RecordsPreventionModules.FlagKey(module), departmentId); + } + + public async Task RequireEnabledAsync(int departmentId, RecordsPreventionModule module) + { + if (!await IsEnabledAsync(departmentId, module)) + throw new RecordsModuleDisabledException(module); + } + + /// Any active department member with Records access may read prevention data. + public async Task RequireViewerAsync(int departmentId, string userId) + { + if (string.IsNullOrWhiteSpace(userId) || !await _authorization.IsActiveMemberAsync(userId, departmentId)) + throw new UnauthorizedAccessException("Records access is required."); + } + + /// Changing prevention data needs the PreventionAdmin permission (registry value 69; department admins by default). + public async Task RequireAdminAsync(int departmentId, string userId) + { + await RequireViewerAsync(departmentId, userId); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.RecordsPreventionAdmin)) + throw new UnauthorizedAccessException("The prevention administrator permission is required."); + } + + /// Investigations sit behind RecordRestricted_View (value 59); case membership is checked on top by the investigations service. + public async Task RequireRestrictedAsync(int departmentId, string userId) + { + await RequireViewerAsync(departmentId, userId); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) + throw new UnauthorizedAccessException("Restricted Records access is required."); + } + + public Task HasAdminAsync(int departmentId, string userId) + => _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.RecordsPreventionAdmin); + + public Task IsDepartmentAdminAsync(int departmentId, string userId) + => _authorization.IsDepartmentAdminAsync(userId, departmentId); + + /// {KIND}-{year}-{sequence:0000}, allocated atomically per department/kind/year. + public async Task NextNumberAsync(int departmentId, string kind, DateTime utcNow, CancellationToken cancellationToken = default) + { + var next = await _sequences.NextAsync(departmentId, kind, utcNow.Year, cancellationToken); + return $"{kind}-{utcNow.Year}-{next:0000}"; + } + + /// + /// Writes an audit row for a prevention or investigation aggregate. RecordId stays null because these are not + /// Records; the aggregate id rides CorrelationId so the audit trail of a case can be read back without the + /// repository's live-record check rewriting the row. + /// + public Task AuditAsync(int departmentId, string userId, RmsAccessAuditAction action, string purpose, string aggregateId, object detail, + bool successful = true, string ipAddress = null, CancellationToken cancellationToken = default) + { + return _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + RecordId = null, + ActorUserId = userId, + Action = (int)action, + Successful = successful, + OccurredOn = DateTime.UtcNow, + Purpose = Trim(purpose, 100), + CorrelationId = Trim(aggregateId, 100), + IpAddress = Trim(ipAddress, 64), + OriginClient = (int)RmsOriginClient.Web, + DetailJson = detail == null ? null : JsonConvert.SerializeObject(detail) + }, cancellationToken, true); + } + + public static string Trim(string value, int max) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + var trimmed = value.Trim(); + return trimmed.Length <= max ? trimmed : trimmed.Substring(0, max); + } + + public static string Require(string value, int max, string message) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException(message); + var trimmed = value.Trim(); + if (trimmed.Length > max) + throw new ArgumentException($"{message} (at most {max} characters)."); + return trimmed; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsProtectionService.cs b/Core/Resgrid.Services/Records/RecordsProtectionService.cs index f8212040b..707ef4f05 100644 --- a/Core/Resgrid.Services/Records/RecordsProtectionService.cs +++ b/Core/Resgrid.Services/Records/RecordsProtectionService.cs @@ -263,6 +263,125 @@ public async Task RevealExportRunsAsync(int departmentId, I #endregion + + #region RMS-5 prevention and investigations (catalog v13) + + public Task ProtectOccupancyAsync(int departmentId, RmsOccupancy row, RmsOccupancy existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsOccupancyId, RmsProtectedFields.Occupancies, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "occupancy", cancellationToken); + + public Task RevealOccupanciesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsOccupancyId), RmsProtectedFields.Occupancies, GrantToken, User, cancellationToken); + + public Task ProtectOccupancyHazardAsync(int departmentId, RmsOccupancyHazard row, RmsOccupancyHazard existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsOccupancyHazardId, RmsProtectedFields.OccupancyHazards, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "occupancy hazard", cancellationToken); + + public Task RevealOccupancyHazardsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsOccupancyHazardId), RmsProtectedFields.OccupancyHazards, GrantToken, User, cancellationToken); + + public Task ProtectInspectionAsync(int departmentId, RmsInspection row, RmsInspection existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsInspectionId, RmsProtectedFields.Inspections, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "inspection", cancellationToken); + + public Task RevealInspectionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsInspectionId), RmsProtectedFields.Inspections, GrantToken, User, cancellationToken); + + public Task ProtectViolationAsync(int departmentId, RmsViolation row, RmsViolation existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsViolationId, RmsProtectedFields.Violations, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "violation", cancellationToken); + + public Task RevealViolationsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsViolationId), RmsProtectedFields.Violations, GrantToken, User, cancellationToken); + + public Task ProtectPermitAsync(int departmentId, RmsPermit row, RmsPermit existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsPermitId, RmsProtectedFields.Permits, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "permit", cancellationToken); + + public Task RevealPermitsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsPermitId), RmsProtectedFields.Permits, GrantToken, User, cancellationToken); + + public Task ProtectPlanReviewAsync(int departmentId, RmsPlanReview row, RmsPlanReview existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsPlanReviewId, RmsProtectedFields.PlanReviews, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "plan review", cancellationToken); + + public Task RevealPlanReviewsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsPlanReviewId), RmsProtectedFields.PlanReviews, GrantToken, User, cancellationToken); + + public Task ProtectInvestigationCaseAsync(int departmentId, RmsInvestigationCase row, RmsInvestigationCase existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsInvestigationCaseId, RmsProtectedFields.InvestigationCases, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "investigation case", cancellationToken); + + public Task RevealInvestigationCasesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsInvestigationCaseId), RmsProtectedFields.InvestigationCases, GrantToken, User, cancellationToken); + + public Task ProtectInvestigationNoteAsync(int departmentId, RmsInvestigationNote row, RmsInvestigationNote existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsInvestigationNoteId, RmsProtectedFields.InvestigationNotes, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "investigation note", cancellationToken); + + public Task RevealInvestigationNotesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsInvestigationNoteId), RmsProtectedFields.InvestigationNotes, GrantToken, User, cancellationToken); + + public Task ProtectInvestigationEvidenceAsync(int departmentId, RmsInvestigationEvidence row, RmsInvestigationEvidence existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsInvestigationEvidenceId, RmsProtectedFields.InvestigationEvidence, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "investigation evidence", cancellationToken); + + public Task RevealInvestigationEvidenceAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsInvestigationEvidenceId), RmsProtectedFields.InvestigationEvidence, GrantToken, User, cancellationToken); + + public Task ProtectInvestigationCustodyAsync(int departmentId, RmsInvestigationCustody row, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, null, row?.RmsInvestigationCustodyId, RmsProtectedFields.InvestigationCustody, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "custody transfer", cancellationToken); + + public Task RevealInvestigationCustodyAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsInvestigationCustodyId), RmsProtectedFields.InvestigationCustody, GrantToken, User, cancellationToken); + + public Task ProtectInvestigationReferralAsync(int departmentId, RmsInvestigationReferral row, RmsInvestigationReferral existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsInvestigationReferralId, RmsProtectedFields.InvestigationReferrals, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "referral", cancellationToken); + + public Task RevealInvestigationReferralsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsInvestigationReferralId), RmsProtectedFields.InvestigationReferrals, GrantToken, User, cancellationToken); + + public Task ProtectQualityReviewAsync(int departmentId, RmsQualityReview row, RmsQualityReview existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsQualityReviewId, RmsProtectedFields.QualityReviews, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "quality review", cancellationToken); + + public Task RevealQualityReviewsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsQualityReviewId), RmsProtectedFields.QualityReviews, GrantToken, User, cancellationToken); + + public async Task ProtectPreventionAttachmentAsync(int departmentId, RmsPreventionAttachment row, RmsPreventionAttachment existing, string userId = null, CancellationToken cancellationToken = default) + { + if (row == null) + return; + await ApplyAsync(departmentId, row, existing, row.RmsPreventionAttachmentId, RmsProtectedFields.PreventionAttachments, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "prevention attachment", cancellationToken); + if (row.Data == null || row.Data.Length == 0 || ProtectedReadService.IsBinaryEnveloped(row.Data)) + return; + var marked = false; + var current = row; + var result = await _writes.PrepareRecordsBinaryWriteAsync(departmentId, RmsProtectedFields.PreventionAttachmentDataFieldId, row.RmsPreventionAttachmentId, row.Data, + bytes => current.Data = bytes, () => marked = true, GrantToken, userId ?? _grant.UserId, Workload, cancellationToken); + if (!result.Success) + throw new RecordProtectedContentException(result.Reason, "prevention attachment"); + if (marked) + { + row.IsProtected = true; + if (row.ProtectedCatalogVersion == 0) + row.ProtectedCatalogVersion = await RequiredCatalogVersionAsync(departmentId, "prevention attachment"); + } + } + + public async Task RevealPreventionAttachmentsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default) + { + var result = await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsPreventionAttachmentId), RmsProtectedFields.PreventionAttachments, GrantToken, User, cancellationToken); + foreach (var attachment in (rows ?? Array.Empty()).Where(r => r != null)) + { + if (attachment.Data == null || !ProtectedReadService.IsBinaryEnveloped(attachment.Data)) + continue; + if (!includeData) + { + attachment.Data = null; + result.IsProtected = true; + result.RedactedFields.Add(RmsProtectedFields.PreventionAttachmentDataFieldId); + continue; + } + var current = attachment; + result.Merge(await _reads.ResolveRecordsBinaryForReadAsync(departmentId, RmsProtectedFields.PreventionAttachmentDataFieldId, current.RmsPreventionAttachmentId, current.Data, + bytes => current.Data = bytes, GrantToken, User, cancellationToken)); + } + return result; + } + + #endregion + #region Explicit-grant reads (IRecordsProtectedReadService) public async Task ResolveAggregateAsync(int departmentId, RecordAggregate aggregate, string grantToken, string userId, CancellationToken cancellationToken = default) diff --git a/Core/Resgrid.Services/Records/RecordsQualityReviewService.cs b/Core/Resgrid.Services/Records/RecordsQualityReviewService.cs new file mode 100644 index 000000000..e2dc2f7ea --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsQualityReviewService.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Post-finalization quality review (RMS plan section 4.7, RMS-4 optional module). A reviewer scores a deterministic + /// sample of finalized Records against a department rubric; findings attach to the Record as a non-mutating QA note + /// and trend by author, unit and definition. Findings never alter the finalized revision — a substantive + /// correction is an amendment, which the lifecycle already handles. + /// + public class RecordsQualityReviewService : IRecordsQualityReviewService + { + private static readonly int[] FinalStates = { (int)RmsRecordState.Finalized, (int)RmsRecordState.Amended, (int)RmsRecordState.Submitted, (int)RmsRecordState.Accepted }; + + private readonly RecordsPreventionGate _gate; + private readonly IRmsQualityRubricsRepository _rubrics; + private readonly IRmsQualityReviewsRepository _reviews; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsRecordUnitResponsesRepository _units; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsProtectionService _protection; + private readonly IRmsAccessAuditsRepository _audits; + + public RecordsQualityReviewService(RecordsPreventionGate gate, IRmsQualityRubricsRepository rubrics, IRmsQualityReviewsRepository reviews, IRmsOperationalRecordsRepository records, + IRmsRecordUnitResponsesRepository units, IRecordsAuthorizationService authorization, IRecordsProtectionService protection, IRmsAccessAuditsRepository audits) + { + _gate = gate; _rubrics = rubrics; _reviews = reviews; _records = records; _units = units; _authorization = authorization; _protection = protection; _audits = audits; + } + + public Task IsModuleEnabledAsync(int departmentId) => _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.QualityReview); + + private async Task RequireReviewerAsync(int departmentId, string userId) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.QualityReview); + await _gate.RequireViewerAsync(departmentId, userId); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ReviewRecords)) throw new UnauthorizedAccessException("The review permission is required."); + } + + private async Task RequireAdminAsync(int departmentId, string userId) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.QualityReview); + if (!await _gate.IsDepartmentAdminAsync(departmentId, userId)) throw new UnauthorizedAccessException("Only a department administrator manages rubrics."); + } + + public async Task> GetRubricsAsync(int departmentId, string userId, bool includeInactive) + { + await RequireReviewerAsync(departmentId, userId); + return (await _rubrics.GetForDepartmentAsync(departmentId, includeInactive))?.ToList() ?? new List(); + } + + public async Task GetRubricAsync(int departmentId, string userId, string rubricId) + { + await RequireReviewerAsync(departmentId, userId); + var rubric = await _rubrics.GetByIdForDepartmentAsync(departmentId, rubricId); + return rubric == null || rubric.DeletedOn != null ? null : rubric; + } + + public async Task SaveRubricAsync(int departmentId, string userId, RmsQualityRubric input, List criteria, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var items = (criteria ?? new List()).Where(c => c != null && !string.IsNullOrWhiteSpace(c.Text)).Select((c, i) => new RmsQualityCriterion { Key = string.IsNullOrWhiteSpace(c.Key) ? $"c{i + 1}" : c.Key.Trim(), Text = c.Text.Trim(), Weight = Math.Clamp(c.Weight <= 0 ? 1 : c.Weight, 1, 10) }).ToList(); + if (items.Count == 0) throw new ArgumentException("A rubric needs at least one criterion."); + if (items.Count > 50) throw new ArgumentException("A rubric holds at most 50 criteria."); + if (items.Select(i => i.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != items.Count) throw new ArgumentException("Criterion keys must be unique."); + var now = DateTime.UtcNow; + var entity = string.IsNullOrWhiteSpace(input.RmsQualityRubricId) ? null : await _rubrics.GetByIdForDepartmentAsync(departmentId, input.RmsQualityRubricId); + var isNew = entity == null; + if (isNew) entity = new RmsQualityRubric { RmsQualityRubricId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), CreatedOn = now, CreatedByUserId = userId, RowVersion = 1 }; + else entity.RowVersion++; + entity.Name = RecordsPreventionGate.Require(input.Name, 200, "A rubric needs a name."); + entity.DefinitionKey = RecordsPreventionGate.Trim(input.DefinitionKey, 100); entity.CriteriaJson = JsonConvert.SerializeObject(items); + entity.SampleSize = Math.Clamp(input.SampleSize <= 0 ? 10 : input.SampleSize, 1, 200); entity.IsActive = input.IsActive; entity.ModifiedOn = now; + if (isNew) await _rubrics.InsertAsync(entity, cancellationToken, true); else await _rubrics.UpdateAsync(entity, cancellationToken, true); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, isNew ? "QA rubric created" : "QA rubric updated", entity.RmsQualityRubricId, new { entity.Name, criteria = items.Count, entity.SampleSize }, cancellationToken: cancellationToken); + return entity; + } + + public static List ParseCriteria(string json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try { return JsonConvert.DeserializeObject>(json) ?? new List(); } + catch (JsonException) { return new List(); } + } + + public static List ParseFindings(string json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try { return JsonConvert.DeserializeObject>(json) ?? new List(); } + catch (JsonException) { return new List(); } + } + + /// + /// Deterministic sampling: candidates are finalized operational records since the window start, not yet reviewed, + /// ordered by SHA-256(rubric, day, record id). The same day and rubric produce the same sample, so two reviewers + /// clicking "sample" do not double up, and the ordering cannot be steered by whoever runs it. + /// + public async Task> SampleAsync(int departmentId, string userId, string rubricId, DateTime sinceUtc, CancellationToken cancellationToken = default) + { + await RequireReviewerAsync(departmentId, userId); + var rubric = await _rubrics.GetByIdForDepartmentAsync(departmentId, rubricId); + if (rubric == null || rubric.DeletedOn != null || !rubric.IsActive) throw new ArgumentException("Choose an active rubric."); + var criteria = ParseCriteria(rubric.CriteriaJson); + if (criteria.Count == 0) throw new InvalidOperationException("The rubric has no criteria."); + var candidates = ((await _records.GetFinalizedSinceAsync(departmentId, sinceUtc)) ?? Enumerable.Empty()) + .Where(r => r.DeletedOn == null && FinalStates.Contains(r.State) && (rubric.DefinitionKey == null || string.Equals(r.DefinitionKey, rubric.DefinitionKey, StringComparison.OrdinalIgnoreCase))) + .ToList(); + if (candidates.Count == 0) return new List(); + var reviewed = ((await _reviews.GetReviewedRecordIdsAsync(departmentId, candidates.Select(c => c.RmsOperationalRecordId))) ?? Enumerable.Empty()).ToHashSet(StringComparer.Ordinal); + var day = DateTime.UtcNow.ToString("yyyyMMdd"); + var picked = candidates.Where(c => !reviewed.Contains(c.RmsOperationalRecordId)).OrderBy(c => Hash($"{rubricId}|{day}|{c.RmsOperationalRecordId}"), StringComparer.Ordinal).Take(rubric.SampleSize).ToList(); + if (picked.Count == 0) return new List(); + var units = ((await _units.GetForRecordsAsync(departmentId, picked.Select(p => p.RmsOperationalRecordId))) ?? Enumerable.Empty()).GroupBy(u => u.RecordId).ToDictionary(g => g.Key, g => g.First().UnitId); + var now = DateTime.UtcNow; + var result = new List(); + foreach (var record in picked) + { + var review = new RmsQualityReview + { + RmsQualityReviewId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsQualityRubricId = rubricId, RecordId = record.RmsOperationalRecordId, RecordKind = (int)RmsRecordKind.Operational, + RevisionId = record.CurrentRevisionId, DefinitionKey = record.DefinitionKey, RecordNumber = record.RecordNumber, AuthorUserId = record.AuthorUserId, UnitId = units.TryGetValue(record.RmsOperationalRecordId, out var unitId) ? unitId : (int?)null, + SampledOn = now, CriteriaJson = rubric.CriteriaJson, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + await _protection.ProtectQualityReviewAsync(departmentId, review, null, userId, cancellationToken); + await _reviews.InsertAsync(review, cancellationToken, true); + result.Add(review); + } + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, "QA sample drawn", rubricId, new { sampled = result.Count, candidates = candidates.Count, since = sinceUtc }, cancellationToken: cancellationToken); + return result; + } + + private static string Hash(string value) + { + using var sha = SHA256.Create(); + return BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-", string.Empty); + } + + public async Task> GetPendingAsync(int departmentId, string userId, int take) + { + await RequireReviewerAsync(departmentId, userId); + var rows = (await _reviews.GetPendingAsync(departmentId, take))?.ToList() ?? new List(); + var visible = new List(); + foreach (var row in rows) + if (await _authorization.CanUserViewRecordAsync(userId, row.RecordId, departmentId)) visible.Add(row); + return visible; + } + + public async Task GetReviewAsync(int departmentId, string userId, string reviewId) + { + await RequireReviewerAsync(departmentId, userId); + var review = await _reviews.GetByIdForDepartmentAsync(departmentId, reviewId); + if (review == null) return null; + if (!await _authorization.CanUserViewRecordAsync(userId, review.RecordId, departmentId)) throw new UnauthorizedAccessException("You cannot view the reviewed record."); + await _protection.RevealQualityReviewsAsync(departmentId, new[] { review }); + return review; + } + + public async Task ScoreAsync(int departmentId, string userId, string reviewId, List findings, string note, bool amendmentRecommended, CancellationToken cancellationToken = default) + { + await RequireReviewerAsync(departmentId, userId); + var review = await _reviews.GetByIdForDepartmentAsync(departmentId, reviewId) ?? throw new ArgumentException("The review does not exist."); + if (!await _authorization.CanUserViewRecordAsync(userId, review.RecordId, departmentId)) throw new UnauthorizedAccessException("You cannot view the reviewed record."); + if (review.ScoredOn.HasValue) throw new InvalidOperationException("The review was already scored."); + if (string.Equals(review.AuthorUserId, userId, StringComparison.Ordinal)) throw new InvalidOperationException("An author does not score their own record."); + var criteria = ParseCriteria(review.CriteriaJson); + findings = (findings ?? new List()).Where(f => f != null && !string.IsNullOrWhiteSpace(f.Key)).ToList(); + var missing = criteria.Where(c => !findings.Any(f => f.Key == c.Key)).Select(c => c.Key).ToList(); + if (missing.Count > 0) throw new ArgumentException($"Every criterion needs a score: {string.Join(", ", missing)}."); + foreach (var f in findings) { f.Score = Math.Clamp(f.Score, 0, 100); f.Note = RecordsPreventionGate.Trim(f.Note, 2000); } + var weight = criteria.Sum(c => c.Weight); + var score = weight == 0 ? 0 : (int)Math.Round(criteria.Sum(c => c.Weight * findings.First(f => f.Key == c.Key).Score) / (double)weight); + var now = DateTime.UtcNow; + var existing = new RmsQualityReview { FindingsJson = review.FindingsJson, Note = review.Note }; + review.ReviewerUserId = userId; review.ScoredOn = now; review.Score = score; review.FindingsJson = JsonConvert.SerializeObject(findings.Where(f => criteria.Any(c => c.Key == f.Key)).ToList()); + review.Note = RecordsPreventionGate.Trim(note, 8000); review.AmendmentRecommended = amendmentRecommended; review.ModifiedOn = now; review.RowVersion++; + var plaintext = PlaintextSnapshot.Take(review, RmsProtectedFields.QualityReviews); + await _protection.ProtectQualityReviewAsync(departmentId, review, existing, userId, cancellationToken); + await _reviews.UpdateAsync(review, cancellationToken, true); + plaintext.Restore(); + // The QA note is attached to the record's audit trail, never to the record itself. + await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = departmentId, RecordId = review.RecordId, RevisionId = review.RevisionId, ActorUserId = userId, Action = (int)RmsAccessAuditAction.Read, Successful = true, OccurredOn = now, Purpose = "Quality review scored", OriginClient = (int)RmsOriginClient.Web, DetailJson = JsonConvert.SerializeObject(new { reviewId, score, amendmentRecommended }) }, cancellationToken, true); + return review; + } + + public async Task> GetForRecordAsync(int departmentId, string userId, string recordId) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.QualityReview); + if (!await _authorization.CanUserViewRecordAsync(userId, recordId, departmentId)) throw new UnauthorizedAccessException("You cannot view that record."); + var rows = (await _reviews.GetForRecordAsync(departmentId, recordId))?.ToList() ?? new List(); + await _protection.RevealQualityReviewsAsync(departmentId, rows); + return rows; + } + + public async Task GetTrendsAsync(int departmentId, string userId, DateTime sinceUtc) + { + await RequireReviewerAsync(departmentId, userId); + var scored = ((await _reviews.GetScoredSinceAsync(departmentId, sinceUtc, 5000)) ?? Enumerable.Empty()).Where(r => r.Score.HasValue).ToList(); + var trends = new RecordsQualityTrends { Since = sinceUtc, Scored = scored.Count, Sampled = scored.Count + ((await _reviews.GetPendingAsync(departmentId, 1000))?.Count() ?? 0), AverageScore = scored.Count == 0 ? 0 : Math.Round(scored.Average(r => r.Score.Value), 1) }; + List Rows(Func key) => scored.Where(r => key(r) != null).GroupBy(key).Select(g => new RecordsQualityTrendRow { Key = g.Key, Label = g.Key, Reviews = g.Count(), AverageScore = Math.Round(g.Average(r => r.Score.Value), 1), AmendmentsRecommended = g.Count(r => r.AmendmentRecommended) }).OrderBy(r => r.AverageScore).ToList(); + trends.ByAuthor = Rows(r => r.AuthorUserId); + trends.ByUnit = Rows(r => r.UnitId?.ToString()); + trends.ByDefinition = Rows(r => r.DefinitionKey); + // Criterion trend needs the plaintext findings; concealed rows (no grant) are skipped rather than counted as zero. + await _protection.RevealQualityReviewsAsync(departmentId, scored); + var byCriterion = new Dictionary(StringComparer.Ordinal); + foreach (var review in scored) + { + var criteria = ParseCriteria(review.CriteriaJson).ToDictionary(c => c.Key, c => c.Text, StringComparer.Ordinal); + foreach (var finding in ParseFindings(review.FindingsJson)) + { + (int Count, long Total, string Text) current = byCriterion.TryGetValue(finding.Key, out var v) ? v : (0, 0L, criteria.TryGetValue(finding.Key, out var text) ? text : finding.Key); + byCriterion[finding.Key] = (current.Count + 1, current.Total + finding.Score, current.Text); + } + } + trends.ByCriterion = byCriterion.Select(kv => new RecordsQualityTrendRow { Key = kv.Key, Label = kv.Value.Text, Reviews = kv.Value.Count, AverageScore = kv.Value.Count == 0 ? 0 : Math.Round(kv.Value.Total / (double)kv.Value.Count, 1) }).OrderBy(r => r.AverageScore).ToList(); + return trends; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsReleaseTelemetryService.cs b/Core/Resgrid.Services/Records/RecordsReleaseTelemetryService.cs new file mode 100644 index 000000000..879b1b8e3 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsReleaseTelemetryService.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// The RMS-4 release telemetry baseline (RMS plan section 6, RMS-4). Every number here already exists in a table + /// the platform writes for its own reasons; this service only counts, so a department administrator, the health + /// endpoint and the daily worker line all see the same figures. No record content is read. + /// + public class RecordsReleaseTelemetryService : IRecordsReleaseTelemetryService + { + /// RMS plan section 5.12: alert at a sustained 60-second outbox backlog. + public const int OutboxLagAlertSeconds = 60; + public const int PermitNoticeDays = 30; + private const int WorkflowRunPage = 500; + + private readonly IRecordsCutoverService _cutover; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IDomainEventOutboxRepository _outbox; + private readonly IWorkflowRunRepository _workflowRuns; + private readonly IRmsRecordAttachmentsRepository _attachments; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsRecordDueStatesRepository _dueStates; + private readonly IRmsSubmissionsRepository _submissions; + private readonly IRmsInspectionsRepository _inspections; + private readonly IRmsViolationsRepository _violations; + private readonly IRmsPermitsRepository _permits; + private readonly IRmsHydrantsRepository _hydrants; + private readonly IDepartmentDataProtectionService _dataProtection; + private readonly RecordsPreventionGate _gate; + + public RecordsReleaseTelemetryService(IRecordsCutoverService cutover, IRecordsAuthorizationService authorization, IRmsAccessAuditsRepository audits, IDomainEventOutboxRepository outbox, + IWorkflowRunRepository workflowRuns, IRmsRecordAttachmentsRepository attachments, IRmsOperationalRecordsRepository records, IRmsRecordDueStatesRepository dueStates, IRmsSubmissionsRepository submissions, + IRmsInspectionsRepository inspections, IRmsViolationsRepository violations, IRmsPermitsRepository permits, IRmsHydrantsRepository hydrants, IDepartmentDataProtectionService dataProtection, RecordsPreventionGate gate) + { + _cutover = cutover; _authorization = authorization; _audits = audits; _outbox = outbox; _workflowRuns = workflowRuns; _attachments = attachments; _records = records; _dueStates = dueStates; + _submissions = submissions; _inspections = inspections; _violations = violations; _permits = permits; _hydrants = hydrants; _dataProtection = dataProtection; _gate = gate; + } + + public async Task GetAsync(int departmentId, string userId, int windowHours = 24) + { + if (!await _authorization.IsDepartmentAdminAsync(userId, departmentId)) throw new UnauthorizedAccessException("Release telemetry is for department administrators."); + return await BuildAsync(departmentId, windowHours); + } + + public async Task LogSnapshotAsync(int departmentId, CancellationToken cancellationToken = default) + { + var snapshot = await BuildAsync(departmentId, 24); + Logging.LogInfo(JsonConvert.SerializeObject(new { rms_release_telemetry = snapshot })); + return snapshot; + } + + private async Task BuildAsync(int departmentId, int windowHours) + { + var window = Math.Clamp(windowHours, 1, 24 * 30); + var now = DateTime.UtcNow; + var since = now.AddHours(-window); + var t = new RecordsReleaseTelemetry { DepartmentId = departmentId, GeneratedOn = now, WindowHours = window }; + + var state = await _cutover.GetModuleStateAsync(departmentId); + t.RecordsActive = state?.RecordsUsable ?? false; + t.ActivatedOn = state?.ActivatedOn; + + await Try(t, "audits", async () => + { + t.LegacyWriteAttempts = await _audits.CountByActionSinceAsync(departmentId, (int)RmsAccessAuditAction.LegacyWriteDenied, since); + t.AuthorizationDenials = await _audits.CountByActionSinceAsync(departmentId, (int)RmsAccessAuditAction.Denied, since); + }); + await Try(t, "outbox", async () => + { + t.OutboxPending = await _outbox.CountByStateForDepartmentAsync(departmentId, (int)DomainEventOutboxState.Pending); + t.OutboxFailed = await _outbox.CountByStateForDepartmentAsync(departmentId, (int)DomainEventOutboxState.Failed); + var oldest = await _outbox.GetOldestPendingCreatedOnForDepartmentAsync(departmentId); + t.OutboxOldestPendingSeconds = oldest.HasValue ? Math.Max(0, (now - oldest.Value).TotalSeconds) : (double?)null; + t.OutboxLagAlert = t.OutboxPending > 0 && t.OutboxOldestPendingSeconds >= OutboxLagAlertSeconds; + }); + await Try(t, "workflow runs", async () => + { + // One bounded page of the most recent runs; a department with more than 500 runs in the window is already visible on the Workflows health page. + var runs = ((await _workflowRuns.GetByDepartmentIdPagedAsync(departmentId, 1, WorkflowRunPage)) ?? Enumerable.Empty()).Where(r => r.StartedOn >= since || r.QueuedOn >= since).ToList(); + t.WorkflowRunsCompleted = runs.Count(r => r.Status == (int)WorkflowRunStatus.Completed); + t.WorkflowRunsSkipped = runs.Count(r => r.Status == (int)WorkflowRunStatus.Skipped); + t.WorkflowRunsFailed = runs.Count(r => r.Status == (int)WorkflowRunStatus.Failed); + t.WorkflowRunsRunning = runs.Count(r => r.Status == (int)WorkflowRunStatus.Running || r.Status == (int)WorkflowRunStatus.Pending || r.Status == (int)WorkflowRunStatus.Retrying); + }); + await Try(t, "attachments", async () => + { + t.AttachmentsScanPending = await _attachments.CountByScanStateAsync(departmentId, (int)RmsAttachmentScanState.Pending); + t.AttachmentsScanRejected = await _attachments.CountByScanStateAsync(departmentId, (int)RmsAttachmentScanState.Rejected); + }); + await Try(t, "records", async () => + { + t.RecordsCreated = await _records.CountCreatedSinceAsync(departmentId, since); + t.RecordsFinalized = await _records.CountFinalizedSinceAsync(departmentId, since); + t.RecordsOverdue = await _dueStates.CountOverdueAsync(departmentId); + }); + await Try(t, "submissions", async () => + { + t.SubmissionsFailed = await _submissions.CountByStateAsync(departmentId, (int)RmsSubmissionState.Failed); + t.SubmissionsAwaiting = await _submissions.CountByStateAsync(departmentId, (int)RmsSubmissionState.Queued) + await _submissions.CountByStateAsync(departmentId, (int)RmsSubmissionState.InFlight) + await _submissions.CountByStateAsync(departmentId, (int)RmsSubmissionState.AwaitingDestination); + }); + await Try(t, "prevention", async () => + { + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Inspections)) + { + t.InspectionsDue = await _inspections.CountAsync(departmentId, new RmsInspectionQuery { States = new List { (int)RmsInspectionState.Scheduled }, ScheduledBefore = now }); + t.ViolationsOverdue = await _violations.CountOverdueAsync(departmentId, now); + } + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Permits)) + t.PermitsExpiringSoon = await _permits.CountExpiringAsync(departmentId, now, now.AddDays(PermitNoticeDays)); + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Hydrants)) + t.HydrantsOutOfService = await _hydrants.CountOutOfServiceAsync(departmentId); + }); + await Try(t, "protection", async () => + { + t.ProtectionEnforced = await _dataProtection.IsProtectionEnforcedAsync(departmentId); + t.ProtectedCatalogVersion = await _dataProtection.GetPinnedCatalogVersionAsync(departmentId); + }); + t.SearchEnabled = Config.SearchConfig.Enabled; + + if (t.OutboxLagAlert) t.Alerts.Add($"Outbox backlog: {t.OutboxPending} pending, oldest {Math.Round(t.OutboxOldestPendingSeconds ?? 0)} s (threshold {OutboxLagAlertSeconds} s)."); + if (t.OutboxFailed > 0) t.Alerts.Add($"{t.OutboxFailed} outbox event(s) exhausted their retries."); + if (t.LegacyWriteAttempts > 0) t.Alerts.Add($"{t.LegacyWriteAttempts} legacy Log/UnitLog write attempt(s) were denied in the last {window} h."); + if (t.SubmissionsFailed > 0) t.Alerts.Add($"{t.SubmissionsFailed} NERIS submission(s) failed."); + if (t.AttachmentsScanRejected > 0) t.Warnings.Add($"{t.AttachmentsScanRejected} attachment(s) were rejected by the scanner."); + if (t.AttachmentsScanPending > 0) t.Warnings.Add($"{t.AttachmentsScanPending} attachment(s) are still waiting for a scan."); + if (t.RecordsOverdue > 0) t.Warnings.Add($"{t.RecordsOverdue} record obligation(s) are overdue."); + if (t.AuthorizationDenials > 0) t.Warnings.Add($"{t.AuthorizationDenials} Records action(s) were denied in the last {window} h."); + if (t.ViolationsOverdue > 0) t.Warnings.Add($"{t.ViolationsOverdue} code violation(s) are past their correction date."); + if (t.WorkflowRunsFailed > 0) t.Warnings.Add($"{t.WorkflowRunsFailed} Workflow run(s) failed in the last {window} h."); + return t; + } + + private static async Task Try(RecordsReleaseTelemetry t, string area, Func work) + { + try { await work(); } + catch (Exception ex) + { + Logging.LogException(ex, $"Release telemetry: {area} counters unavailable for department {t.DepartmentId}."); + t.Warnings.Add($"{area} counters unavailable."); + } + } + } + + /// Daily prevention sweep (worker 42): due inspections, overdue violations, permit expiry, hydrant tests due, and the release telemetry line. + public class RecordsPreventionSweepService : IRecordsPreventionSweepService + { + private readonly IRmsDepartmentCutoversRepository _cutovers; + private readonly RecordsPreventionGate _gate; + private readonly IRecordsInspectionsService _inspections; + private readonly IRecordsPermitsService _permits; + private readonly IRecordsHydrantsService _hydrants; + private readonly IRecordsReleaseTelemetryService _telemetry; + private readonly IRmsOccupanciesRepository _occupancies; + private readonly IRmsInspectionsRepository _inspectionRows; + private readonly IRmsViolationsRepository _violationRows; + private readonly IRmsHydrantsRepository _hydrantRows; + private readonly IRmsPermitsRepository _permitRows; + private readonly IRmsCrrActivitiesRepository _crrRows; + private readonly IRmsInvestigationCasesRepository _caseRows; + + public RecordsPreventionSweepService(IRmsDepartmentCutoversRepository cutovers, RecordsPreventionGate gate, IRecordsInspectionsService inspections, IRecordsPermitsService permits, IRecordsHydrantsService hydrants, + IRecordsReleaseTelemetryService telemetry, IRmsOccupanciesRepository occupancies, IRmsInspectionsRepository inspectionRows, IRmsViolationsRepository violationRows, IRmsHydrantsRepository hydrantRows, + IRmsPermitsRepository permitRows, IRmsCrrActivitiesRepository crrRows, IRmsInvestigationCasesRepository caseRows) + { + _cutovers = cutovers; _gate = gate; _inspections = inspections; _permits = permits; _hydrants = hydrants; _telemetry = telemetry; _occupancies = occupancies; _inspectionRows = inspectionRows; + _violationRows = violationRows; _hydrantRows = hydrantRows; _permitRows = permitRows; _crrRows = crrRows; _caseRows = caseRows; + } + + public async Task SweepAsync(CancellationToken cancellationToken = default) + { + var result = new RecordsPreventionSweepResult(); + var now = DateTime.UtcNow; + foreach (var cutover in (await _cutovers.GetActiveAsync())?.ToList() ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + result.DepartmentsEvaluated++; + try + { + var department = await SweepDepartmentAsync(cutover.DepartmentId, now, cancellationToken); + result.InspectionsGenerated += department.InspectionsGenerated; result.ViolationsBecameOverdue += department.ViolationsBecameOverdue; + result.PermitsExpiringNotified += department.PermitsExpiringNotified; result.PermitsExpired += department.PermitsExpired; result.HydrantTestsDue += department.HydrantTestsDue; result.Errors += department.Errors; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Prevention sweep failed for department {cutover.DepartmentId}."); + result.Errors++; + } + } + return result; + } + + public async Task SweepDepartmentAsync(int departmentId, DateTime utcNow, CancellationToken cancellationToken = default) + { + var result = new RecordsPreventionSweepResult { DepartmentsEvaluated = 1 }; + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Inspections)) + { + result.InspectionsGenerated = await _inspections.GenerateDueInspectionsAsync(departmentId, utcNow, cancellationToken); + result.ViolationsBecameOverdue = await _inspections.EmitOverdueViolationsAsync(departmentId, utcNow, cancellationToken); + } + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Permits)) + { + var (expired, notified) = await _permits.SweepExpiryAsync(departmentId, utcNow, RecordsReleaseTelemetryService.PermitNoticeDays, cancellationToken); + result.PermitsExpired = expired; result.PermitsExpiringNotified = notified; + } + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Hydrants)) + result.HydrantTestsDue = await _hydrants.CountTestDueAsync(departmentId, utcNow); + try { await _telemetry.LogSnapshotAsync(departmentId, cancellationToken); } + catch (Exception ex) { Logging.LogException(ex, $"Release telemetry snapshot failed for department {departmentId}."); result.Errors++; } + return result; + } + + public async Task GetSummaryAsync(int departmentId, string userId) + { + await _gate.RequireViewerAsync(departmentId, userId); + var now = DateTime.UtcNow; + var summary = new PreventionSummary(); + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Occupancy)) + { + summary.Occupancies = await _occupancies.CountLiveAsync(departmentId); + summary.OccupanciesReviewOverdue = await _occupancies.CountReviewOverdueAsync(departmentId, now); + } + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Inspections)) + { + summary.InspectionsScheduled = await _inspectionRows.CountAsync(departmentId, new RmsInspectionQuery { States = new List { (int)RmsInspectionState.Scheduled, (int)RmsInspectionState.InProgress } }); + summary.InspectionsDue = await _inspectionRows.CountAsync(departmentId, new RmsInspectionQuery { States = new List { (int)RmsInspectionState.Scheduled }, ScheduledBefore = now }); + summary.ViolationsOpen = await _violationRows.CountOpenAsync(departmentId); + summary.ViolationsOverdue = await _violationRows.CountOverdueAsync(departmentId, now); + } + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Hydrants)) + { + summary.Hydrants = await _hydrantRows.CountLiveAsync(departmentId); + summary.HydrantsOutOfService = await _hydrantRows.CountOutOfServiceAsync(departmentId); + summary.HydrantsTestDue = await _hydrants.CountTestDueAsync(departmentId, now); + } + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Permits)) + { + summary.PermitsActive = await _permitRows.CountAsync(departmentId, new RmsPermitQuery { States = new List { (int)RmsPermitState.Issued } }); + summary.PermitsAwaitingReview = await _permitRows.CountAsync(departmentId, new RmsPermitQuery { States = new List { (int)RmsPermitState.Applied, (int)RmsPermitState.UnderReview } }); + summary.PermitsExpiringSoon = await _permitRows.CountExpiringAsync(departmentId, now, now.AddDays(RecordsReleaseTelemetryService.PermitNoticeDays)); + } + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Crr)) + { + var rows = ((await _crrRows.GetForRangeAsync(departmentId, now.AddDays(-90), now.AddDays(1), 2000)) ?? Enumerable.Empty()).ToList(); + summary.CrrActivitiesLast90Days = rows.Count; summary.CrrSmokeAlarmsLast90Days = rows.Sum(r => r.SmokeAlarmsInstalled); + } + if (await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Investigations)) + summary.InvestigationCasesOpen = await _caseRows.CountOpenAsync(departmentId); + return summary; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsService.cs b/Core/Resgrid.Services/Records/RecordsService.cs index 096874e37..2b626ddd5 100644 --- a/Core/Resgrid.Services/Records/RecordsService.cs +++ b/Core/Resgrid.Services/Records/RecordsService.cs @@ -213,6 +213,9 @@ public async Task CreateDraftAsync(int departmentId, string use } record.DisplaySummary = definitionVersion == null ? BuildDisplaySummary(recordType, record, details, units) : definitionVersion.DefinitionKey; + record.CardinalityKey = BuildCardinalityKey(record, details, CardinalityFor(record, definitionVersion)); + await GuardCardinalityAsync(record, definitionVersion); + var outboxIds = new List(); // Concurrent creates can read the same maximum before either has inserted, and // UX_RmsOperationalRecords_Department_RecordNumber then rejects the loser. Its number was never used, so @@ -251,17 +254,31 @@ await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Chan string.IsNullOrWhiteSpace(input.DuplicateContinueReason) ? null : new { duplicateContinueReason = input.DuplicateContinueReason }); }); } - catch (DbException) when (scopedKey != null) - { - var winner = await _records.GetByIdempotencyKeyAsync(departmentId, scopedKey); - if (winner == null) throw; - return await ReplayCreateAsync(departmentId, userId, winner, requestChecksum); - } - catch (DbException) when (allocatesOnCreate && attempt < NumberAllocationRetries) + // One catch for every reason an insert can be rejected: an exception filter cannot await, so the + // idempotency lookup happens here. A key that finds no winner falls through to the other causes rather + // than rethrowing — a number collision on an idempotent create is still a retryable collision. + catch (DbException) when (scopedKey != null || record.CardinalityKey != null || (allocatesOnCreate && attempt < NumberAllocationRetries)) { - // The transaction rolled back, so nothing of this attempt survives; reallocate and go round again. - record.RecordNumber = null; - continue; + if (scopedKey != null) + { + var winner = await _records.GetByIdempotencyKeyAsync(departmentId, scopedKey); + if (winner != null) + return await ReplayCreateAsync(departmentId, userId, winner, requestChecksum); + } + if (record.CardinalityKey != null) + { + // Two creates passed the read-side guard and the index rejected this one. Re-read: if the winner is + // there, the author gets the same answer they would have got a moment earlier. + await GuardCardinalityAsync(record, definitionVersion); + throw; + } + if (allocatesOnCreate && attempt < NumberAllocationRetries) + { + // The transaction rolled back, so nothing of this attempt survives; reallocate and go round again. + record.RecordNumber = null; + continue; + } + throw; } break; } @@ -607,6 +624,8 @@ await InTransactionAsync(async () => var revision = await WriteRevisionAsync(record, draft, RmsRevisionTransition.Voided, userId, reasonCode, reasonText, AttestationStatementVersion, now, cancellationToken); record.State = (int)RmsRecordState.Voided; + // A voided Record no longer holds its cardinality slot; the replacement must be creatable (plan 5.2.1). + record.CardinalityKey = null; record.VoidedOn = now; record.VoidedByUserId = userId; record.VoidReasonCode = reasonCode; @@ -639,6 +658,8 @@ await InTransactionAsync(async () => { await GuardVersionAsync(record, record.RowVersion, cancellationToken); record.State = (int)RmsRecordState.Cancelled; + // Same as void: an abandoned draft must not block the Record that replaces it. + record.CardinalityKey = null; record.CancelledOn = now; record.CancelledByUserId = userId; record.ModifiedOn = now; @@ -1186,6 +1207,60 @@ private async Task RestoreDraftFromSnapshotAsync(RmsOperationalRecord record, Re record.EndedOn = snapshot.EndedOn; } + /// + /// The cardinality rule in force for a Record: the version's own value for a department definition, and the + /// locked table for a system definition, which is where NERIS and Unit Activity get theirs (plan 5.2.1). + /// + private static RmsRecordCardinality CardinalityFor(RmsOperationalRecord record, RmsRecordDefinitionVersion definitionVersion) + => definitionVersion == null + ? RmsDefinitionKeys.CardinalityFor(record.DefinitionKey) + : (RmsRecordCardinality)definitionVersion.Cardinality; + + /// + /// The value a filtered unique index enforces, or null when nothing is to be enforced. Every rule is keyed on + /// the Call, so a Record without one is never constrained — the same fallback incident-scoped numbering takes. + /// + private static string BuildCardinalityKey(RmsOperationalRecord record, RmsOperationalRecordDetail details, RmsRecordCardinality cardinality) + { + if (cardinality == RmsRecordCardinality.MultiplePerCall || !record.CallId.HasValue) + return null; + + if (cardinality == RmsRecordCardinality.SingleAuthoritative) + // The reporting entity is the department, and DepartmentId is already the first column of the index. + return $"single:{record.CallId.Value}:{record.DefinitionKey}"; + + // OnePerSubjectPerCall: the subject is the unit the Record is about when it names one — Unit Activity — + // and otherwise the author, which is what a per-person record (responder exposure) is keyed on. + var subject = details?.UnitId.HasValue == true + ? "unit:" + details.UnitId.Value + : "person:" + record.AuthorUserId; + return $"subject:{record.CallId.Value}:{record.DefinitionKey}:{subject}"; + } + + /// + /// Reads before writing so the author is handed the Record that already exists instead of a database error. + /// The index is still the authority: two simultaneous creates both pass this check and the loser's insert is + /// rejected, which is why the caller sees from either path. + /// + private async Task GuardCardinalityAsync(RmsOperationalRecord record, RmsRecordDefinitionVersion definitionVersion) + { + if (record.CardinalityKey == null) + return; + + var existing = (await _records.GetByCallAsync(record.DepartmentId, record.CallId.Value))? + .FirstOrDefault(r => string.Equals(r.CardinalityKey, record.CardinalityKey, StringComparison.Ordinal) + && !string.Equals(r.RmsOperationalRecordId, record.RmsOperationalRecordId, StringComparison.Ordinal)); + if (existing == null) + return; + + var cardinality = CardinalityFor(record, definitionVersion); + var name = definitionVersion?.DefinitionKey ?? record.DefinitionKey; + throw new RecordCardinalityException(existing.RmsOperationalRecordId, record.DefinitionKey, cardinality, + cardinality == RmsRecordCardinality.SingleAuthoritative + ? $"This call already has its {name} record. Open the existing record instead of starting another." + : $"This call already has a {name} record for that subject. Open the existing record instead of starting another."); + } + private async Task AllocateRecordNumberAsync(RmsOperationalRecord record, CancellationToken cancellationToken) { var config = await _settings.GetRecordsNumberingConfigAsync(record.DepartmentId); diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 1275ff7a4..8938efda1 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -296,6 +296,18 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-5 prevention + investigations, RMS-4 quality review and release telemetry + builder.RegisterType().AsSelf().InstancePerLifetimeScope(); + builder.RegisterType().As().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index d60b9e649..79c8066fe 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -532,6 +532,9 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve case WorkflowTriggerEventType.RecordEvidenceCaptured: case WorkflowTriggerEventType.RecordPurged: case WorkflowTriggerEventType.RecordExportScheduled: + case WorkflowTriggerEventType.RecordInspectionCompleted: + case WorkflowTriggerEventType.RecordViolationOverdue: + case WorkflowTriggerEventType.RecordPermitExpiring: case WorkflowTriggerEventType.RecordDefinitionPublished: case WorkflowTriggerEventType.RecordDefinitionRetired: AddRecordsSamples(obj, eventType); @@ -869,6 +872,68 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType obj["export"] = x; } + if (eventType == WorkflowTriggerEventType.RecordInspectionCompleted) + { + r["kind"] = "Prevention"; + var i = new ScriptObject(); + i["id"] = "3e4f5a6b-7c8d-4e9f-a0b1-c2d3e4f5a6b7"; + i["number"] = "INSP-2026-0031"; + i["occupancy_id"] = "4f5a6b7c-8d9e-4f0a-b1c2-d3e4f5a6b7c8"; + i["occupancy_number"] = "OCC-2026-0012"; + i["occupancy_name"] = "Riverside Assisted Living"; + i["program_id"] = "5a6b7c8d-9e0f-4a1b-c2d3-e4f5a6b7c8d9"; + i["program_name"] = "Annual life-safety inspection"; + i["state"] = "ReinspectionRequired"; + i["result"] = "Fail"; + i["scheduled_on"] = DateTime.Now.AddDays(-3); + i["completed_on"] = DateTime.Now; + i["inspector_user_id"] = "00000000-0000-0000-0000-000000000003"; + i["violation_count"] = 2; + i["critical_violation_count"] = 1; + i["is_reinspection"] = false; + obj["inspection"] = i; + } + + if (eventType == WorkflowTriggerEventType.RecordViolationOverdue) + { + r["kind"] = "Prevention"; + var v = new ScriptObject(); + v["id"] = "6b7c8d9e-0f1a-4b2c-d3e4-f5a6b7c8d9e0"; + v["inspection_id"] = "3e4f5a6b-7c8d-4e9f-a0b1-c2d3e4f5a6b7"; + v["occupancy_id"] = "4f5a6b7c-8d9e-4f0a-b1c2-d3e4f5a6b7c8"; + v["occupancy_number"] = "OCC-2026-0012"; + v["occupancy_name"] = "Riverside Assisted Living"; + v["code_set_id"] = "7c8d9e0f-1a2b-4c3d-e4f5-a6b7c8d9e0f1"; + v["code_section_id"] = "8d9e0f1a-2b3c-4d4e-f5a6-b7c8d9e0f1a2"; + v["code_section_number"] = "IFC 1031.2"; + v["severity"] = "Serious"; + v["state"] = "Open"; + v["due_on"] = DateTime.Now.AddDays(-4); + v["days_overdue"] = 4; + obj["violation"] = v; + } + + if (eventType == WorkflowTriggerEventType.RecordPermitExpiring) + { + r["kind"] = "Prevention"; + var p = new ScriptObject(); + p["id"] = "9e0f1a2b-3c4d-4e5f-a6b7-c8d9e0f1a2b3"; + p["number"] = "PRM-2026-0107"; + p["type_id"] = "0f1a2b3c-4d5e-4f6a-b7c8-d9e0f1a2b3c4"; + p["type_name"] = "Hot work"; + p["type_code"] = "HW"; + p["occupancy_id"] = "4f5a6b7c-8d9e-4f0a-b1c2-d3e4f5a6b7c8"; + p["occupancy_number"] = "OCC-2026-0012"; + p["occupancy_name"] = "Riverside Assisted Living"; + p["state"] = "Issued"; + p["issued_on"] = DateTime.Now.AddDays(-335); + p["effective_on"] = DateTime.Now.AddDays(-335); + p["expires_on"] = DateTime.Now.AddDays(30); + p["days_until_expiry"] = 30; + p["fee_paid"] = true; + obj["permit"] = p; + } + if (eventType == WorkflowTriggerEventType.RecordSubmittedForReview || eventType == WorkflowTriggerEventType.RecordReturnedForCorrection) { var returned = eventType == WorkflowTriggerEventType.RecordReturnedForCorrection; diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 225c78df7..3de89201b 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -425,6 +425,9 @@ public async Task BuildContextAsync( case WorkflowTriggerEventType.RecordEvidenceCaptured: case WorkflowTriggerEventType.RecordPurged: case WorkflowTriggerEventType.RecordExportScheduled: + case WorkflowTriggerEventType.RecordInspectionCompleted: + case WorkflowTriggerEventType.RecordViolationOverdue: + case WorkflowTriggerEventType.RecordPermitExpiring: { // Records (RMS): the payload is the outbox snapshot carried by RecordsWorkflowEvent; it is never // rehydrated from current record state, so a retry sees exactly what the original run saw. @@ -1221,7 +1224,7 @@ private static string MapRecordsEventVariables(ScriptObject obj, RecordsWorkflow obj["obligation"] = ToScriptObject(obligation); // RMS-3e blocks (plan section 5.6): each is present only on the triggers that carry it. - foreach (var name in new[] { "attachment", "disclosure", "legal_hold", "evidence", "purge", "export", "definition", "fields" }) + foreach (var name in new[] { "attachment", "disclosure", "legal_hold", "evidence", "purge", "export", "definition", "fields", "inspection", "violation", "permit" }) { if (payload[name] is JObject block) obj[name] = ToScriptObject(block); diff --git a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs index 30909dea4..63b15c49e 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs @@ -1753,6 +1753,8 @@ public static RecordClaimGrant[] RecordClaimGrants(PermissionTypes type) return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.RecordLegalHold, ResgridClaimTypes.Actions.Update) }; case PermissionTypes.ReassignRecordDrafts: return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Reassign) }; + case PermissionTypes.RecordsPreventionAdmin: + return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.PreventionAdmin) }; default: return NoGrants; } diff --git a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs index 439693666..77073e3bd 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs @@ -22,6 +22,8 @@ public static class Actions public const string Publish = "Publish"; public const string Share = "Share"; public const string Reassign = "Reassign"; + /// RMS-5: administer prevention data (occupancies, inspections, hydrants, permits, CRR). + public const string PreventionAdmin = "PreventionAdmin"; } public static class Memberships diff --git a/Providers/Resgrid.Providers.Claims/ResgridResources.cs b/Providers/Resgrid.Providers.Claims/ResgridResources.cs index 8d500ff9d..4db61fa8e 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridResources.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridResources.cs @@ -199,5 +199,6 @@ public static class ResgridResources public const string RecordReport_Update = "RecordReport_Update"; public const string RecordDisclosure_Update = "RecordDisclosure_Update"; public const string RecordLegalHold_Update = "RecordLegalHold_Update"; + public const string Record_PreventionAdmin = "Record_PreventionAdmin"; } } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0182_AddRmsRecordCardinality.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0182_AddRmsRecordCardinality.cs new file mode 100644 index 000000000..7e79dbdda --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0182_AddRmsRecordCardinality.cs @@ -0,0 +1,41 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Per-definition record cardinality (RMS plan section 5.2.1, registry M0182). Two parts: the rule a definition + /// version declares, and the key that enforces it. + /// + /// The filtered unique index is the enforcement, not the service check in front of it. Two engine companies on + /// the same fire produce one official incident report and two company-level records, and which of those a + /// definition is must be a property of the definition rather than a global rule. A Record that is voided or + /// cancelled has its key cleared, so an abandoned Record never blocks the one that replaces it. + /// + [Migration(182)] + public class M0182_AddRmsRecordCardinality : Migration + { + public override void Up() + { + if (Schema.Table("RmsRecordDefinitionVersions").Exists() && !Schema.Table("RmsRecordDefinitionVersions").Column("Cardinality").Exists()) + Alter.Table("RmsRecordDefinitionVersions").AddColumn("Cardinality").AsInt32().NotNullable().WithDefaultValue(2); + + if (Schema.Table("RmsOperationalRecords").Exists() && !Schema.Table("RmsOperationalRecords").Column("CardinalityKey").Exists()) + { + Alter.Table("RmsOperationalRecords").AddColumn("CardinalityKey").AsString(400).Nullable(); + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsOperationalRecords_Department_CardinalityKey ON RmsOperationalRecords (DepartmentId, CardinalityKey) WHERE CardinalityKey IS NOT NULL;"); + } + } + + public override void Down() + { + if (Schema.Table("RmsOperationalRecords").Exists() && Schema.Table("RmsOperationalRecords").Index("UX_RmsOperationalRecords_Department_CardinalityKey").Exists()) + Delete.Index("UX_RmsOperationalRecords_Department_CardinalityKey").OnTable("RmsOperationalRecords"); + + if (Schema.Table("RmsOperationalRecords").Exists() && Schema.Table("RmsOperationalRecords").Column("CardinalityKey").Exists()) + Delete.Column("CardinalityKey").FromTable("RmsOperationalRecords"); + + if (Schema.Table("RmsRecordDefinitionVersions").Exists() && Schema.Table("RmsRecordDefinitionVersions").Column("Cardinality").Exists()) + Delete.Column("Cardinality").FromTable("RmsRecordDefinitionVersions"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0183_AddContactPreplans.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0183_AddContactPreplans.cs new file mode 100644 index 000000000..b41506937 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0183_AddContactPreplans.cs @@ -0,0 +1,103 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Contact pre-incident plans (Contacts plan Phase A, A1; registry M0183, taken as the next physical + /// number under the no-gaps rule): the 1:1 structured NFPA 1620 pre-plan per Contact and its repeating + /// typed premise hazards. Both tables carry the ADP IsProtected row marker: the text columns are catalog v12 fields + /// (ProtectedFieldCatalog.ContactPreplansCatalogVersion) and are enveloped in a protected department. Guarded for safe retry. + /// + [Migration(183)] + public class M0183_AddContactPreplans : Migration + { + public override void Up() + { + if (!Schema.Table("ContactPreplans").Exists()) + { + Create.Table("ContactPreplans") + .WithColumn("ContactPreplanId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ContactId").AsString(128).NotNullable() + .WithColumn("ConstructionType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RoofType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("OccupancyType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("OccupancyNotes").AsString(int.MaxValue).Nullable() + .WithColumn("OccupancyHours").AsString(500).Nullable() + .WithColumn("OccupantLoad").AsInt32().Nullable() + .WithColumn("HasOccupantsNeedingAssistance").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("OccupantsNeedingAssistanceNotes").AsString(int.MaxValue).Nullable() + .WithColumn("GasShutoffLocation").AsString(1000).Nullable() + .WithColumn("ElectricShutoffLocation").AsString(1000).Nullable() + .WithColumn("WaterShutoffLocation").AsString(1000).Nullable() + .WithColumn("UtilityNotes").AsString(int.MaxValue).Nullable() + .WithColumn("KnoxBoxLocation").AsString(1000).Nullable() + .WithColumn("GateCode").AsString(1000).Nullable() + .WithColumn("AlarmPanelLocation").AsString(1000).Nullable() + .WithColumn("AlarmCompany").AsString(500).Nullable() + .WithColumn("AlarmCompanyPhone").AsString(100).Nullable() + .WithColumn("AccessNotes").AsString(int.MaxValue).Nullable() + .WithColumn("NearestHydrantLocation").AsString(1000).Nullable() + .WithColumn("RequiredFireFlowGpm").AsInt32().Nullable() + .WithColumn("WaterSupplyNotes").AsString(int.MaxValue).Nullable() + .WithColumn("EmergencyContactName").AsString(500).Nullable() + .WithColumn("EmergencyContactPhone").AsString(100).Nullable() + .WithColumn("SecondaryContactName").AsString(500).Nullable() + .WithColumn("SecondaryContactPhone").AsString(100).Nullable() + .WithColumn("HazmatOnSite").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("GeneralHazardNotes").AsString(int.MaxValue).Nullable() + .WithColumn("TacticalSummary").AsString(int.MaxValue).Nullable() + .WithColumn("LastReviewedOn").AsDateTime2().Nullable() + .WithColumn("ReviewedByUserId").AsString(128).Nullable() + .WithColumn("NextReviewDue").AsDateTime2().Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false); + + Create.Index("IX_ContactPreplans_Department").OnTable("ContactPreplans") + .OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + + // One live pre-plan per contact; soft-deleted rows do not block a replacement. + Execute.Sql("CREATE UNIQUE INDEX [UX_ContactPreplans_Contact_Live] ON [ContactPreplans] ([ContactId]) WHERE [IsDeleted] = 0;"); + } + + if (!Schema.Table("ContactPreplanHazards").Exists()) + { + Create.Table("ContactPreplanHazards") + .WithColumn("ContactPreplanHazardId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("ContactPreplanId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ContactId").AsString(128).NotNullable() + .WithColumn("HazardType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Severity").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Title").AsString(500).NotNullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("LocationDescription").AsString(1000).Nullable() + .WithColumn("GpsCoordinates").AsString(100).Nullable() + .WithColumn("ShouldAlert").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false); + + Create.Index("IX_ContactPreplanHazards_Preplan").OnTable("ContactPreplanHazards") + .OnColumn("ContactPreplanId").Ascending().OnColumn("IsDeleted").Ascending(); + Create.Index("IX_ContactPreplanHazards_Contact").OnTable("ContactPreplanHazards") + .OnColumn("DepartmentId").Ascending().OnColumn("ContactId").Ascending().OnColumn("IsDeleted").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("ContactPreplanHazards").Exists()) + Delete.Table("ContactPreplanHazards"); + if (Schema.Table("ContactPreplans").Exists()) + Delete.Table("ContactPreplans"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0184_AddContactAttachments.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0184_AddContactAttachments.cs new file mode 100644 index 000000000..8604c457a --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0184_AddContactAttachments.cs @@ -0,0 +1,44 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Contact site attachments (Contacts plan Phase A, A1; registry M0184, next physical number under the + /// no-gaps rule): typed blob rows (document, pre-plan, floor plan, site photo, site drawing) mirroring + /// CallAttachments, including the ADP IsProtected row marker (name, file name and bytes are catalog v12 fields). + /// List queries read metadata only. Guarded for safe retry. + /// + [Migration(184)] + public class M0184_AddContactAttachments : Migration + { + public override void Up() + { + if (!Schema.Table("ContactAttachments").Exists()) + { + Create.Table("ContactAttachments") + .WithColumn("ContactAttachmentId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("ContactId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ContactAttachmentType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Name").AsString(500).Nullable() + .WithColumn("FileName").AsString(500).Nullable() + .WithColumn("FileType").AsString(200).Nullable() + .WithColumn("Size").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Data").AsBinary(int.MaxValue).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false); + + Create.Index("IX_ContactAttachments_Contact").OnTable("ContactAttachments") + .OnColumn("DepartmentId").Ascending().OnColumn("ContactId").Ascending().OnColumn("IsDeleted").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("ContactAttachments").Exists()) + Delete.Table("ContactAttachments"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0185_RmsReleaseReadiness.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0185_RmsReleaseReadiness.cs new file mode 100644 index 000000000..9a104a5ac --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0185_RmsReleaseReadiness.cs @@ -0,0 +1,124 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// RMS-4 release readiness (RMS plan section 6, RMS-4; registry M0185, taken physically under the no-gaps rule in + /// place of the M0174 reservation): the covering indexes for the hot Records read paths (queue by state and + /// modified time, call lookups, finalized-since windows, idempotency replay, group-scope recompute, due-state + /// lookups, per-department outbox and audit counts for the release telemetry, scan-state and submission-state + /// counts), plus the optional post-finalization quality review tables (plan section 4.7): a department rubric + /// and one non-mutating review per sampled revision. Guarded for safe retry. + /// + [Migration(185)] + public class M0185_RmsReleaseReadiness : Migration + { + public override void Up() + { + if (Schema.Table("RmsOperationalRecords").Exists() && !Schema.Table("RmsOperationalRecords").Index("IX_RmsOperationalRecords_Department_State_Modified").Exists()) + Create.Index("IX_RmsOperationalRecords_Department_State_Modified").OnTable("RmsOperationalRecords").OnColumn("DepartmentId").Ascending().OnColumn("State").Ascending().OnColumn("ModifiedOn").Ascending(); + if (Schema.Table("RmsOperationalRecords").Exists() && !Schema.Table("RmsOperationalRecords").Index("IX_RmsOperationalRecords_Department_Call").Exists()) + Create.Index("IX_RmsOperationalRecords_Department_Call").OnTable("RmsOperationalRecords").OnColumn("DepartmentId").Ascending().OnColumn("CallId").Ascending(); + if (Schema.Table("RmsOperationalRecords").Exists() && !Schema.Table("RmsOperationalRecords").Index("IX_RmsOperationalRecords_Department_Finalized").Exists()) + Create.Index("IX_RmsOperationalRecords_Department_Finalized").OnTable("RmsOperationalRecords").OnColumn("DepartmentId").Ascending().OnColumn("FinalizedOn").Ascending(); + if (Schema.Table("RmsOperationalRecords").Exists() && !Schema.Table("RmsOperationalRecords").Index("IX_RmsOperationalRecords_Department_Idempotency").Exists()) + Create.Index("IX_RmsOperationalRecords_Department_Idempotency").OnTable("RmsOperationalRecords").OnColumn("DepartmentId").Ascending().OnColumn("IdempotencyKey").Ascending(); + if (Schema.Table("RmsIncidentReports").Exists() && !Schema.Table("RmsIncidentReports").Index("IX_RmsIncidentReports_Department_Modified").Exists()) + Create.Index("IX_RmsIncidentReports_Department_Modified").OnTable("RmsIncidentReports").OnColumn("DepartmentId").Ascending().OnColumn("ModifiedOn").Ascending(); + if (Schema.Table("RmsRecordGroupScopes").Exists() && !Schema.Table("RmsRecordGroupScopes").Index("IX_RmsRecordGroupScopes_Department_Record").Exists()) + Create.Index("IX_RmsRecordGroupScopes_Department_Record").OnTable("RmsRecordGroupScopes").OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending(); + if (Schema.Table("RmsRecordDueStates").Exists() && !Schema.Table("RmsRecordDueStates").Index("IX_RmsRecordDueStates_Department_Record").Exists()) + Create.Index("IX_RmsRecordDueStates_Department_Record").OnTable("RmsRecordDueStates").OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending(); + if (Schema.Table("DomainEventOutbox").Exists() && !Schema.Table("DomainEventOutbox").Index("IX_DomainEventOutbox_Department_State_Created").Exists()) + Create.Index("IX_DomainEventOutbox_Department_State_Created").OnTable("DomainEventOutbox").OnColumn("DepartmentId").Ascending().OnColumn("State").Ascending().OnColumn("CreatedOn").Ascending(); + if (Schema.Table("RmsAccessAudits").Exists() && !Schema.Table("RmsAccessAudits").Index("IX_RmsAccessAudits_Department_Action_Occurred").Exists()) + Create.Index("IX_RmsAccessAudits_Department_Action_Occurred").OnTable("RmsAccessAudits").OnColumn("DepartmentId").Ascending().OnColumn("Action").Ascending().OnColumn("OccurredOn").Ascending(); + if (Schema.Table("RmsRecordAttachments").Exists() && !Schema.Table("RmsRecordAttachments").Index("IX_RmsRecordAttachments_Department_ScanState").Exists()) + Create.Index("IX_RmsRecordAttachments_Department_ScanState").OnTable("RmsRecordAttachments").OnColumn("DepartmentId").Ascending().OnColumn("ScanState").Ascending(); + if (Schema.Table("RmsSubmissions").Exists() && !Schema.Table("RmsSubmissions").Index("IX_RmsSubmissions_Department_State").Exists()) + Create.Index("IX_RmsSubmissions_Department_State").OnTable("RmsSubmissions").OnColumn("DepartmentId").Ascending().OnColumn("State").Ascending(); + + if (!Schema.Table("RmsQualityRubrics").Exists()) + { + Create.Table("RmsQualityRubrics") + .WithColumn("RmsQualityRubricId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("DefinitionKey").AsString(100).Nullable() + .WithColumn("CriteriaJson").AsString(int.MaxValue).NotNullable() + .WithColumn("SampleSize").AsInt32().NotNullable().WithDefaultValue(10) + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsQualityRubrics_Department").OnTable("RmsQualityRubrics").OnColumn("DepartmentId").Ascending().OnColumn("IsActive").Ascending(); + } + if (!Schema.Table("RmsQualityReviews").Exists()) + { + Create.Table("RmsQualityReviews") + .WithColumn("RmsQualityReviewId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsQualityRubricId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RecordKind").AsInt32().NotNullable() + .WithColumn("RevisionId").AsString(36).Nullable() + .WithColumn("DefinitionKey").AsString(100).Nullable() + .WithColumn("RecordNumber").AsString(64).Nullable() + .WithColumn("AuthorUserId").AsString(128).Nullable() + .WithColumn("UnitId").AsInt32().Nullable() + .WithColumn("ReviewerUserId").AsString(128).Nullable() + .WithColumn("SampledOn").AsDateTime2().NotNullable() + .WithColumn("ScoredOn").AsDateTime2().Nullable() + .WithColumn("Score").AsInt32().Nullable() + .WithColumn("CriteriaJson").AsString(int.MaxValue).NotNullable() + .WithColumn("FindingsJson").AsString(int.MaxValue).Nullable() + .WithColumn("Note").AsString(int.MaxValue).Nullable() + .WithColumn("AmendmentRecommended").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsQualityReviews_Department_Record").OnTable("RmsQualityReviews").OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending(); + Create.Index("IX_RmsQualityReviews_Department_Scored").OnTable("RmsQualityReviews").OnColumn("DepartmentId").Ascending().OnColumn("ScoredOn").Ascending(); + Create.Index("IX_RmsQualityReviews_Department_Rubric_Sampled").OnTable("RmsQualityReviews").OnColumn("DepartmentId").Ascending().OnColumn("RmsQualityRubricId").Ascending().OnColumn("SampledOn").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("RmsQualityReviews").Exists()) + Delete.Table("RmsQualityReviews"); + if (Schema.Table("RmsQualityRubrics").Exists()) + Delete.Table("RmsQualityRubrics"); + if (Schema.Table("RmsOperationalRecords").Exists() && Schema.Table("RmsOperationalRecords").Index("IX_RmsOperationalRecords_Department_State_Modified").Exists()) + Delete.Index("IX_RmsOperationalRecords_Department_State_Modified").OnTable("RmsOperationalRecords"); + if (Schema.Table("RmsOperationalRecords").Exists() && Schema.Table("RmsOperationalRecords").Index("IX_RmsOperationalRecords_Department_Call").Exists()) + Delete.Index("IX_RmsOperationalRecords_Department_Call").OnTable("RmsOperationalRecords"); + if (Schema.Table("RmsOperationalRecords").Exists() && Schema.Table("RmsOperationalRecords").Index("IX_RmsOperationalRecords_Department_Finalized").Exists()) + Delete.Index("IX_RmsOperationalRecords_Department_Finalized").OnTable("RmsOperationalRecords"); + if (Schema.Table("RmsOperationalRecords").Exists() && Schema.Table("RmsOperationalRecords").Index("IX_RmsOperationalRecords_Department_Idempotency").Exists()) + Delete.Index("IX_RmsOperationalRecords_Department_Idempotency").OnTable("RmsOperationalRecords"); + if (Schema.Table("RmsIncidentReports").Exists() && Schema.Table("RmsIncidentReports").Index("IX_RmsIncidentReports_Department_Modified").Exists()) + Delete.Index("IX_RmsIncidentReports_Department_Modified").OnTable("RmsIncidentReports"); + if (Schema.Table("RmsRecordGroupScopes").Exists() && Schema.Table("RmsRecordGroupScopes").Index("IX_RmsRecordGroupScopes_Department_Record").Exists()) + Delete.Index("IX_RmsRecordGroupScopes_Department_Record").OnTable("RmsRecordGroupScopes"); + if (Schema.Table("RmsRecordDueStates").Exists() && Schema.Table("RmsRecordDueStates").Index("IX_RmsRecordDueStates_Department_Record").Exists()) + Delete.Index("IX_RmsRecordDueStates_Department_Record").OnTable("RmsRecordDueStates"); + if (Schema.Table("DomainEventOutbox").Exists() && Schema.Table("DomainEventOutbox").Index("IX_DomainEventOutbox_Department_State_Created").Exists()) + Delete.Index("IX_DomainEventOutbox_Department_State_Created").OnTable("DomainEventOutbox"); + if (Schema.Table("RmsAccessAudits").Exists() && Schema.Table("RmsAccessAudits").Index("IX_RmsAccessAudits_Department_Action_Occurred").Exists()) + Delete.Index("IX_RmsAccessAudits_Department_Action_Occurred").OnTable("RmsAccessAudits"); + if (Schema.Table("RmsRecordAttachments").Exists() && Schema.Table("RmsRecordAttachments").Index("IX_RmsRecordAttachments_Department_ScanState").Exists()) + Delete.Index("IX_RmsRecordAttachments_Department_ScanState").OnTable("RmsRecordAttachments"); + if (Schema.Table("RmsSubmissions").Exists() && Schema.Table("RmsSubmissions").Index("IX_RmsSubmissions_Department_State").Exists()) + Delete.Index("IX_RmsSubmissions_Department_State").OnTable("RmsSubmissions"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0186_AddRmsPreventionAndInvestigations.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0186_AddRmsPreventionAndInvestigations.cs new file mode 100644 index 000000000..afbb56f62 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0186_AddRmsPreventionAndInvestigations.cs @@ -0,0 +1,783 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// RMS-5 prevention and investigations (RMS plan sections 4.3, 4.4 and 6; registry M0186): the occupancy/property + /// master with role-separated Contact links, hazards, the ContactPreplan/Contact/POI crosswalk, field provenance and + /// the per-department structure-write ownership row; inspection programs, adopted code sets, inspections and + /// violations; hydrants with flow tests and maintenance; permit types, permits and plan reviews; community risk + /// reduction activities; prevention attachments and number sequences; investigation cases with members, linked + /// incidents, notes, evidence, an append-only custody chain and referrals. Seeds the seven module feature flags off. + /// Every table carries DepartmentId and ProtectionId; the cataloged text columns (ADP catalog v13) carry the + /// IsProtected marker. Guarded for safe retry. + /// + [Migration(186)] + public class M0186_AddRmsPreventionAndInvestigations : Migration + { + public override void Up() + { + if (!Schema.Table("RmsOccupancies").Exists()) + { + Create.Table("RmsOccupancies") + .WithColumn("RmsOccupancyId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("OccupancyNumber").AsString(32).Nullable() + .WithColumn("Name").AsString(250).NotNullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("MergedIntoOccupancyId").AsString(36).Nullable() + .WithColumn("AddressText").AsString(500).Nullable() + .WithColumn("City").AsString(120).Nullable() + .WithColumn("StateProvince").AsString(120).Nullable() + .WithColumn("PostalCode").AsString(20).Nullable() + .WithColumn("Country").AsString(80).Nullable() + .WithColumn("NormalizedAddress").AsString(500).Nullable() + .WithColumn("Latitude").AsDecimal(12, 8).Nullable() + .WithColumn("Longitude").AsDecimal(12, 8).Nullable() + .WithColumn("ParcelId").AsString(100).Nullable() + .WithColumn("ConstructionType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RoofType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("OccupancyType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Stories").AsInt32().Nullable() + .WithColumn("YearBuilt").AsInt32().Nullable() + .WithColumn("SquareFeet").AsInt32().Nullable() + .WithColumn("OccupantLoad").AsInt32().Nullable() + .WithColumn("OccupancyHours").AsString(250).Nullable() + .WithColumn("HasOccupantsNeedingAssistance").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("OccupantsNeedingAssistanceNotes").AsString(int.MaxValue).Nullable() + .WithColumn("SprinklerType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("HasStandpipe").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("HasFireAlarm").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("FdcLocation").AsString(250).Nullable() + .WithColumn("GasShutoffLocation").AsString(500).Nullable() + .WithColumn("ElectricShutoffLocation").AsString(500).Nullable() + .WithColumn("WaterShutoffLocation").AsString(500).Nullable() + .WithColumn("UtilityNotes").AsString(int.MaxValue).Nullable() + .WithColumn("KnoxBoxLocation").AsString(int.MaxValue).Nullable() + .WithColumn("GateCode").AsString(int.MaxValue).Nullable() + .WithColumn("AlarmPanelLocation").AsString(int.MaxValue).Nullable() + .WithColumn("AlarmCompany").AsString(int.MaxValue).Nullable() + .WithColumn("AlarmCompanyPhone").AsString(int.MaxValue).Nullable() + .WithColumn("AccessNotes").AsString(int.MaxValue).Nullable() + .WithColumn("NearestHydrantId").AsString(36).Nullable() + .WithColumn("RequiredFireFlowGpm").AsInt32().Nullable() + .WithColumn("WaterSupplyNotes").AsString(int.MaxValue).Nullable() + .WithColumn("EmergencyContactName").AsString(int.MaxValue).Nullable() + .WithColumn("EmergencyContactPhone").AsString(int.MaxValue).Nullable() + .WithColumn("HazmatOnSite").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("GeneralHazardNotes").AsString(int.MaxValue).Nullable() + .WithColumn("TacticalSummary").AsString(int.MaxValue).Nullable() + .WithColumn("PoiId").AsInt32().Nullable() + .WithColumn("LastReviewedOn").AsDateTime2().Nullable() + .WithColumn("ReviewedByUserId").AsString(128).Nullable() + .WithColumn("NextReviewDue").AsDateTime2().Nullable() + .WithColumn("LastInspectedOn").AsDateTime2().Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedByUserId").AsString(128).Nullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsOccupancies_Department_Status").OnTable("RmsOccupancies").OnColumn("DepartmentId").Ascending().OnColumn("Status").Ascending().OnColumn("DeletedOn").Ascending(); + Create.Index("IX_RmsOccupancies_Department_Address").OnTable("RmsOccupancies").OnColumn("DepartmentId").Ascending().OnColumn("NormalizedAddress").Ascending(); + Create.Index("IX_RmsOccupancies_Department_Review").OnTable("RmsOccupancies").OnColumn("DepartmentId").Ascending().OnColumn("NextReviewDue").Ascending(); + } + if (!Schema.Table("RmsOccupancyContactLinks").Exists()) + { + Create.Table("RmsOccupancyContactLinks") + .WithColumn("RmsOccupancyContactLinkId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsOccupancyId").AsString(36).NotNullable() + .WithColumn("ContactId").AsString(128).NotNullable() + .WithColumn("Role").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsPrimary").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsOccupancyContactLinks_Occupancy").OnTable("RmsOccupancyContactLinks").OnColumn("DepartmentId").Ascending().OnColumn("RmsOccupancyId").Ascending(); + Create.Index("IX_RmsOccupancyContactLinks_Contact").OnTable("RmsOccupancyContactLinks").OnColumn("DepartmentId").Ascending().OnColumn("ContactId").Ascending(); + } + if (!Schema.Table("RmsOccupancyHazards").Exists()) + { + Create.Table("RmsOccupancyHazards") + .WithColumn("RmsOccupancyHazardId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsOccupancyId").AsString(36).NotNullable() + .WithColumn("HazardType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Severity").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("Title").AsString(200).NotNullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("LocationDescription").AsString(int.MaxValue).Nullable() + .WithColumn("GpsCoordinates").AsString(int.MaxValue).Nullable() + .WithColumn("ShouldAlert").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("SourceContactPreplanHazardId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsOccupancyHazards_Occupancy").OnTable("RmsOccupancyHazards").OnColumn("DepartmentId").Ascending().OnColumn("RmsOccupancyId").Ascending(); + } + if (!Schema.Table("RmsOccupancyCrosswalks").Exists()) + { + Create.Table("RmsOccupancyCrosswalks") + .WithColumn("RmsOccupancyCrosswalkId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsOccupancyId").AsString(36).Nullable() + .WithColumn("SourceKind").AsInt32().NotNullable() + .WithColumn("SourceId").AsString(128).NotNullable() + .WithColumn("ContactId").AsString(128).Nullable() + .WithColumn("SourceDisplayName").AsString(250).Nullable() + .WithColumn("NormalizedAddress").AsString(500).Nullable() + .WithColumn("Latitude").AsDecimal(12, 8).Nullable() + .WithColumn("Longitude").AsDecimal(12, 8).Nullable() + .WithColumn("MatchConfidence").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("MatchReason").AsString(250).Nullable() + .WithColumn("SuggestedOccupancyId").AsString(36).Nullable() + .WithColumn("GroupKey").AsString(64).Nullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("DecidedOn").AsDateTime2().Nullable() + .WithColumn("DecidedByUserId").AsString(128).Nullable() + .WithColumn("InventoriedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsOccupancyCrosswalks_Department_State").OnTable("RmsOccupancyCrosswalks").OnColumn("DepartmentId").Ascending().OnColumn("State").Ascending(); + Create.Index("IX_RmsOccupancyCrosswalks_Occupancy").OnTable("RmsOccupancyCrosswalks").OnColumn("DepartmentId").Ascending().OnColumn("RmsOccupancyId").Ascending(); + Create.Index("IX_RmsOccupancyCrosswalks_Contact").OnTable("RmsOccupancyCrosswalks").OnColumn("DepartmentId").Ascending().OnColumn("ContactId").Ascending(); + Create.Index("UX_RmsOccupancyCrosswalks_Source").OnTable("RmsOccupancyCrosswalks").OnColumn("DepartmentId").Ascending().OnColumn("SourceKind").Ascending().OnColumn("SourceId").Ascending().WithOptions().Unique(); + } + if (!Schema.Table("RmsOccupancyFieldProvenances").Exists()) + { + Create.Table("RmsOccupancyFieldProvenances") + .WithColumn("RmsOccupancyFieldProvenanceId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsOccupancyId").AsString(36).NotNullable() + .WithColumn("FieldKey").AsString(100).NotNullable() + .WithColumn("SourceKind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SourceId").AsString(128).Nullable() + .WithColumn("CapturedOn").AsDateTime2().NotNullable() + .WithColumn("CapturedByUserId").AsString(128).Nullable() + .WithColumn("ReviewedOn").AsDateTime2().Nullable() + .WithColumn("ReviewedByUserId").AsString(128).Nullable(); + + Create.Index("IX_RmsOccupancyFieldProvenances_Occupancy").OnTable("RmsOccupancyFieldProvenances").OnColumn("DepartmentId").Ascending().OnColumn("RmsOccupancyId").Ascending(); + } + if (!Schema.Table("RmsOccupancyOwnerships").Exists()) + { + Create.Table("RmsOccupancyOwnerships") + .WithColumn("RmsOccupancyOwnershipId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("InventoriedOn").AsDateTime2().Nullable() + .WithColumn("SwitchedOn").AsDateTime2().Nullable() + .WithColumn("SwitchedByUserId").AsString(128).Nullable() + .WithColumn("Reason").AsString(1000).Nullable() + .WithColumn("CandidateCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("LinkedCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RejectedCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("UX_RmsOccupancyOwnerships_Department").OnTable("RmsOccupancyOwnerships").OnColumn("DepartmentId").Ascending().WithOptions().Unique(); + } + if (!Schema.Table("RmsCodeSets").Exists()) + { + Create.Table("RmsCodeSets") + .WithColumn("RmsCodeSetId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Edition").AsString(100).Nullable() + .WithColumn("Jurisdiction").AsString(200).Nullable() + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsCodeSets_Department").OnTable("RmsCodeSets").OnColumn("DepartmentId").Ascending().OnColumn("IsActive").Ascending(); + } + if (!Schema.Table("RmsCodeSections").Exists()) + { + Create.Table("RmsCodeSections") + .WithColumn("RmsCodeSectionId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsCodeSetId").AsString(36).NotNullable() + .WithColumn("SectionNumber").AsString(64).NotNullable() + .WithColumn("Title").AsString(300).Nullable() + .WithColumn("Text").AsString(int.MaxValue).Nullable() + .WithColumn("DefaultSeverity").AsInt32().NotNullable().WithDefaultValue(2) + .WithColumn("DefaultCorrectionDays").AsInt32().NotNullable().WithDefaultValue(30) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsCodeSections_CodeSet").OnTable("RmsCodeSections").OnColumn("DepartmentId").Ascending().OnColumn("RmsCodeSetId").Ascending(); + } + if (!Schema.Table("RmsInspectionPrograms").Exists()) + { + Create.Table("RmsInspectionPrograms") + .WithColumn("RmsInspectionProgramId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("OccupancyTypesCsv").AsString(500).Nullable() + .WithColumn("FrequencyMonths").AsInt32().NotNullable().WithDefaultValue(12) + .WithColumn("RmsCodeSetId").AsString(36).Nullable() + .WithColumn("ChecklistJson").AsString(int.MaxValue).Nullable() + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsInspectionPrograms_Department").OnTable("RmsInspectionPrograms").OnColumn("DepartmentId").Ascending().OnColumn("IsActive").Ascending(); + } + if (!Schema.Table("RmsInspections").Exists()) + { + Create.Table("RmsInspections") + .WithColumn("RmsInspectionId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsOccupancyId").AsString(36).NotNullable() + .WithColumn("RmsInspectionProgramId").AsString(36).Nullable() + .WithColumn("InspectionNumber").AsString(32).Nullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("Result").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ScheduledOn").AsDateTime2().Nullable() + .WithColumn("StartedOn").AsDateTime2().Nullable() + .WithColumn("CompletedOn").AsDateTime2().Nullable() + .WithColumn("InspectorUserId").AsString(128).Nullable() + .WithColumn("ItemsJson").AsString(int.MaxValue).Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("SignatureName").AsString(int.MaxValue).Nullable() + .WithColumn("SignedOn").AsDateTime2().Nullable() + .WithColumn("ParentInspectionId").AsString(36).Nullable() + .WithColumn("NoticeIssuedOn").AsDateTime2().Nullable() + .WithColumn("NoticeReference").AsString(100).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsInspections_Department_State").OnTable("RmsInspections").OnColumn("DepartmentId").Ascending().OnColumn("State").Ascending().OnColumn("ScheduledOn").Ascending(); + Create.Index("IX_RmsInspections_Occupancy").OnTable("RmsInspections").OnColumn("DepartmentId").Ascending().OnColumn("RmsOccupancyId").Ascending().OnColumn("CompletedOn").Ascending(); + Create.Index("IX_RmsInspections_Program").OnTable("RmsInspections").OnColumn("DepartmentId").Ascending().OnColumn("RmsInspectionProgramId").Ascending().OnColumn("State").Ascending(); + } + if (!Schema.Table("RmsViolations").Exists()) + { + Create.Table("RmsViolations") + .WithColumn("RmsViolationId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsInspectionId").AsString(36).NotNullable() + .WithColumn("RmsOccupancyId").AsString(36).NotNullable() + .WithColumn("RmsCodeSetId").AsString(36).Nullable() + .WithColumn("RmsCodeSectionId").AsString(36).Nullable() + .WithColumn("ChecklistItemKey").AsString(100).Nullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("Severity").AsInt32().NotNullable().WithDefaultValue(2) + .WithColumn("CorrectiveAction").AsString(int.MaxValue).Nullable() + .WithColumn("DueOn").AsDateTime2().Nullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("CorrectedOn").AsDateTime2().Nullable() + .WithColumn("VerifiedOn").AsDateTime2().Nullable() + .WithColumn("VerifiedByUserId").AsString(128).Nullable() + .WithColumn("ReinspectionId").AsString(36).Nullable() + .WithColumn("OverdueEmittedOn").AsDateTime2().Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsViolations_Department_State_Due").OnTable("RmsViolations").OnColumn("DepartmentId").Ascending().OnColumn("State").Ascending().OnColumn("DueOn").Ascending(); + Create.Index("IX_RmsViolations_Inspection").OnTable("RmsViolations").OnColumn("DepartmentId").Ascending().OnColumn("RmsInspectionId").Ascending(); + Create.Index("IX_RmsViolations_Occupancy").OnTable("RmsViolations").OnColumn("DepartmentId").Ascending().OnColumn("RmsOccupancyId").Ascending().OnColumn("State").Ascending(); + } + if (!Schema.Table("RmsHydrants").Exists()) + { + Create.Table("RmsHydrants") + .WithColumn("RmsHydrantId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("HydrantNumber").AsString(64).NotNullable() + .WithColumn("Type").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("Latitude").AsDecimal(12, 8).NotNullable() + .WithColumn("Longitude").AsDecimal(12, 8).NotNullable() + .WithColumn("AddressText").AsString(500).Nullable() + .WithColumn("OwnerKind").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("OwnerName").AsString(200).Nullable() + .WithColumn("MainSizeInches").AsDecimal(6,2).Nullable() + .WithColumn("StaticPressurePsi").AsInt32().Nullable() + .WithColumn("ResidualPressurePsi").AsInt32().Nullable() + .WithColumn("FlowGpm").AsInt32().Nullable() + .WithColumn("FlowClass").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("InService").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("OutOfServiceReason").AsString(500).Nullable() + .WithColumn("OutOfServiceSince").AsDateTime2().Nullable() + .WithColumn("LastTestedOn").AsDateTime2().Nullable() + .WithColumn("LastMaintainedOn").AsDateTime2().Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("PoiId").AsInt32().Nullable() + .WithColumn("Source").AsString(32).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsHydrants_Department_Service").OnTable("RmsHydrants").OnColumn("DepartmentId").Ascending().OnColumn("InService").Ascending().OnColumn("DeletedOn").Ascending(); + Create.Index("IX_RmsHydrants_Department_Position").OnTable("RmsHydrants").OnColumn("DepartmentId").Ascending().OnColumn("Latitude").Ascending().OnColumn("Longitude").Ascending(); + } + if (!Schema.Table("RmsHydrantFlowTests").Exists()) + { + Create.Table("RmsHydrantFlowTests") + .WithColumn("RmsHydrantFlowTestId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsHydrantId").AsString(36).NotNullable() + .WithColumn("TestedOn").AsDateTime2().NotNullable() + .WithColumn("TestedByUserId").AsString(128).Nullable() + .WithColumn("StaticPressurePsi").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ResidualPressurePsi").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("PitotPressurePsi").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("OutletDiameterInches").AsDecimal(6,2).NotNullable().WithDefaultValue(0) + .WithColumn("Coefficient").AsDecimal(6,2).NotNullable().WithDefaultValue(0) + .WithColumn("FlowGpm").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("FlowClass").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable(); + + Create.Index("IX_RmsHydrantFlowTests_Hydrant").OnTable("RmsHydrantFlowTests").OnColumn("DepartmentId").Ascending().OnColumn("RmsHydrantId").Ascending().OnColumn("TestedOn").Descending(); + } + if (!Schema.Table("RmsHydrantMaintenances").Exists()) + { + Create.Table("RmsHydrantMaintenances") + .WithColumn("RmsHydrantMaintenanceId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsHydrantId").AsString(36).NotNullable() + .WithColumn("PerformedOn").AsDateTime2().NotNullable() + .WithColumn("PerformedByUserId").AsString(128).Nullable() + .WithColumn("Kind").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("ReturnedToService").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("CreatedOn").AsDateTime2().NotNullable(); + + Create.Index("IX_RmsHydrantMaintenances_Hydrant").OnTable("RmsHydrantMaintenances").OnColumn("DepartmentId").Ascending().OnColumn("RmsHydrantId").Ascending().OnColumn("PerformedOn").Descending(); + } + if (!Schema.Table("RmsPermitTypes").Exists()) + { + Create.Table("RmsPermitTypes") + .WithColumn("RmsPermitTypeId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Code").AsString(32).Nullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("DefaultValidityDays").AsInt32().NotNullable().WithDefaultValue(365) + .WithColumn("RequiresPlanReview").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("FeeAmount").AsDecimal(18, 2).Nullable() + .WithColumn("ConditionsTemplate").AsString(int.MaxValue).Nullable() + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsPermitTypes_Department").OnTable("RmsPermitTypes").OnColumn("DepartmentId").Ascending().OnColumn("IsActive").Ascending(); + } + if (!Schema.Table("RmsPermits").Exists()) + { + Create.Table("RmsPermits") + .WithColumn("RmsPermitId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsPermitTypeId").AsString(36).NotNullable() + .WithColumn("RmsOccupancyId").AsString(36).Nullable() + .WithColumn("PermitNumber").AsString(32).Nullable() + .WithColumn("ApplicantContactId").AsString(128).Nullable() + .WithColumn("ApplicantName").AsString(int.MaxValue).Nullable() + .WithColumn("ApplicantPhone").AsString(int.MaxValue).Nullable() + .WithColumn("ApplicantEmail").AsString(int.MaxValue).Nullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("AppliedOn").AsDateTime2().NotNullable() + .WithColumn("ReviewedOn").AsDateTime2().Nullable() + .WithColumn("ReviewedByUserId").AsString(128).Nullable() + .WithColumn("IssuedOn").AsDateTime2().Nullable() + .WithColumn("IssuedByUserId").AsString(128).Nullable() + .WithColumn("EffectiveOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("Conditions").AsString(int.MaxValue).Nullable() + .WithColumn("ReviewNotes").AsString(int.MaxValue).Nullable() + .WithColumn("FeeAmount").AsDecimal(18, 2).Nullable() + .WithColumn("FeePaidOn").AsDateTime2().Nullable() + .WithColumn("InvoiceReference").AsString(100).Nullable() + .WithColumn("DecisionReason").AsString(1000).Nullable() + .WithColumn("ExpiringEmittedOn").AsDateTime2().Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsPermits_Department_State_Expires").OnTable("RmsPermits").OnColumn("DepartmentId").Ascending().OnColumn("State").Ascending().OnColumn("ExpiresOn").Ascending(); + Create.Index("IX_RmsPermits_Occupancy").OnTable("RmsPermits").OnColumn("DepartmentId").Ascending().OnColumn("RmsOccupancyId").Ascending(); + } + if (!Schema.Table("RmsPlanReviews").Exists()) + { + Create.Table("RmsPlanReviews") + .WithColumn("RmsPlanReviewId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsPermitId").AsString(36).NotNullable() + .WithColumn("CycleNumber").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SubmittedOn").AsDateTime2().NotNullable() + .WithColumn("ReviewerUserId").AsString(128).Nullable() + .WithColumn("ReviewedOn").AsDateTime2().Nullable() + .WithColumn("Outcome").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Comments").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsPlanReviews_Permit").OnTable("RmsPlanReviews").OnColumn("DepartmentId").Ascending().OnColumn("RmsPermitId").Ascending().OnColumn("CycleNumber").Ascending(); + } + if (!Schema.Table("RmsCrrActivities").Exists()) + { + Create.Table("RmsCrrActivities") + .WithColumn("RmsCrrActivityId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("Kind").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("OccurredOn").AsDateTime2().NotNullable() + .WithColumn("Title").AsString(250).NotNullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("RmsOccupancyId").AsString(36).Nullable() + .WithColumn("LocationText").AsString(500).Nullable() + .WithColumn("Latitude").AsDecimal(12, 8).Nullable() + .WithColumn("Longitude").AsDecimal(12, 8).Nullable() + .WithColumn("AudienceCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SmokeAlarmsInstalled").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("HoursSpent").AsDecimal(8,2).NotNullable().WithDefaultValue(0) + .WithColumn("StaffUserIdsCsv").AsString(int.MaxValue).Nullable() + .WithColumn("Outcome").AsString(int.MaxValue).Nullable() + .WithColumn("NerisSecondaryJson").AsString(int.MaxValue).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsCrrActivities_Department_Occurred").OnTable("RmsCrrActivities").OnColumn("DepartmentId").Ascending().OnColumn("OccurredOn").Ascending(); + } + if (!Schema.Table("RmsPreventionAttachments").Exists()) + { + Create.Table("RmsPreventionAttachments") + .WithColumn("RmsPreventionAttachmentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("ParentKind").AsInt32().NotNullable() + .WithColumn("ParentId").AsString(36).NotNullable() + .WithColumn("FileName").AsString(int.MaxValue).Nullable() + .WithColumn("ContentType").AsString(200).Nullable() + .WithColumn("ByteSize").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("Checksum").AsString(128).Nullable() + .WithColumn("Data").AsBinary(int.MaxValue).Nullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("UploadedByUserId").AsString(128).Nullable() + .WithColumn("UploadedOn").AsDateTime2().NotNullable() + .WithColumn("ScanState").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("MetadataStripped").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Classification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsPreventionAttachments_Parent").OnTable("RmsPreventionAttachments").OnColumn("DepartmentId").Ascending().OnColumn("ParentKind").Ascending().OnColumn("ParentId").Ascending(); + } + if (!Schema.Table("RmsPreventionSequences").Exists()) + { + Create.Table("RmsPreventionSequences") + .WithColumn("RmsPreventionSequenceId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Kind").AsString(16).NotNullable() + .WithColumn("Year").AsInt32().NotNullable() + .WithColumn("LastValue").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ModifiedOn").AsDateTime2().NotNullable(); + + Create.Index("UX_RmsPreventionSequences_Key").OnTable("RmsPreventionSequences").OnColumn("DepartmentId").Ascending().OnColumn("Kind").Ascending().OnColumn("Year").Ascending().WithOptions().Unique(); + } + if (!Schema.Table("RmsInvestigationCases").Exists()) + { + Create.Table("RmsInvestigationCases") + .WithColumn("RmsInvestigationCaseId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("CaseNumber").AsString(32).Nullable() + .WithColumn("Title").AsString(250).NotNullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("OpenedOn").AsDateTime2().NotNullable() + .WithColumn("OpenedByUserId").AsString(128).Nullable() + .WithColumn("LeadInvestigatorUserId").AsString(128).Nullable() + .WithColumn("RmsOccupancyId").AsString(36).Nullable() + .WithColumn("CallId").AsInt32().Nullable() + .WithColumn("IncidentSummary").AsString(int.MaxValue).Nullable() + .WithColumn("CauseClassification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CauseDetail").AsString(int.MaxValue).Nullable() + .WithColumn("OriginDescription").AsString(int.MaxValue).Nullable() + .WithColumn("Findings").AsString(int.MaxValue).Nullable() + .WithColumn("FindingsAuthorUserId").AsString(128).Nullable() + .WithColumn("FindingsRecordedOn").AsDateTime2().Nullable() + .WithColumn("FindingsApprovedOn").AsDateTime2().Nullable() + .WithColumn("FindingsApprovedByUserId").AsString(128).Nullable() + .WithColumn("RecommendsIncidentAmendment").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ClosedOn").AsDateTime2().Nullable() + .WithColumn("ClosedByUserId").AsString(128).Nullable() + .WithColumn("ClosureReason").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsInvestigationCases_Department_State").OnTable("RmsInvestigationCases").OnColumn("DepartmentId").Ascending().OnColumn("State").Ascending().OnColumn("OpenedOn").Ascending(); + } + if (!Schema.Table("RmsInvestigationCaseIncidents").Exists()) + { + Create.Table("RmsInvestigationCaseIncidents") + .WithColumn("RmsInvestigationCaseIncidentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsInvestigationCaseId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("PinnedRevisionId").AsString(36).Nullable() + .WithColumn("RecordNumber").AsString(64).Nullable() + .WithColumn("LinkedOn").AsDateTime2().NotNullable() + .WithColumn("LinkedByUserId").AsString(128).Nullable(); + + Create.Index("IX_RmsInvestigationCaseIncidents_Case").OnTable("RmsInvestigationCaseIncidents").OnColumn("DepartmentId").Ascending().OnColumn("RmsInvestigationCaseId").Ascending(); + Create.Index("IX_RmsInvestigationCaseIncidents_Record").OnTable("RmsInvestigationCaseIncidents").OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending(); + } + if (!Schema.Table("RmsInvestigationCaseMembers").Exists()) + { + Create.Table("RmsInvestigationCaseMembers") + .WithColumn("RmsInvestigationCaseMemberId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsInvestigationCaseId").AsString(36).NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("Role").AsInt32().NotNullable().WithDefaultValue(2) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("RemovedOn").AsDateTime2().Nullable() + .WithColumn("RemovedByUserId").AsString(128).Nullable(); + + Create.Index("IX_RmsInvestigationCaseMembers_Case").OnTable("RmsInvestigationCaseMembers").OnColumn("DepartmentId").Ascending().OnColumn("RmsInvestigationCaseId").Ascending(); + Create.Index("IX_RmsInvestigationCaseMembers_User").OnTable("RmsInvestigationCaseMembers").OnColumn("DepartmentId").Ascending().OnColumn("UserId").Ascending().OnColumn("RemovedOn").Ascending(); + } + if (!Schema.Table("RmsInvestigationNotes").Exists()) + { + Create.Table("RmsInvestigationNotes") + .WithColumn("RmsInvestigationNoteId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsInvestigationCaseId").AsString(36).NotNullable() + .WithColumn("Kind").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("OccurredOn").AsDateTime2().NotNullable() + .WithColumn("AuthorUserId").AsString(128).Nullable() + .WithColumn("Subject").AsString(int.MaxValue).Nullable() + .WithColumn("Body").AsString(int.MaxValue).Nullable() + .WithColumn("IsLocked").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsInvestigationNotes_Case").OnTable("RmsInvestigationNotes").OnColumn("DepartmentId").Ascending().OnColumn("RmsInvestigationCaseId").Ascending().OnColumn("OccurredOn").Ascending(); + } + if (!Schema.Table("RmsInvestigationEvidence").Exists()) + { + Create.Table("RmsInvestigationEvidence") + .WithColumn("RmsInvestigationEvidenceId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsInvestigationCaseId").AsString(36).NotNullable() + .WithColumn("EvidenceNumber").AsString(32).Nullable() + .WithColumn("Kind").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("CollectedOn").AsDateTime2().NotNullable() + .WithColumn("CollectedByUserId").AsString(128).Nullable() + .WithColumn("CollectedFrom").AsString(int.MaxValue).Nullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("CurrentCustodianUserId").AsString(128).Nullable() + .WithColumn("CurrentCustodianExternal").AsString(int.MaxValue).Nullable() + .WithColumn("StorageLocation").AsString(250).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsInvestigationEvidence_Case").OnTable("RmsInvestigationEvidence").OnColumn("DepartmentId").Ascending().OnColumn("RmsInvestigationCaseId").Ascending(); + } + if (!Schema.Table("RmsInvestigationCustody").Exists()) + { + Create.Table("RmsInvestigationCustody") + .WithColumn("RmsInvestigationCustodyId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsInvestigationEvidenceId").AsString(36).NotNullable() + .WithColumn("Sequence").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("TransferredOn").AsDateTime2().NotNullable() + .WithColumn("FromUserId").AsString(128).Nullable() + .WithColumn("FromExternal").AsString(int.MaxValue).Nullable() + .WithColumn("ToUserId").AsString(128).Nullable() + .WithColumn("ToExternal").AsString(int.MaxValue).Nullable() + .WithColumn("Reason").AsString(int.MaxValue).Nullable() + .WithColumn("ResultingState").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("RecordedByUserId").AsString(128).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_RmsInvestigationCustody_Evidence").OnTable("RmsInvestigationCustody").OnColumn("DepartmentId").Ascending().OnColumn("RmsInvestigationEvidenceId").Ascending().OnColumn("Sequence").Ascending(); + } + if (!Schema.Table("RmsInvestigationReferrals").Exists()) + { + Create.Table("RmsInvestigationReferrals") + .WithColumn("RmsInvestigationReferralId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RmsInvestigationCaseId").AsString(36).NotNullable() + .WithColumn("Agency").AsString(250).NotNullable() + .WithColumn("ReferredOn").AsDateTime2().NotNullable() + .WithColumn("ReferredByUserId").AsString(128).Nullable() + .WithColumn("Reason").AsString(int.MaxValue).Nullable() + .WithColumn("ReferenceNumber").AsString(100).Nullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsInvestigationReferrals_Case").OnTable("RmsInvestigationReferrals").OnColumn("DepartmentId").Ascending().OnColumn("RmsInvestigationCaseId").Ascending(); + } + + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Records.Prevention.Occupancy') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Records.Prevention.Occupancy', 'Records Prevention - Occupancies', 'RMS-5 occupancy/property master and the Contacts pre-plan crosswalk. Requires Records.System. Seeded off.', 'Records', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Records.Prevention.Inspections') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Records.Prevention.Inspections', 'Records Prevention - Inspections', 'RMS-5 inspection programs, code sets, inspections and violations. Requires Records.Prevention.Occupancy. Seeded off.', 'Records', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Records.Prevention.Hydrants') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Records.Prevention.Hydrants', 'Records Prevention - Hydrants', 'RMS-5 hydrants and water sources with flow tests and the response-map layer. Requires Records.System. Seeded off.', 'Records', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Records.Prevention.Permits') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Records.Prevention.Permits', 'Records Prevention - Permits', 'RMS-5 permits, plan review and expiration. Requires Records.Prevention.Occupancy. Seeded off.', 'Records', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Records.Prevention.Crr') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Records.Prevention.Crr', 'Records Prevention - Community Risk Reduction', 'RMS-5 community risk reduction activity tracking. Requires Records.System. Seeded off.', 'Records', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Records.Investigations') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Records.Investigations', 'Records Investigations', 'RMS-5 investigation cases with case-level authorization, evidence chain of custody and referrals. Requires Records.System. Seeded off.', 'Records', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Records.QualityReview') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Records.QualityReview', 'Records Quality Review', 'RMS-4 optional post-finalization quality review: rubric-scored sampling of finalized records. Requires Records.System. Seeded off.', 'Records', 0);"); + } + + public override void Down() + { + Delete.FromTable("FeatureFlags").Row(new { FlagKey = "Records.Prevention.Occupancy" }); + Delete.FromTable("FeatureFlags").Row(new { FlagKey = "Records.Prevention.Inspections" }); + Delete.FromTable("FeatureFlags").Row(new { FlagKey = "Records.Prevention.Hydrants" }); + Delete.FromTable("FeatureFlags").Row(new { FlagKey = "Records.Prevention.Permits" }); + Delete.FromTable("FeatureFlags").Row(new { FlagKey = "Records.Prevention.Crr" }); + Delete.FromTable("FeatureFlags").Row(new { FlagKey = "Records.Investigations" }); + Delete.FromTable("FeatureFlags").Row(new { FlagKey = "Records.QualityReview" }); + if (Schema.Table("RmsInvestigationReferrals").Exists()) + Delete.Table("RmsInvestigationReferrals"); + if (Schema.Table("RmsInvestigationCustody").Exists()) + Delete.Table("RmsInvestigationCustody"); + if (Schema.Table("RmsInvestigationEvidence").Exists()) + Delete.Table("RmsInvestigationEvidence"); + if (Schema.Table("RmsInvestigationNotes").Exists()) + Delete.Table("RmsInvestigationNotes"); + if (Schema.Table("RmsInvestigationCaseMembers").Exists()) + Delete.Table("RmsInvestigationCaseMembers"); + if (Schema.Table("RmsInvestigationCaseIncidents").Exists()) + Delete.Table("RmsInvestigationCaseIncidents"); + if (Schema.Table("RmsInvestigationCases").Exists()) + Delete.Table("RmsInvestigationCases"); + if (Schema.Table("RmsPreventionSequences").Exists()) + Delete.Table("RmsPreventionSequences"); + if (Schema.Table("RmsPreventionAttachments").Exists()) + Delete.Table("RmsPreventionAttachments"); + if (Schema.Table("RmsCrrActivities").Exists()) + Delete.Table("RmsCrrActivities"); + if (Schema.Table("RmsPlanReviews").Exists()) + Delete.Table("RmsPlanReviews"); + if (Schema.Table("RmsPermits").Exists()) + Delete.Table("RmsPermits"); + if (Schema.Table("RmsPermitTypes").Exists()) + Delete.Table("RmsPermitTypes"); + if (Schema.Table("RmsHydrantMaintenances").Exists()) + Delete.Table("RmsHydrantMaintenances"); + if (Schema.Table("RmsHydrantFlowTests").Exists()) + Delete.Table("RmsHydrantFlowTests"); + if (Schema.Table("RmsHydrants").Exists()) + Delete.Table("RmsHydrants"); + if (Schema.Table("RmsViolations").Exists()) + Delete.Table("RmsViolations"); + if (Schema.Table("RmsInspections").Exists()) + Delete.Table("RmsInspections"); + if (Schema.Table("RmsInspectionPrograms").Exists()) + Delete.Table("RmsInspectionPrograms"); + if (Schema.Table("RmsCodeSections").Exists()) + Delete.Table("RmsCodeSections"); + if (Schema.Table("RmsCodeSets").Exists()) + Delete.Table("RmsCodeSets"); + if (Schema.Table("RmsOccupancyOwnerships").Exists()) + Delete.Table("RmsOccupancyOwnerships"); + if (Schema.Table("RmsOccupancyFieldProvenances").Exists()) + Delete.Table("RmsOccupancyFieldProvenances"); + if (Schema.Table("RmsOccupancyCrosswalks").Exists()) + Delete.Table("RmsOccupancyCrosswalks"); + if (Schema.Table("RmsOccupancyHazards").Exists()) + Delete.Table("RmsOccupancyHazards"); + if (Schema.Table("RmsOccupancyContactLinks").Exists()) + Delete.Table("RmsOccupancyContactLinks"); + if (Schema.Table("RmsOccupancies").Exists()) + Delete.Table("RmsOccupancies"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0182_AddRmsRecordCardinalityPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0182_AddRmsRecordCardinalityPg.cs new file mode 100644 index 000000000..91dbe3b90 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0182_AddRmsRecordCardinalityPg.cs @@ -0,0 +1,40 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Per-definition record cardinality (RMS plan section 5.2.1, registry M0182). Two parts: the rule a definition + /// version declares, and the key that enforces it. + /// + /// The partial unique index is the enforcement, not the service check in front of it. Two engine companies on + /// the same fire produce one official incident report and two company-level records, and which of those a + /// definition is must be a property of the definition rather than a global rule. A Record that is voided or + /// cancelled has its key cleared, so an abandoned Record never blocks the one that replaces it. + /// + [Migration(182)] + public class M0182_AddRmsRecordCardinalityPg : Migration + { + public override void Up() + { + if (Schema.Table("rmsrecorddefinitionversions").Exists() && !Schema.Table("rmsrecorddefinitionversions").Column("cardinality").Exists()) + Alter.Table("rmsrecorddefinitionversions").AddColumn("cardinality").AsInt32().NotNullable().WithDefaultValue(2); + + if (Schema.Table("rmsoperationalrecords").Exists() && !Schema.Table("rmsoperationalrecords").Column("cardinalitykey").Exists()) + { + Alter.Table("rmsoperationalrecords").AddColumn("cardinalitykey").AsString(400).Nullable(); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmsoperationalrecords_department_cardinalitykey ON rmsoperationalrecords (departmentid, cardinalitykey) WHERE cardinalitykey IS NOT NULL;"); + } + } + + public override void Down() + { + Execute.Sql("DROP INDEX IF EXISTS ux_rmsoperationalrecords_department_cardinalitykey;"); + + if (Schema.Table("rmsoperationalrecords").Exists() && Schema.Table("rmsoperationalrecords").Column("cardinalitykey").Exists()) + Delete.Column("cardinalitykey").FromTable("rmsoperationalrecords"); + + if (Schema.Table("rmsrecorddefinitionversions").Exists() && Schema.Table("rmsrecorddefinitionversions").Column("cardinality").Exists()) + Delete.Column("cardinality").FromTable("rmsrecorddefinitionversions"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0183_AddContactPreplansPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0183_AddContactPreplansPg.cs new file mode 100644 index 000000000..7709a9c22 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0183_AddContactPreplansPg.cs @@ -0,0 +1,99 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Contact pre-incident plans (Contacts plan Phase A, A1; registry M0183, taken as the next physical + /// number under the no-gaps rule): the 1:1 structured NFPA 1620 pre-plan per Contact and its repeating + /// typed premise hazards. Both tables carry the ADP IsProtected row marker: the text columns are catalog v12 fields + /// (ProtectedFieldCatalog.ContactPreplansCatalogVersion) and are enveloped in a protected department. Guarded for safe retry. + /// + [Migration(183)] + public class M0183_AddContactPreplansPg : Migration + { + public override void Up() + { + if (!Schema.Table("contactpreplans").Exists()) + { + Create.Table("contactpreplans") + .WithColumn("contactpreplanid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("contactid").AsString(128).NotNullable() + .WithColumn("constructiontype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rooftype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("occupancytype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("occupancynotes").AsCustom("text").Nullable() + .WithColumn("occupancyhours").AsString(500).Nullable() + .WithColumn("occupantload").AsInt32().Nullable() + .WithColumn("hasoccupantsneedingassistance").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("occupantsneedingassistancenotes").AsCustom("text").Nullable() + .WithColumn("gasshutofflocation").AsString(1000).Nullable() + .WithColumn("electricshutofflocation").AsString(1000).Nullable() + .WithColumn("watershutofflocation").AsString(1000).Nullable() + .WithColumn("utilitynotes").AsCustom("text").Nullable() + .WithColumn("knoxboxlocation").AsString(1000).Nullable() + .WithColumn("gatecode").AsString(1000).Nullable() + .WithColumn("alarmpanellocation").AsString(1000).Nullable() + .WithColumn("alarmcompany").AsString(500).Nullable() + .WithColumn("alarmcompanyphone").AsString(100).Nullable() + .WithColumn("accessnotes").AsCustom("text").Nullable() + .WithColumn("nearesthydrantlocation").AsString(1000).Nullable() + .WithColumn("requiredfireflowgpm").AsInt32().Nullable() + .WithColumn("watersupplynotes").AsCustom("text").Nullable() + .WithColumn("emergencycontactname").AsString(500).Nullable() + .WithColumn("emergencycontactphone").AsString(100).Nullable() + .WithColumn("secondarycontactname").AsString(500).Nullable() + .WithColumn("secondarycontactphone").AsString(100).Nullable() + .WithColumn("hazmatonsite").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("generalhazardnotes").AsCustom("text").Nullable() + .WithColumn("tacticalsummary").AsCustom("text").Nullable() + .WithColumn("lastreviewedon").AsDateTime2().Nullable() + .WithColumn("reviewedbyuserid").AsString(128).Nullable() + .WithColumn("nextreviewdue").AsDateTime2().Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_contactpreplans_department ON contactpreplans (departmentid, isdeleted);"); + // One live pre-plan per contact; soft-deleted rows do not block a replacement. + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_contactpreplans_contact_live ON contactpreplans (contactid) WHERE isdeleted = FALSE;"); + } + + if (!Schema.Table("contactpreplanhazards").Exists()) + { + Create.Table("contactpreplanhazards") + .WithColumn("contactpreplanhazardid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("contactpreplanid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("contactid").AsString(128).NotNullable() + .WithColumn("hazardtype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("severity").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("title").AsString(500).NotNullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("locationdescription").AsString(1000).Nullable() + .WithColumn("gpscoordinates").AsString(100).Nullable() + .WithColumn("shouldalert").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_contactpreplanhazards_preplan ON contactpreplanhazards (contactpreplanid, isdeleted);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_contactpreplanhazards_contact ON contactpreplanhazards (departmentid, contactid, isdeleted);"); + } + } + + public override void Down() + { + if (Schema.Table("contactpreplanhazards").Exists()) + Delete.Table("contactpreplanhazards"); + if (Schema.Table("contactpreplans").Exists()) + Delete.Table("contactpreplans"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0184_AddContactAttachmentsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0184_AddContactAttachmentsPg.cs new file mode 100644 index 000000000..37c6635c1 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0184_AddContactAttachmentsPg.cs @@ -0,0 +1,43 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Contact site attachments (Contacts plan Phase A, A1; registry M0184, next physical number under the + /// no-gaps rule): typed blob rows (document, pre-plan, floor plan, site photo, site drawing) mirroring + /// CallAttachments, including the ADP IsProtected row marker (name, file name and bytes are catalog v12 fields). + /// List queries read metadata only. Guarded for safe retry. + /// + [Migration(184)] + public class M0184_AddContactAttachmentsPg : Migration + { + public override void Up() + { + if (!Schema.Table("contactattachments").Exists()) + { + Create.Table("contactattachments") + .WithColumn("contactattachmentid").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("contactid").AsString(128).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("contactattachmenttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("name").AsString(500).Nullable() + .WithColumn("filename").AsString(500).Nullable() + .WithColumn("filetype").AsString(200).Nullable() + .WithColumn("size").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("data").AsCustom("bytea").Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_contactattachments_contact ON contactattachments (departmentid, contactid, isdeleted);"); + } + } + + public override void Down() + { + if (Schema.Table("contactattachments").Exists()) + Delete.Table("contactattachments"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0185_RmsReleaseReadinessPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0185_RmsReleaseReadinessPg.cs new file mode 100644 index 000000000..219dd45ad --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0185_RmsReleaseReadinessPg.cs @@ -0,0 +1,124 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// RMS-4 release readiness (RMS plan section 6, RMS-4; registry M0185, taken physically under the no-gaps rule in + /// place of the M0174 reservation): the covering indexes for the hot Records read paths (queue by state and + /// modified time, call lookups, finalized-since windows, idempotency replay, group-scope recompute, due-state + /// lookups, per-department outbox and audit counts for the release telemetry, scan-state and submission-state + /// counts), plus the optional post-finalization quality review tables (plan section 4.7): a department rubric + /// and one non-mutating review per sampled revision. Guarded for safe retry. + /// + [Migration(185)] + public class M0185_RmsReleaseReadinessPg : Migration + { + public override void Up() + { + if (Schema.Table("rmsoperationalrecords").Exists() && !Schema.Table("rmsoperationalrecords").Index("IX_RmsOperationalRecords_Department_State_Modified").Exists()) + Create.Index("IX_RmsOperationalRecords_Department_State_Modified").OnTable("rmsoperationalrecords").OnColumn("departmentid").Ascending().OnColumn("state").Ascending().OnColumn("modifiedon").Ascending(); + if (Schema.Table("rmsoperationalrecords").Exists() && !Schema.Table("rmsoperationalrecords").Index("IX_RmsOperationalRecords_Department_Call").Exists()) + Create.Index("IX_RmsOperationalRecords_Department_Call").OnTable("rmsoperationalrecords").OnColumn("departmentid").Ascending().OnColumn("callid").Ascending(); + if (Schema.Table("rmsoperationalrecords").Exists() && !Schema.Table("rmsoperationalrecords").Index("IX_RmsOperationalRecords_Department_Finalized").Exists()) + Create.Index("IX_RmsOperationalRecords_Department_Finalized").OnTable("rmsoperationalrecords").OnColumn("departmentid").Ascending().OnColumn("finalizedon").Ascending(); + if (Schema.Table("rmsoperationalrecords").Exists() && !Schema.Table("rmsoperationalrecords").Index("IX_RmsOperationalRecords_Department_Idempotency").Exists()) + Create.Index("IX_RmsOperationalRecords_Department_Idempotency").OnTable("rmsoperationalrecords").OnColumn("departmentid").Ascending().OnColumn("idempotencykey").Ascending(); + if (Schema.Table("rmsincidentreports").Exists() && !Schema.Table("rmsincidentreports").Index("IX_RmsIncidentReports_Department_Modified").Exists()) + Create.Index("IX_RmsIncidentReports_Department_Modified").OnTable("rmsincidentreports").OnColumn("departmentid").Ascending().OnColumn("modifiedon").Ascending(); + if (Schema.Table("rmsrecordgroupscopes").Exists() && !Schema.Table("rmsrecordgroupscopes").Index("IX_RmsRecordGroupScopes_Department_Record").Exists()) + Create.Index("IX_RmsRecordGroupScopes_Department_Record").OnTable("rmsrecordgroupscopes").OnColumn("departmentid").Ascending().OnColumn("recordid").Ascending(); + if (Schema.Table("rmsrecordduestates").Exists() && !Schema.Table("rmsrecordduestates").Index("IX_RmsRecordDueStates_Department_Record").Exists()) + Create.Index("IX_RmsRecordDueStates_Department_Record").OnTable("rmsrecordduestates").OnColumn("departmentid").Ascending().OnColumn("recordid").Ascending(); + if (Schema.Table("domaineventoutbox").Exists() && !Schema.Table("domaineventoutbox").Index("IX_DomainEventOutbox_Department_State_Created").Exists()) + Create.Index("IX_DomainEventOutbox_Department_State_Created").OnTable("domaineventoutbox").OnColumn("departmentid").Ascending().OnColumn("state").Ascending().OnColumn("createdon").Ascending(); + if (Schema.Table("rmsaccessaudits").Exists() && !Schema.Table("rmsaccessaudits").Index("IX_RmsAccessAudits_Department_Action_Occurred").Exists()) + Create.Index("IX_RmsAccessAudits_Department_Action_Occurred").OnTable("rmsaccessaudits").OnColumn("departmentid").Ascending().OnColumn("action").Ascending().OnColumn("occurredon").Ascending(); + if (Schema.Table("rmsrecordattachments").Exists() && !Schema.Table("rmsrecordattachments").Index("IX_RmsRecordAttachments_Department_ScanState").Exists()) + Create.Index("IX_RmsRecordAttachments_Department_ScanState").OnTable("rmsrecordattachments").OnColumn("departmentid").Ascending().OnColumn("scanstate").Ascending(); + if (Schema.Table("rmssubmissions").Exists() && !Schema.Table("rmssubmissions").Index("IX_RmsSubmissions_Department_State").Exists()) + Create.Index("IX_RmsSubmissions_Department_State").OnTable("rmssubmissions").OnColumn("departmentid").Ascending().OnColumn("state").Ascending(); + + if (!Schema.Table("rmsqualityrubrics").Exists()) + { + Create.Table("rmsqualityrubrics") + .WithColumn("rmsqualityrubricid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("definitionkey").AsString(100).Nullable() + .WithColumn("criteriajson").AsCustom("text").NotNullable() + .WithColumn("samplesize").AsInt32().NotNullable().WithDefaultValue(10) + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsQualityRubrics_Department").OnTable("rmsqualityrubrics").OnColumn("departmentid").Ascending().OnColumn("isactive").Ascending(); + } + if (!Schema.Table("rmsqualityreviews").Exists()) + { + Create.Table("rmsqualityreviews") + .WithColumn("rmsqualityreviewid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsqualityrubricid").AsString(36).NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("recordkind").AsInt32().NotNullable() + .WithColumn("revisionid").AsString(36).Nullable() + .WithColumn("definitionkey").AsString(100).Nullable() + .WithColumn("recordnumber").AsString(64).Nullable() + .WithColumn("authoruserid").AsString(128).Nullable() + .WithColumn("unitid").AsInt32().Nullable() + .WithColumn("revieweruserid").AsString(128).Nullable() + .WithColumn("sampledon").AsDateTime2().NotNullable() + .WithColumn("scoredon").AsDateTime2().Nullable() + .WithColumn("score").AsInt32().Nullable() + .WithColumn("criteriajson").AsCustom("text").NotNullable() + .WithColumn("findingsjson").AsCustom("text").Nullable() + .WithColumn("note").AsCustom("text").Nullable() + .WithColumn("amendmentrecommended").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsQualityReviews_Department_Record").OnTable("rmsqualityreviews").OnColumn("departmentid").Ascending().OnColumn("recordid").Ascending(); + Create.Index("IX_RmsQualityReviews_Department_Scored").OnTable("rmsqualityreviews").OnColumn("departmentid").Ascending().OnColumn("scoredon").Ascending(); + Create.Index("IX_RmsQualityReviews_Department_Rubric_Sampled").OnTable("rmsqualityreviews").OnColumn("departmentid").Ascending().OnColumn("rmsqualityrubricid").Ascending().OnColumn("sampledon").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("rmsqualityreviews").Exists()) + Delete.Table("rmsqualityreviews"); + if (Schema.Table("rmsqualityrubrics").Exists()) + Delete.Table("rmsqualityrubrics"); + if (Schema.Table("rmsoperationalrecords").Exists() && Schema.Table("rmsoperationalrecords").Index("IX_RmsOperationalRecords_Department_State_Modified").Exists()) + Delete.Index("IX_RmsOperationalRecords_Department_State_Modified").OnTable("rmsoperationalrecords"); + if (Schema.Table("rmsoperationalrecords").Exists() && Schema.Table("rmsoperationalrecords").Index("IX_RmsOperationalRecords_Department_Call").Exists()) + Delete.Index("IX_RmsOperationalRecords_Department_Call").OnTable("rmsoperationalrecords"); + if (Schema.Table("rmsoperationalrecords").Exists() && Schema.Table("rmsoperationalrecords").Index("IX_RmsOperationalRecords_Department_Finalized").Exists()) + Delete.Index("IX_RmsOperationalRecords_Department_Finalized").OnTable("rmsoperationalrecords"); + if (Schema.Table("rmsoperationalrecords").Exists() && Schema.Table("rmsoperationalrecords").Index("IX_RmsOperationalRecords_Department_Idempotency").Exists()) + Delete.Index("IX_RmsOperationalRecords_Department_Idempotency").OnTable("rmsoperationalrecords"); + if (Schema.Table("rmsincidentreports").Exists() && Schema.Table("rmsincidentreports").Index("IX_RmsIncidentReports_Department_Modified").Exists()) + Delete.Index("IX_RmsIncidentReports_Department_Modified").OnTable("rmsincidentreports"); + if (Schema.Table("rmsrecordgroupscopes").Exists() && Schema.Table("rmsrecordgroupscopes").Index("IX_RmsRecordGroupScopes_Department_Record").Exists()) + Delete.Index("IX_RmsRecordGroupScopes_Department_Record").OnTable("rmsrecordgroupscopes"); + if (Schema.Table("rmsrecordduestates").Exists() && Schema.Table("rmsrecordduestates").Index("IX_RmsRecordDueStates_Department_Record").Exists()) + Delete.Index("IX_RmsRecordDueStates_Department_Record").OnTable("rmsrecordduestates"); + if (Schema.Table("domaineventoutbox").Exists() && Schema.Table("domaineventoutbox").Index("IX_DomainEventOutbox_Department_State_Created").Exists()) + Delete.Index("IX_DomainEventOutbox_Department_State_Created").OnTable("domaineventoutbox"); + if (Schema.Table("rmsaccessaudits").Exists() && Schema.Table("rmsaccessaudits").Index("IX_RmsAccessAudits_Department_Action_Occurred").Exists()) + Delete.Index("IX_RmsAccessAudits_Department_Action_Occurred").OnTable("rmsaccessaudits"); + if (Schema.Table("rmsrecordattachments").Exists() && Schema.Table("rmsrecordattachments").Index("IX_RmsRecordAttachments_Department_ScanState").Exists()) + Delete.Index("IX_RmsRecordAttachments_Department_ScanState").OnTable("rmsrecordattachments"); + if (Schema.Table("rmssubmissions").Exists() && Schema.Table("rmssubmissions").Index("IX_RmsSubmissions_Department_State").Exists()) + Delete.Index("IX_RmsSubmissions_Department_State").OnTable("rmssubmissions"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0186_AddRmsPreventionAndInvestigationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0186_AddRmsPreventionAndInvestigationsPg.cs new file mode 100644 index 000000000..5e23b457d --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0186_AddRmsPreventionAndInvestigationsPg.cs @@ -0,0 +1,783 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// RMS-5 prevention and investigations (RMS plan sections 4.3, 4.4 and 6; registry M0186): the occupancy/property + /// master with role-separated Contact links, hazards, the ContactPreplan/Contact/POI crosswalk, field provenance and + /// the per-department structure-write ownership row; inspection programs, adopted code sets, inspections and + /// violations; hydrants with flow tests and maintenance; permit types, permits and plan reviews; community risk + /// reduction activities; prevention attachments and number sequences; investigation cases with members, linked + /// incidents, notes, evidence, an append-only custody chain and referrals. Seeds the seven module feature flags off. + /// Every table carries DepartmentId and ProtectionId; the cataloged text columns (ADP catalog v13) carry the + /// IsProtected marker. Guarded for safe retry. + /// + [Migration(186)] + public class M0186_AddRmsPreventionAndInvestigationsPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmsoccupancies").Exists()) + { + Create.Table("rmsoccupancies") + .WithColumn("rmsoccupancyid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("occupancynumber").AsString(32).Nullable() + .WithColumn("name").AsString(250).NotNullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("mergedintooccupancyid").AsString(36).Nullable() + .WithColumn("addresstext").AsString(500).Nullable() + .WithColumn("city").AsString(120).Nullable() + .WithColumn("stateprovince").AsString(120).Nullable() + .WithColumn("postalcode").AsString(20).Nullable() + .WithColumn("country").AsString(80).Nullable() + .WithColumn("normalizedaddress").AsString(500).Nullable() + .WithColumn("latitude").AsDecimal(12, 8).Nullable() + .WithColumn("longitude").AsDecimal(12, 8).Nullable() + .WithColumn("parcelid").AsString(100).Nullable() + .WithColumn("constructiontype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rooftype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("occupancytype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("stories").AsInt32().Nullable() + .WithColumn("yearbuilt").AsInt32().Nullable() + .WithColumn("squarefeet").AsInt32().Nullable() + .WithColumn("occupantload").AsInt32().Nullable() + .WithColumn("occupancyhours").AsString(250).Nullable() + .WithColumn("hasoccupantsneedingassistance").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("occupantsneedingassistancenotes").AsCustom("text").Nullable() + .WithColumn("sprinklertype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("hasstandpipe").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("hasfirealarm").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("fdclocation").AsString(250).Nullable() + .WithColumn("gasshutofflocation").AsString(500).Nullable() + .WithColumn("electricshutofflocation").AsString(500).Nullable() + .WithColumn("watershutofflocation").AsString(500).Nullable() + .WithColumn("utilitynotes").AsCustom("text").Nullable() + .WithColumn("knoxboxlocation").AsCustom("text").Nullable() + .WithColumn("gatecode").AsCustom("text").Nullable() + .WithColumn("alarmpanellocation").AsCustom("text").Nullable() + .WithColumn("alarmcompany").AsCustom("text").Nullable() + .WithColumn("alarmcompanyphone").AsCustom("text").Nullable() + .WithColumn("accessnotes").AsCustom("text").Nullable() + .WithColumn("nearesthydrantid").AsString(36).Nullable() + .WithColumn("requiredfireflowgpm").AsInt32().Nullable() + .WithColumn("watersupplynotes").AsCustom("text").Nullable() + .WithColumn("emergencycontactname").AsCustom("text").Nullable() + .WithColumn("emergencycontactphone").AsCustom("text").Nullable() + .WithColumn("hazmatonsite").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("generalhazardnotes").AsCustom("text").Nullable() + .WithColumn("tacticalsummary").AsCustom("text").Nullable() + .WithColumn("poiid").AsInt32().Nullable() + .WithColumn("lastreviewedon").AsDateTime2().Nullable() + .WithColumn("reviewedbyuserid").AsString(128).Nullable() + .WithColumn("nextreviewdue").AsDateTime2().Nullable() + .WithColumn("lastinspectedon").AsDateTime2().Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("modifiedbyuserid").AsString(128).Nullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsOccupancies_Department_Status").OnTable("rmsoccupancies").OnColumn("departmentid").Ascending().OnColumn("status").Ascending().OnColumn("deletedon").Ascending(); + Create.Index("IX_RmsOccupancies_Department_Address").OnTable("rmsoccupancies").OnColumn("departmentid").Ascending().OnColumn("normalizedaddress").Ascending(); + Create.Index("IX_RmsOccupancies_Department_Review").OnTable("rmsoccupancies").OnColumn("departmentid").Ascending().OnColumn("nextreviewdue").Ascending(); + } + if (!Schema.Table("rmsoccupancycontactlinks").Exists()) + { + Create.Table("rmsoccupancycontactlinks") + .WithColumn("rmsoccupancycontactlinkid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsoccupancyid").AsString(36).NotNullable() + .WithColumn("contactid").AsString(128).NotNullable() + .WithColumn("role").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isprimary").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsOccupancyContactLinks_Occupancy").OnTable("rmsoccupancycontactlinks").OnColumn("departmentid").Ascending().OnColumn("rmsoccupancyid").Ascending(); + Create.Index("IX_RmsOccupancyContactLinks_Contact").OnTable("rmsoccupancycontactlinks").OnColumn("departmentid").Ascending().OnColumn("contactid").Ascending(); + } + if (!Schema.Table("rmsoccupancyhazards").Exists()) + { + Create.Table("rmsoccupancyhazards") + .WithColumn("rmsoccupancyhazardid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsoccupancyid").AsString(36).NotNullable() + .WithColumn("hazardtype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("severity").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("title").AsString(200).NotNullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("locationdescription").AsCustom("text").Nullable() + .WithColumn("gpscoordinates").AsCustom("text").Nullable() + .WithColumn("shouldalert").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("sourcecontactpreplanhazardid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsOccupancyHazards_Occupancy").OnTable("rmsoccupancyhazards").OnColumn("departmentid").Ascending().OnColumn("rmsoccupancyid").Ascending(); + } + if (!Schema.Table("rmsoccupancycrosswalks").Exists()) + { + Create.Table("rmsoccupancycrosswalks") + .WithColumn("rmsoccupancycrosswalkid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsoccupancyid").AsString(36).Nullable() + .WithColumn("sourcekind").AsInt32().NotNullable() + .WithColumn("sourceid").AsString(128).NotNullable() + .WithColumn("contactid").AsString(128).Nullable() + .WithColumn("sourcedisplayname").AsString(250).Nullable() + .WithColumn("normalizedaddress").AsString(500).Nullable() + .WithColumn("latitude").AsDecimal(12, 8).Nullable() + .WithColumn("longitude").AsDecimal(12, 8).Nullable() + .WithColumn("matchconfidence").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("matchreason").AsString(250).Nullable() + .WithColumn("suggestedoccupancyid").AsString(36).Nullable() + .WithColumn("groupkey").AsString(64).Nullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("decidedon").AsDateTime2().Nullable() + .WithColumn("decidedbyuserid").AsString(128).Nullable() + .WithColumn("inventoriedon").AsDateTime2().NotNullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsOccupancyCrosswalks_Department_State").OnTable("rmsoccupancycrosswalks").OnColumn("departmentid").Ascending().OnColumn("state").Ascending(); + Create.Index("IX_RmsOccupancyCrosswalks_Occupancy").OnTable("rmsoccupancycrosswalks").OnColumn("departmentid").Ascending().OnColumn("rmsoccupancyid").Ascending(); + Create.Index("IX_RmsOccupancyCrosswalks_Contact").OnTable("rmsoccupancycrosswalks").OnColumn("departmentid").Ascending().OnColumn("contactid").Ascending(); + Create.Index("UX_RmsOccupancyCrosswalks_Source").OnTable("rmsoccupancycrosswalks").OnColumn("departmentid").Ascending().OnColumn("sourcekind").Ascending().OnColumn("sourceid").Ascending().WithOptions().Unique(); + } + if (!Schema.Table("rmsoccupancyfieldprovenances").Exists()) + { + Create.Table("rmsoccupancyfieldprovenances") + .WithColumn("rmsoccupancyfieldprovenanceid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsoccupancyid").AsString(36).NotNullable() + .WithColumn("fieldkey").AsString(100).NotNullable() + .WithColumn("sourcekind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("sourceid").AsString(128).Nullable() + .WithColumn("capturedon").AsDateTime2().NotNullable() + .WithColumn("capturedbyuserid").AsString(128).Nullable() + .WithColumn("reviewedon").AsDateTime2().Nullable() + .WithColumn("reviewedbyuserid").AsString(128).Nullable(); + + Create.Index("IX_RmsOccupancyFieldProvenances_Occupancy").OnTable("rmsoccupancyfieldprovenances").OnColumn("departmentid").Ascending().OnColumn("rmsoccupancyid").Ascending(); + } + if (!Schema.Table("rmsoccupancyownerships").Exists()) + { + Create.Table("rmsoccupancyownerships") + .WithColumn("rmsoccupancyownershipid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("inventoriedon").AsDateTime2().Nullable() + .WithColumn("switchedon").AsDateTime2().Nullable() + .WithColumn("switchedbyuserid").AsString(128).Nullable() + .WithColumn("reason").AsString(1000).Nullable() + .WithColumn("candidatecount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("linkedcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rejectedcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("UX_RmsOccupancyOwnerships_Department").OnTable("rmsoccupancyownerships").OnColumn("departmentid").Ascending().WithOptions().Unique(); + } + if (!Schema.Table("rmscodesets").Exists()) + { + Create.Table("rmscodesets") + .WithColumn("rmscodesetid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("edition").AsString(100).Nullable() + .WithColumn("jurisdiction").AsString(200).Nullable() + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsCodeSets_Department").OnTable("rmscodesets").OnColumn("departmentid").Ascending().OnColumn("isactive").Ascending(); + } + if (!Schema.Table("rmscodesections").Exists()) + { + Create.Table("rmscodesections") + .WithColumn("rmscodesectionid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmscodesetid").AsString(36).NotNullable() + .WithColumn("sectionnumber").AsString(64).NotNullable() + .WithColumn("title").AsString(300).Nullable() + .WithColumn("text").AsCustom("text").Nullable() + .WithColumn("defaultseverity").AsInt32().NotNullable().WithDefaultValue(2) + .WithColumn("defaultcorrectiondays").AsInt32().NotNullable().WithDefaultValue(30) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsCodeSections_CodeSet").OnTable("rmscodesections").OnColumn("departmentid").Ascending().OnColumn("rmscodesetid").Ascending(); + } + if (!Schema.Table("rmsinspectionprograms").Exists()) + { + Create.Table("rmsinspectionprograms") + .WithColumn("rmsinspectionprogramid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("occupancytypescsv").AsString(500).Nullable() + .WithColumn("frequencymonths").AsInt32().NotNullable().WithDefaultValue(12) + .WithColumn("rmscodesetid").AsString(36).Nullable() + .WithColumn("checklistjson").AsCustom("text").Nullable() + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsInspectionPrograms_Department").OnTable("rmsinspectionprograms").OnColumn("departmentid").Ascending().OnColumn("isactive").Ascending(); + } + if (!Schema.Table("rmsinspections").Exists()) + { + Create.Table("rmsinspections") + .WithColumn("rmsinspectionid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsoccupancyid").AsString(36).NotNullable() + .WithColumn("rmsinspectionprogramid").AsString(36).Nullable() + .WithColumn("inspectionnumber").AsString(32).Nullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("result").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("scheduledon").AsDateTime2().Nullable() + .WithColumn("startedon").AsDateTime2().Nullable() + .WithColumn("completedon").AsDateTime2().Nullable() + .WithColumn("inspectoruserid").AsString(128).Nullable() + .WithColumn("itemsjson").AsCustom("text").Nullable() + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("signaturename").AsCustom("text").Nullable() + .WithColumn("signedon").AsDateTime2().Nullable() + .WithColumn("parentinspectionid").AsString(36).Nullable() + .WithColumn("noticeissuedon").AsDateTime2().Nullable() + .WithColumn("noticereference").AsString(100).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsInspections_Department_State").OnTable("rmsinspections").OnColumn("departmentid").Ascending().OnColumn("state").Ascending().OnColumn("scheduledon").Ascending(); + Create.Index("IX_RmsInspections_Occupancy").OnTable("rmsinspections").OnColumn("departmentid").Ascending().OnColumn("rmsoccupancyid").Ascending().OnColumn("completedon").Ascending(); + Create.Index("IX_RmsInspections_Program").OnTable("rmsinspections").OnColumn("departmentid").Ascending().OnColumn("rmsinspectionprogramid").Ascending().OnColumn("state").Ascending(); + } + if (!Schema.Table("rmsviolations").Exists()) + { + Create.Table("rmsviolations") + .WithColumn("rmsviolationid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsinspectionid").AsString(36).NotNullable() + .WithColumn("rmsoccupancyid").AsString(36).NotNullable() + .WithColumn("rmscodesetid").AsString(36).Nullable() + .WithColumn("rmscodesectionid").AsString(36).Nullable() + .WithColumn("checklistitemkey").AsString(100).Nullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("severity").AsInt32().NotNullable().WithDefaultValue(2) + .WithColumn("correctiveaction").AsCustom("text").Nullable() + .WithColumn("dueon").AsDateTime2().Nullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("correctedon").AsDateTime2().Nullable() + .WithColumn("verifiedon").AsDateTime2().Nullable() + .WithColumn("verifiedbyuserid").AsString(128).Nullable() + .WithColumn("reinspectionid").AsString(36).Nullable() + .WithColumn("overdueemittedon").AsDateTime2().Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsViolations_Department_State_Due").OnTable("rmsviolations").OnColumn("departmentid").Ascending().OnColumn("state").Ascending().OnColumn("dueon").Ascending(); + Create.Index("IX_RmsViolations_Inspection").OnTable("rmsviolations").OnColumn("departmentid").Ascending().OnColumn("rmsinspectionid").Ascending(); + Create.Index("IX_RmsViolations_Occupancy").OnTable("rmsviolations").OnColumn("departmentid").Ascending().OnColumn("rmsoccupancyid").Ascending().OnColumn("state").Ascending(); + } + if (!Schema.Table("rmshydrants").Exists()) + { + Create.Table("rmshydrants") + .WithColumn("rmshydrantid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("hydrantnumber").AsString(64).NotNullable() + .WithColumn("type").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("latitude").AsDecimal(12, 8).NotNullable() + .WithColumn("longitude").AsDecimal(12, 8).NotNullable() + .WithColumn("addresstext").AsString(500).Nullable() + .WithColumn("ownerkind").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("ownername").AsString(200).Nullable() + .WithColumn("mainsizeinches").AsDecimal(6,2).Nullable() + .WithColumn("staticpressurepsi").AsInt32().Nullable() + .WithColumn("residualpressurepsi").AsInt32().Nullable() + .WithColumn("flowgpm").AsInt32().Nullable() + .WithColumn("flowclass").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("inservice").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("outofservicereason").AsString(500).Nullable() + .WithColumn("outofservicesince").AsDateTime2().Nullable() + .WithColumn("lasttestedon").AsDateTime2().Nullable() + .WithColumn("lastmaintainedon").AsDateTime2().Nullable() + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("poiid").AsInt32().Nullable() + .WithColumn("source").AsString(32).Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsHydrants_Department_Service").OnTable("rmshydrants").OnColumn("departmentid").Ascending().OnColumn("inservice").Ascending().OnColumn("deletedon").Ascending(); + Create.Index("IX_RmsHydrants_Department_Position").OnTable("rmshydrants").OnColumn("departmentid").Ascending().OnColumn("latitude").Ascending().OnColumn("longitude").Ascending(); + } + if (!Schema.Table("rmshydrantflowtests").Exists()) + { + Create.Table("rmshydrantflowtests") + .WithColumn("rmshydrantflowtestid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmshydrantid").AsString(36).NotNullable() + .WithColumn("testedon").AsDateTime2().NotNullable() + .WithColumn("testedbyuserid").AsString(128).Nullable() + .WithColumn("staticpressurepsi").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("residualpressurepsi").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("pitotpressurepsi").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("outletdiameterinches").AsDecimal(6,2).NotNullable().WithDefaultValue(0) + .WithColumn("coefficient").AsDecimal(6,2).NotNullable().WithDefaultValue(0) + .WithColumn("flowgpm").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("flowclass").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable(); + + Create.Index("IX_RmsHydrantFlowTests_Hydrant").OnTable("rmshydrantflowtests").OnColumn("departmentid").Ascending().OnColumn("rmshydrantid").Ascending().OnColumn("testedon").Descending(); + } + if (!Schema.Table("rmshydrantmaintenances").Exists()) + { + Create.Table("rmshydrantmaintenances") + .WithColumn("rmshydrantmaintenanceid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmshydrantid").AsString(36).NotNullable() + .WithColumn("performedon").AsDateTime2().NotNullable() + .WithColumn("performedbyuserid").AsString(128).Nullable() + .WithColumn("kind").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("returnedtoservice").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("createdon").AsDateTime2().NotNullable(); + + Create.Index("IX_RmsHydrantMaintenances_Hydrant").OnTable("rmshydrantmaintenances").OnColumn("departmentid").Ascending().OnColumn("rmshydrantid").Ascending().OnColumn("performedon").Descending(); + } + if (!Schema.Table("rmspermittypes").Exists()) + { + Create.Table("rmspermittypes") + .WithColumn("rmspermittypeid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("code").AsString(32).Nullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("defaultvaliditydays").AsInt32().NotNullable().WithDefaultValue(365) + .WithColumn("requiresplanreview").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("feeamount").AsDecimal(18, 2).Nullable() + .WithColumn("conditionstemplate").AsCustom("text").Nullable() + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsPermitTypes_Department").OnTable("rmspermittypes").OnColumn("departmentid").Ascending().OnColumn("isactive").Ascending(); + } + if (!Schema.Table("rmspermits").Exists()) + { + Create.Table("rmspermits") + .WithColumn("rmspermitid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmspermittypeid").AsString(36).NotNullable() + .WithColumn("rmsoccupancyid").AsString(36).Nullable() + .WithColumn("permitnumber").AsString(32).Nullable() + .WithColumn("applicantcontactid").AsString(128).Nullable() + .WithColumn("applicantname").AsCustom("text").Nullable() + .WithColumn("applicantphone").AsCustom("text").Nullable() + .WithColumn("applicantemail").AsCustom("text").Nullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("appliedon").AsDateTime2().NotNullable() + .WithColumn("reviewedon").AsDateTime2().Nullable() + .WithColumn("reviewedbyuserid").AsString(128).Nullable() + .WithColumn("issuedon").AsDateTime2().Nullable() + .WithColumn("issuedbyuserid").AsString(128).Nullable() + .WithColumn("effectiveon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("conditions").AsCustom("text").Nullable() + .WithColumn("reviewnotes").AsCustom("text").Nullable() + .WithColumn("feeamount").AsDecimal(18, 2).Nullable() + .WithColumn("feepaidon").AsDateTime2().Nullable() + .WithColumn("invoicereference").AsString(100).Nullable() + .WithColumn("decisionreason").AsString(1000).Nullable() + .WithColumn("expiringemittedon").AsDateTime2().Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsPermits_Department_State_Expires").OnTable("rmspermits").OnColumn("departmentid").Ascending().OnColumn("state").Ascending().OnColumn("expireson").Ascending(); + Create.Index("IX_RmsPermits_Occupancy").OnTable("rmspermits").OnColumn("departmentid").Ascending().OnColumn("rmsoccupancyid").Ascending(); + } + if (!Schema.Table("rmsplanreviews").Exists()) + { + Create.Table("rmsplanreviews") + .WithColumn("rmsplanreviewid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmspermitid").AsString(36).NotNullable() + .WithColumn("cyclenumber").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("submittedon").AsDateTime2().NotNullable() + .WithColumn("revieweruserid").AsString(128).Nullable() + .WithColumn("reviewedon").AsDateTime2().Nullable() + .WithColumn("outcome").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("comments").AsCustom("text").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsPlanReviews_Permit").OnTable("rmsplanreviews").OnColumn("departmentid").Ascending().OnColumn("rmspermitid").Ascending().OnColumn("cyclenumber").Ascending(); + } + if (!Schema.Table("rmscrractivities").Exists()) + { + Create.Table("rmscrractivities") + .WithColumn("rmscrractivityid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("kind").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("occurredon").AsDateTime2().NotNullable() + .WithColumn("title").AsString(250).NotNullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("rmsoccupancyid").AsString(36).Nullable() + .WithColumn("locationtext").AsString(500).Nullable() + .WithColumn("latitude").AsDecimal(12, 8).Nullable() + .WithColumn("longitude").AsDecimal(12, 8).Nullable() + .WithColumn("audiencecount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("smokealarmsinstalled").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("hoursspent").AsDecimal(8,2).NotNullable().WithDefaultValue(0) + .WithColumn("staffuseridscsv").AsCustom("text").Nullable() + .WithColumn("outcome").AsCustom("text").Nullable() + .WithColumn("nerissecondaryjson").AsCustom("text").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsCrrActivities_Department_Occurred").OnTable("rmscrractivities").OnColumn("departmentid").Ascending().OnColumn("occurredon").Ascending(); + } + if (!Schema.Table("rmspreventionattachments").Exists()) + { + Create.Table("rmspreventionattachments") + .WithColumn("rmspreventionattachmentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("parentkind").AsInt32().NotNullable() + .WithColumn("parentid").AsString(36).NotNullable() + .WithColumn("filename").AsCustom("text").Nullable() + .WithColumn("contenttype").AsString(200).Nullable() + .WithColumn("bytesize").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("checksum").AsString(128).Nullable() + .WithColumn("data").AsCustom("bytea").Nullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("uploadedbyuserid").AsString(128).Nullable() + .WithColumn("uploadedon").AsDateTime2().NotNullable() + .WithColumn("scanstate").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("metadatastripped").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("classification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsPreventionAttachments_Parent").OnTable("rmspreventionattachments").OnColumn("departmentid").Ascending().OnColumn("parentkind").Ascending().OnColumn("parentid").Ascending(); + } + if (!Schema.Table("rmspreventionsequences").Exists()) + { + Create.Table("rmspreventionsequences") + .WithColumn("rmspreventionsequenceid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("kind").AsString(16).NotNullable() + .WithColumn("year").AsInt32().NotNullable() + .WithColumn("lastvalue").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("modifiedon").AsDateTime2().NotNullable(); + + Create.Index("UX_RmsPreventionSequences_Key").OnTable("rmspreventionsequences").OnColumn("departmentid").Ascending().OnColumn("kind").Ascending().OnColumn("year").Ascending().WithOptions().Unique(); + } + if (!Schema.Table("rmsinvestigationcases").Exists()) + { + Create.Table("rmsinvestigationcases") + .WithColumn("rmsinvestigationcaseid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("casenumber").AsString(32).Nullable() + .WithColumn("title").AsString(250).NotNullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("openedon").AsDateTime2().NotNullable() + .WithColumn("openedbyuserid").AsString(128).Nullable() + .WithColumn("leadinvestigatoruserid").AsString(128).Nullable() + .WithColumn("rmsoccupancyid").AsString(36).Nullable() + .WithColumn("callid").AsInt32().Nullable() + .WithColumn("incidentsummary").AsCustom("text").Nullable() + .WithColumn("causeclassification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("causedetail").AsCustom("text").Nullable() + .WithColumn("origindescription").AsCustom("text").Nullable() + .WithColumn("findings").AsCustom("text").Nullable() + .WithColumn("findingsauthoruserid").AsString(128).Nullable() + .WithColumn("findingsrecordedon").AsDateTime2().Nullable() + .WithColumn("findingsapprovedon").AsDateTime2().Nullable() + .WithColumn("findingsapprovedbyuserid").AsString(128).Nullable() + .WithColumn("recommendsincidentamendment").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("closedon").AsDateTime2().Nullable() + .WithColumn("closedbyuserid").AsString(128).Nullable() + .WithColumn("closurereason").AsCustom("text").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsInvestigationCases_Department_State").OnTable("rmsinvestigationcases").OnColumn("departmentid").Ascending().OnColumn("state").Ascending().OnColumn("openedon").Ascending(); + } + if (!Schema.Table("rmsinvestigationcaseincidents").Exists()) + { + Create.Table("rmsinvestigationcaseincidents") + .WithColumn("rmsinvestigationcaseincidentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsinvestigationcaseid").AsString(36).NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("pinnedrevisionid").AsString(36).Nullable() + .WithColumn("recordnumber").AsString(64).Nullable() + .WithColumn("linkedon").AsDateTime2().NotNullable() + .WithColumn("linkedbyuserid").AsString(128).Nullable(); + + Create.Index("IX_RmsInvestigationCaseIncidents_Case").OnTable("rmsinvestigationcaseincidents").OnColumn("departmentid").Ascending().OnColumn("rmsinvestigationcaseid").Ascending(); + Create.Index("IX_RmsInvestigationCaseIncidents_Record").OnTable("rmsinvestigationcaseincidents").OnColumn("departmentid").Ascending().OnColumn("recordid").Ascending(); + } + if (!Schema.Table("rmsinvestigationcasemembers").Exists()) + { + Create.Table("rmsinvestigationcasemembers") + .WithColumn("rmsinvestigationcasememberid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsinvestigationcaseid").AsString(36).NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("role").AsInt32().NotNullable().WithDefaultValue(2) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("removedon").AsDateTime2().Nullable() + .WithColumn("removedbyuserid").AsString(128).Nullable(); + + Create.Index("IX_RmsInvestigationCaseMembers_Case").OnTable("rmsinvestigationcasemembers").OnColumn("departmentid").Ascending().OnColumn("rmsinvestigationcaseid").Ascending(); + Create.Index("IX_RmsInvestigationCaseMembers_User").OnTable("rmsinvestigationcasemembers").OnColumn("departmentid").Ascending().OnColumn("userid").Ascending().OnColumn("removedon").Ascending(); + } + if (!Schema.Table("rmsinvestigationnotes").Exists()) + { + Create.Table("rmsinvestigationnotes") + .WithColumn("rmsinvestigationnoteid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsinvestigationcaseid").AsString(36).NotNullable() + .WithColumn("kind").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("occurredon").AsDateTime2().NotNullable() + .WithColumn("authoruserid").AsString(128).Nullable() + .WithColumn("subject").AsCustom("text").Nullable() + .WithColumn("body").AsCustom("text").Nullable() + .WithColumn("islocked").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsInvestigationNotes_Case").OnTable("rmsinvestigationnotes").OnColumn("departmentid").Ascending().OnColumn("rmsinvestigationcaseid").Ascending().OnColumn("occurredon").Ascending(); + } + if (!Schema.Table("rmsinvestigationevidence").Exists()) + { + Create.Table("rmsinvestigationevidence") + .WithColumn("rmsinvestigationevidenceid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsinvestigationcaseid").AsString(36).NotNullable() + .WithColumn("evidencenumber").AsString(32).Nullable() + .WithColumn("kind").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("collectedon").AsDateTime2().NotNullable() + .WithColumn("collectedbyuserid").AsString(128).Nullable() + .WithColumn("collectedfrom").AsCustom("text").Nullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("currentcustodianuserid").AsString(128).Nullable() + .WithColumn("currentcustodianexternal").AsCustom("text").Nullable() + .WithColumn("storagelocation").AsString(250).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Create.Index("IX_RmsInvestigationEvidence_Case").OnTable("rmsinvestigationevidence").OnColumn("departmentid").Ascending().OnColumn("rmsinvestigationcaseid").Ascending(); + } + if (!Schema.Table("rmsinvestigationcustody").Exists()) + { + Create.Table("rmsinvestigationcustody") + .WithColumn("rmsinvestigationcustodyid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsinvestigationevidenceid").AsString(36).NotNullable() + .WithColumn("sequence").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("transferredon").AsDateTime2().NotNullable() + .WithColumn("fromuserid").AsString(128).Nullable() + .WithColumn("fromexternal").AsCustom("text").Nullable() + .WithColumn("touserid").AsString(128).Nullable() + .WithColumn("toexternal").AsCustom("text").Nullable() + .WithColumn("reason").AsCustom("text").Nullable() + .WithColumn("resultingstate").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("recordedbyuserid").AsString(128).Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_RmsInvestigationCustody_Evidence").OnTable("rmsinvestigationcustody").OnColumn("departmentid").Ascending().OnColumn("rmsinvestigationevidenceid").Ascending().OnColumn("sequence").Ascending(); + } + if (!Schema.Table("rmsinvestigationreferrals").Exists()) + { + Create.Table("rmsinvestigationreferrals") + .WithColumn("rmsinvestigationreferralid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("rmsinvestigationcaseid").AsString(36).NotNullable() + .WithColumn("agency").AsString(250).NotNullable() + .WithColumn("referredon").AsDateTime2().NotNullable() + .WithColumn("referredbyuserid").AsString(128).Nullable() + .WithColumn("reason").AsCustom("text").Nullable() + .WithColumn("referencenumber").AsString(100).Nullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsInvestigationReferrals_Case").OnTable("rmsinvestigationreferrals").OnColumn("departmentid").Ascending().OnColumn("rmsinvestigationcaseid").Ascending(); + } + + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Records.Prevention.Occupancy', 'Records Prevention - Occupancies', 'RMS-5 occupancy/property master and the Contacts pre-plan crosswalk. Requires Records.System. Seeded off.', 'Records', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Records.Prevention.Occupancy');"); + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Records.Prevention.Inspections', 'Records Prevention - Inspections', 'RMS-5 inspection programs, code sets, inspections and violations. Requires Records.Prevention.Occupancy. Seeded off.', 'Records', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Records.Prevention.Inspections');"); + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Records.Prevention.Hydrants', 'Records Prevention - Hydrants', 'RMS-5 hydrants and water sources with flow tests and the response-map layer. Requires Records.System. Seeded off.', 'Records', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Records.Prevention.Hydrants');"); + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Records.Prevention.Permits', 'Records Prevention - Permits', 'RMS-5 permits, plan review and expiration. Requires Records.Prevention.Occupancy. Seeded off.', 'Records', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Records.Prevention.Permits');"); + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Records.Prevention.Crr', 'Records Prevention - Community Risk Reduction', 'RMS-5 community risk reduction activity tracking. Requires Records.System. Seeded off.', 'Records', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Records.Prevention.Crr');"); + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Records.Investigations', 'Records Investigations', 'RMS-5 investigation cases with case-level authorization, evidence chain of custody and referrals. Requires Records.System. Seeded off.', 'Records', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Records.Investigations');"); + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Records.QualityReview', 'Records Quality Review', 'RMS-4 optional post-finalization quality review: rubric-scored sampling of finalized records. Requires Records.System. Seeded off.', 'Records', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Records.QualityReview');"); + } + + public override void Down() + { + Delete.FromTable("featureflags").Row(new { flagkey = "Records.Prevention.Occupancy" }); + Delete.FromTable("featureflags").Row(new { flagkey = "Records.Prevention.Inspections" }); + Delete.FromTable("featureflags").Row(new { flagkey = "Records.Prevention.Hydrants" }); + Delete.FromTable("featureflags").Row(new { flagkey = "Records.Prevention.Permits" }); + Delete.FromTable("featureflags").Row(new { flagkey = "Records.Prevention.Crr" }); + Delete.FromTable("featureflags").Row(new { flagkey = "Records.Investigations" }); + Delete.FromTable("featureflags").Row(new { flagkey = "Records.QualityReview" }); + if (Schema.Table("rmsinvestigationreferrals").Exists()) + Delete.Table("rmsinvestigationreferrals"); + if (Schema.Table("rmsinvestigationcustody").Exists()) + Delete.Table("rmsinvestigationcustody"); + if (Schema.Table("rmsinvestigationevidence").Exists()) + Delete.Table("rmsinvestigationevidence"); + if (Schema.Table("rmsinvestigationnotes").Exists()) + Delete.Table("rmsinvestigationnotes"); + if (Schema.Table("rmsinvestigationcasemembers").Exists()) + Delete.Table("rmsinvestigationcasemembers"); + if (Schema.Table("rmsinvestigationcaseincidents").Exists()) + Delete.Table("rmsinvestigationcaseincidents"); + if (Schema.Table("rmsinvestigationcases").Exists()) + Delete.Table("rmsinvestigationcases"); + if (Schema.Table("rmspreventionsequences").Exists()) + Delete.Table("rmspreventionsequences"); + if (Schema.Table("rmspreventionattachments").Exists()) + Delete.Table("rmspreventionattachments"); + if (Schema.Table("rmscrractivities").Exists()) + Delete.Table("rmscrractivities"); + if (Schema.Table("rmsplanreviews").Exists()) + Delete.Table("rmsplanreviews"); + if (Schema.Table("rmspermits").Exists()) + Delete.Table("rmspermits"); + if (Schema.Table("rmspermittypes").Exists()) + Delete.Table("rmspermittypes"); + if (Schema.Table("rmshydrantmaintenances").Exists()) + Delete.Table("rmshydrantmaintenances"); + if (Schema.Table("rmshydrantflowtests").Exists()) + Delete.Table("rmshydrantflowtests"); + if (Schema.Table("rmshydrants").Exists()) + Delete.Table("rmshydrants"); + if (Schema.Table("rmsviolations").Exists()) + Delete.Table("rmsviolations"); + if (Schema.Table("rmsinspections").Exists()) + Delete.Table("rmsinspections"); + if (Schema.Table("rmsinspectionprograms").Exists()) + Delete.Table("rmsinspectionprograms"); + if (Schema.Table("rmscodesections").Exists()) + Delete.Table("rmscodesections"); + if (Schema.Table("rmscodesets").Exists()) + Delete.Table("rmscodesets"); + if (Schema.Table("rmsoccupancyownerships").Exists()) + Delete.Table("rmsoccupancyownerships"); + if (Schema.Table("rmsoccupancyfieldprovenances").Exists()) + Delete.Table("rmsoccupancyfieldprovenances"); + if (Schema.Table("rmsoccupancycrosswalks").Exists()) + Delete.Table("rmsoccupancycrosswalks"); + if (Schema.Table("rmsoccupancyhazards").Exists()) + Delete.Table("rmsoccupancyhazards"); + if (Schema.Table("rmsoccupancycontactlinks").Exists()) + Delete.Table("rmsoccupancycontactlinks"); + if (Schema.Table("rmsoccupancies").Exists()) + Delete.Table("rmsoccupancies"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ContactPreplanRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ContactPreplanRepositories.cs new file mode 100644 index 000000000..85a5b58c1 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ContactPreplanRepositories.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +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 +{ + /// Contact pre-plans (Contacts plan Phase A, registry M0183). Dapper over the shared dialect helpers. + public class ContactPreplanRepository : RmsRepositoryBase, IContactPreplanRepository + { + public ContactPreplanRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetPreplanByContactIdAsync(string contactId, int departmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("ContactPreplans")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("IsDeleted")} = {False} ORDER BY {Col("AddedOn")} DESC", + new { DepartmentId = departmentId, ContactId = contactId }); + } + + public Task> GetPreplansByContactIdsAsync(int departmentId, IEnumerable contactIds) + { + var ids = InListValue(contactIds); + if (ids.Length == 0) + return Task.FromResult>(new List()); + + return QueryAsync( + $"SELECT * FROM {Tbl("ContactPreplans")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("ContactId", "ContactIds")} AND {Col("IsDeleted")} = {False}", + new { DepartmentId = departmentId, ContactIds = ids }); + } + + public Task> GetPreplansByDepartmentIdAsync(int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("ContactPreplans")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}", + new { DepartmentId = departmentId }); + } + + private static string False => IsPostgres ? "FALSE" : "0"; + } + + /// Repeating premise hazards on a contact pre-plan (registry M0183). + public class ContactPreplanHazardRepository : RmsRepositoryBase, IContactPreplanHazardRepository + { + public ContactPreplanHazardRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetHazardsByPreplanIdAsync(string contactPreplanId, int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("ContactPreplanHazards")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactPreplanId")} = {P}PreplanId AND {Col("IsDeleted")} = {False} ORDER BY {Col("Severity")} DESC, {Col("AddedOn")}", + new { DepartmentId = departmentId, PreplanId = contactPreplanId }); + } + + public Task> GetHazardsByContactIdAsync(string contactId, int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("ContactPreplanHazards")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("IsDeleted")} = {False} ORDER BY {Col("Severity")} DESC, {Col("AddedOn")}", + new { DepartmentId = departmentId, ContactId = contactId }); + } + + public Task> GetHazardsByContactIdsAsync(int departmentId, IEnumerable contactIds) + { + var ids = InListValue(contactIds); + if (ids.Length == 0) + return Task.FromResult>(new List()); + + return QueryAsync( + $"SELECT * FROM {Tbl("ContactPreplanHazards")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("ContactId", "ContactIds")} AND {Col("IsDeleted")} = {False} ORDER BY {Col("Severity")} DESC, {Col("AddedOn")}", + new { DepartmentId = departmentId, ContactIds = ids }); + } + + private static string False => IsPostgres ? "FALSE" : "0"; + } + + /// Contact site attachments (registry M0184). Metadata reads exclude the blob column by construction. + public class ContactAttachmentRepository : RmsRepositoryBase, IContactAttachmentRepository + { + public ContactAttachmentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + private static string MetadataColumns() + { + return Cols("ContactAttachmentId", "ContactId", "DepartmentId", "ContactAttachmentType", "Name", "FileName", "FileType", "Size", "IsDeleted", "AddedOn", "AddedByUserId", "IsProtected"); + } + + public Task> GetAttachmentMetaByContactIdAsync(string contactId, int departmentId) + { + return QueryAsync( + $"SELECT {MetadataColumns()} FROM {Tbl("ContactAttachments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("IsDeleted")} = {False} ORDER BY {Col("AddedOn")} DESC", + new { DepartmentId = departmentId, ContactId = contactId }); + } + + public Task> GetAttachmentMetaByContactIdsAsync(int departmentId, IEnumerable contactIds) + { + var ids = InListValue(contactIds); + if (ids.Length == 0) + return Task.FromResult>(new List()); + + return QueryAsync( + $"SELECT {MetadataColumns()} FROM {Tbl("ContactAttachments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("ContactId", "ContactIds")} AND {Col("IsDeleted")} = {False} ORDER BY {Col("AddedOn")} DESC", + new { DepartmentId = departmentId, ContactIds = ids }); + } + + public Task GetAttachmentByIdAsync(int contactAttachmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("ContactAttachments")} WHERE {Col("ContactAttachmentId")} = {P}Id", + new { Id = contactAttachmentId }); + } + + private static string False => IsPostgres ? "FALSE" : "0"; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 334fac249..96b776299 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -1,4 +1,4 @@ -using Autofac; +using Autofac; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; @@ -220,6 +220,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // Indoor Maps Repositories builder.RegisterType().As().InstancePerLifetimeScope(); @@ -322,6 +325,36 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-5 prevention + investigations and the RMS-4 quality review (registry M0185/M0186) + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // NERIS incident report aggregate (RMS-2, registry M0164-M0166) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 2ff27193b..6a09fef1d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -212,6 +212,12 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workflow runs: the RMS-4 release telemetry counts them per department. + 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(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -282,6 +288,36 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-5 prevention + investigations and the RMS-4 quality review (registry M0185/M0186) + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // NERIS incident report aggregate (RMS-2, registry M0164-M0166) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs index d65c243fa..08f60fe37 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs @@ -194,6 +194,7 @@ public class RmsExternalOrdersRepository : RmsRepositoryBase, private static readonly string MetadataColumns = Cols("RmsExternalOrderId", "DepartmentId", "ProtectionId", "RecordId", "ProfileKey", "ProfileVersion", "HomeProfileKey", "HostProfileKey", "SourceScheme", "SourceSystem", "OrderNumber", "IncidentName", "IncidentNumber", "IncidentCountry", "IncidentSubdivision", "OrderingOffice", "DispatchOffice", "RequestingAgency", "ReceivingAgency", "SendingAgency", "DepartmentRole", "CostCode", "AgreementReference", "CurrencyCode", "MeasurementSystem", "TimeZoneId", "CapturedOffsetMinutes", "SourceCapturedOn", "SourceVersion", "ArtifactFileName", "ArtifactContentType", "ArtifactChecksum", "ArtifactSafeUrl", + "ConnectorId", "OwnershipMarker", "Status", "MobilizedOn", "ReleasedOn", "ClosedOutOn", "ClosedOutByUserId", "CloseoutNotes", "IsProtected", "ProtectedCatalogVersion", "CreatedOn", "CreatedByUserId", "ModifiedOn", "ModifiedByUserId", "RowVersion", "DeletedOn"); public RmsExternalOrdersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) @@ -238,5 +239,8 @@ public async Task> GetForOrdersAsync(int depar public Task GetByIdForDepartmentAsync(int departmentId, string fillId) => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsExternalOrderFills")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderFillId")} = {P}Id AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = fillId }); + + public async Task TryBumpRowVersionAsync(int departmentId, string fillId, long expectedVersion, CancellationToken cancellationToken = default) + => await ExecuteAsync($"UPDATE {Tbl("RmsExternalOrderFills")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderFillId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = fillId, Version = expectedVersion }, cancellationToken) == 1; } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs index 094033d7c..de2178cca 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs @@ -97,7 +97,7 @@ public class RmsFieldRolloutEventsRepository : RmsRepositoryBase> GetForWindowAsync(int departmentId, DateTime sinceUtc, int take) + public Task> GetForWindowAsync(int departmentId, DateTime sinceUtc, int take, CancellationToken cancellationToken = default) { var parameters = new DynamicParameters(); parameters.Add("DepartmentId", departmentId); @@ -106,7 +106,7 @@ public Task> GetForWindowAsync(int departmentI parameters.Add("Take", take <= 0 ? 20000 : Math.Min(take, 200000)); return QueryAsync( $"SELECT * FROM {Tbl("RmsFieldRolloutEvents")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("OccurredOn")} >= {P}Since ORDER BY {Col("OccurredOn")}, {Col("RmsFieldRolloutEventId")} {Paging()}", - parameters); + parameters, cancellationToken); } public async Task InsertBatchAsync(IEnumerable events, CancellationToken cancellationToken = default) diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs new file mode 100644 index 000000000..78d186b9d --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs @@ -0,0 +1,669 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +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 +{ + /// + /// RMS-5 prevention, investigation and RMS-4 quality-review repositories (registry M0185/M0186). Dapper over + /// RmsRepositoryBase; every query carries DepartmentId, list parameters go through InList so PostgreSQL binds + /// arrays, and soft-deleted rows are excluded everywhere a DeletedOn column exists. + /// + internal static class RmsPreventionSql + { + public static DynamicParameters Paged(int departmentId, int skip, int take, int cap = 500) + { + var p = new DynamicParameters(); + p.Add("DepartmentId", departmentId); + p.Add("Skip", Math.Max(0, skip)); + p.Add("Take", take <= 0 ? 50 : Math.Min(take, cap)); + return p; + } + } + + public class RmsOccupanciesRepository : RmsRepositoryBase, IRmsOccupanciesRepository + { + public RmsOccupanciesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string occupancyId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsOccupancies")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id", new { DepartmentId = departmentId, Id = occupancyId }); + + public Task> GetByIdsAsync(int departmentId, IEnumerable occupancyIds) + { + var ids = InListValue(occupancyIds); + if (ids.Length == 0) return Task.FromResult>(new List()); + return QueryAsync($"SELECT * FROM {Tbl("RmsOccupancies")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsOccupancyId", "Ids")}", new { DepartmentId = departmentId, Ids = ids }); + } + + private string Where(RmsOccupancyQuery query, DynamicParameters p) + { + var clauses = new List { $"{Col("DepartmentId")} = {P}DepartmentId", $"{Col("DeletedOn")} IS NULL" }; + if (query.Status.HasValue) { clauses.Add($"{Col("Status")} = {P}Status"); p.Add("Status", query.Status.Value); } + else clauses.Add($"{Col("Status")} <> {(int)RmsOccupancyStatus.Merged}"); + if (query.OccupancyType.HasValue) { clauses.Add($"{Col("OccupancyType")} = {P}OccupancyType"); p.Add("OccupancyType", query.OccupancyType.Value); } + if (query.HazmatOnSite.HasValue) { clauses.Add($"{Col("HazmatOnSite")} = {P}Hazmat"); p.Add("Hazmat", query.HazmatOnSite.Value); } + if (query.ReviewOverdue == true) { clauses.Add($"{Col("NextReviewDue")} < {P}Now"); p.Add("Now", DateTime.UtcNow); } + if (!string.IsNullOrWhiteSpace(query.Search)) + { + clauses.Add($"({Col("Name")} LIKE {P}Search OR {Col("AddressText")} LIKE {P}Search OR {Col("OccupancyNumber")} LIKE {P}Search)"); + p.Add("Search", "%" + query.Search.Trim() + "%"); + } + return string.Join(" AND ", clauses); + } + + public Task> QueryAsync(int departmentId, RmsOccupancyQuery query) + { + query ??= new RmsOccupancyQuery(); + var p = RmsPreventionSql.Paged(departmentId, query.Skip, query.Take); + var where = Where(query, p); + return QueryAsync($"SELECT * FROM {Tbl("RmsOccupancies")} WHERE {where} ORDER BY {Col("Name")}, {Col("RmsOccupancyId")} {Paging()}", p); + } + + public Task CountAsync(int departmentId, RmsOccupancyQuery query) + { + query ??= new RmsOccupancyQuery(); + var p = new DynamicParameters(); p.Add("DepartmentId", departmentId); + var where = Where(query, p); + return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsOccupancies")} WHERE {where}", p); + } + + public Task> GetByNormalizedAddressAsync(int departmentId, string normalizedAddress) + => QueryAsync($"SELECT * FROM {Tbl("RmsOccupancies")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("NormalizedAddress")} = {P}Address AND {Col("DeletedOn")} IS NULL AND {Col("Status")} <> {(int)RmsOccupancyStatus.Merged}", new { DepartmentId = departmentId, Address = normalizedAddress }); + + public Task> GetAllLiveAsync(int departmentId) + => QueryAsync($"SELECT * FROM {Tbl("RmsOccupancies")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {Col("Status")} <> {(int)RmsOccupancyStatus.Merged} ORDER BY {Col("Name")}", new { DepartmentId = departmentId }); + + public Task CountLiveAsync(int departmentId) + => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsOccupancies")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {Col("Status")} <> {(int)RmsOccupancyStatus.Merged}", new { DepartmentId = departmentId }); + + public Task CountReviewOverdueAsync(int departmentId, DateTime utcNow) + => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsOccupancies")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {Col("Status")} = {(int)RmsOccupancyStatus.Active} AND {Col("NextReviewDue")} < {P}Now", new { DepartmentId = departmentId, Now = utcNow }); + + public async Task TryBumpRowVersionAsync(int departmentId, string occupancyId, long expectedVersion, CancellationToken cancellationToken = default) + => await ExecuteAsync($"UPDATE {Tbl("RmsOccupancies")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1, {Col("ModifiedOn")} = {P}Now WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id AND {Col("RowVersion")} = {P}Expected", + new { DepartmentId = departmentId, Id = occupancyId, Expected = expectedVersion, Now = DateTime.UtcNow }, cancellationToken) == 1; + } + + public class RmsOccupancyContactLinksRepository : RmsRepositoryBase, IRmsOccupancyContactLinksRepository + { + public RmsOccupancyContactLinksRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) + => QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyContactLinks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id AND {Col("DeletedOn")} IS NULL ORDER BY {Col("Role")}, {Col("CreatedOn")}", new { DepartmentId = departmentId, Id = occupancyId }); + + public Task> GetForContactAsync(int departmentId, string contactId) + => QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyContactLinks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, ContactId = contactId }); + + public Task> GetForContactsAsync(int departmentId, IEnumerable contactIds) + { + var ids = InListValue(contactIds); + if (ids.Length == 0) return Task.FromResult>(new List()); + return QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyContactLinks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("ContactId", "Ids")} AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Ids = ids }); + } + + public Task GetByIdForDepartmentAsync(int departmentId, string linkId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsOccupancyContactLinks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyContactLinkId")} = {P}Id", new { DepartmentId = departmentId, Id = linkId }); + } + + public class RmsOccupancyHazardsRepository : RmsRepositoryBase, IRmsOccupancyHazardsRepository + { + public RmsOccupancyHazardsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) + => QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyHazards")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id AND {Col("DeletedOn")} IS NULL ORDER BY {Col("Severity")} DESC, {Col("Title")}", new { DepartmentId = departmentId, Id = occupancyId }); + + public Task> GetForOccupanciesAsync(int departmentId, IEnumerable occupancyIds) + { + var ids = InListValue(occupancyIds); + if (ids.Length == 0) return Task.FromResult>(new List()); + return QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyHazards")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsOccupancyId", "Ids")} AND {Col("DeletedOn")} IS NULL ORDER BY {Col("Severity")} DESC", new { DepartmentId = departmentId, Ids = ids }); + } + + public Task GetByIdForDepartmentAsync(int departmentId, string hazardId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsOccupancyHazards")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyHazardId")} = {P}Id", new { DepartmentId = departmentId, Id = hazardId }); + } + + public class RmsOccupancyCrosswalksRepository : RmsRepositoryBase, IRmsOccupancyCrosswalksRepository + { + public RmsOccupancyCrosswalksRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string crosswalkId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsOccupancyCrosswalks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyCrosswalkId")} = {P}Id", new { DepartmentId = departmentId, Id = crosswalkId }); + + public Task GetBySourceAsync(int departmentId, RmsOccupancyCrosswalkSourceKind sourceKind, string sourceId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsOccupancyCrosswalks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("SourceKind")} = {P}Kind AND {Col("SourceId")} = {P}SourceId", new { DepartmentId = departmentId, Kind = (int)sourceKind, SourceId = sourceId }); + + public Task> GetByStateAsync(int departmentId, RmsOccupancyCrosswalkState state, int skip, int take) + { + var p = RmsPreventionSql.Paged(departmentId, skip, take, 1000); p.Add("State", (int)state); + return QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyCrosswalks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {P}State ORDER BY {Col("GroupKey")}, {Col("SourceDisplayName")}, {Col("RmsOccupancyCrosswalkId")} {Paging()}", p); + } + + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) + => QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyCrosswalks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id", new { DepartmentId = departmentId, Id = occupancyId }); + + public Task> GetForContactAsync(int departmentId, string contactId) + => QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyCrosswalks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId", new { DepartmentId = departmentId, ContactId = contactId }); + + public Task> GetForContactsAsync(int departmentId, IEnumerable contactIds) + { + var ids = InListValue(contactIds); + if (ids.Length == 0) return Task.FromResult>(new List()); + return QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyCrosswalks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("ContactId", "Ids")}", new { DepartmentId = departmentId, Ids = ids }); + } + + public Task> GetAllForDepartmentAsync(int departmentId) + => QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyCrosswalks")} WHERE {Col("DepartmentId")} = {P}DepartmentId", new { DepartmentId = departmentId }); + + public Task CountByStateAsync(int departmentId, RmsOccupancyCrosswalkState state) + => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsOccupancyCrosswalks")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {P}State", new { DepartmentId = departmentId, State = (int)state }); + } + + public class RmsOccupancyFieldProvenancesRepository : RmsRepositoryBase, IRmsOccupancyFieldProvenancesRepository + { + public RmsOccupancyFieldProvenancesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) + => QueryAsync($"SELECT * FROM {Tbl("RmsOccupancyFieldProvenances")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id ORDER BY {Col("FieldKey")}", new { DepartmentId = departmentId, Id = occupancyId }); + + public Task DeleteForOccupancyAsync(int departmentId, string occupancyId, CancellationToken cancellationToken = default) + => ExecuteAsync($"DELETE FROM {Tbl("RmsOccupancyFieldProvenances")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id", new { DepartmentId = departmentId, Id = occupancyId }, cancellationToken); + } + + public class RmsOccupancyOwnershipsRepository : RmsRepositoryBase, IRmsOccupancyOwnershipsRepository + { + public RmsOccupancyOwnershipsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetForDepartmentAsync(int departmentId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsOccupancyOwnerships")} WHERE {Col("DepartmentId")} = {P}DepartmentId", new { DepartmentId = departmentId }); + } + + public class RmsCodeSetsRepository : RmsRepositoryBase, IRmsCodeSetsRepository + { + public RmsCodeSetsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string codeSetId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsCodeSets")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsCodeSetId")} = {P}Id", new { DepartmentId = departmentId, Id = codeSetId }); + + public Task> GetForDepartmentAsync(int departmentId, bool includeInactive) + => QueryAsync($"SELECT * FROM {Tbl("RmsCodeSets")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL{(includeInactive ? "" : $" AND {Col("IsActive")} = {P}Active")} ORDER BY {Col("Name")}", new { DepartmentId = departmentId, Active = true }); + } + + public class RmsCodeSectionsRepository : RmsRepositoryBase, IRmsCodeSectionsRepository + { + public RmsCodeSectionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string sectionId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsCodeSections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsCodeSectionId")} = {P}Id", new { DepartmentId = departmentId, Id = sectionId }); + + public Task> GetForCodeSetAsync(int departmentId, string codeSetId) + => QueryAsync($"SELECT * FROM {Tbl("RmsCodeSections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsCodeSetId")} = {P}Id AND {Col("DeletedOn")} IS NULL ORDER BY {Col("SectionNumber")}", new { DepartmentId = departmentId, Id = codeSetId }); + + public Task> GetByIdsAsync(int departmentId, IEnumerable sectionIds) + { + var ids = InListValue(sectionIds); + if (ids.Length == 0) return Task.FromResult>(new List()); + return QueryAsync($"SELECT * FROM {Tbl("RmsCodeSections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsCodeSectionId", "Ids")}", new { DepartmentId = departmentId, Ids = ids }); + } + } + + public class RmsInspectionProgramsRepository : RmsRepositoryBase, IRmsInspectionProgramsRepository + { + public RmsInspectionProgramsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string programId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsInspectionPrograms")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInspectionProgramId")} = {P}Id", new { DepartmentId = departmentId, Id = programId }); + + public Task> GetForDepartmentAsync(int departmentId, bool includeInactive) + => QueryAsync($"SELECT * FROM {Tbl("RmsInspectionPrograms")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL{(includeInactive ? "" : $" AND {Col("IsActive")} = {P}Active")} ORDER BY {Col("Name")}", new { DepartmentId = departmentId, Active = true }); + } + + public class RmsInspectionsRepository : RmsRepositoryBase, IRmsInspectionsRepository + { + public RmsInspectionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string inspectionId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsInspections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInspectionId")} = {P}Id", new { DepartmentId = departmentId, Id = inspectionId }); + + private string Where(RmsInspectionQuery query, DynamicParameters p) + { + var clauses = new List { $"{Col("DepartmentId")} = {P}DepartmentId", $"{Col("DeletedOn")} IS NULL" }; + if (!string.IsNullOrWhiteSpace(query.OccupancyId)) { clauses.Add($"{Col("RmsOccupancyId")} = {P}OccupancyId"); p.Add("OccupancyId", query.OccupancyId); } + if (!string.IsNullOrWhiteSpace(query.ProgramId)) { clauses.Add($"{Col("RmsInspectionProgramId")} = {P}ProgramId"); p.Add("ProgramId", query.ProgramId); } + if (!string.IsNullOrWhiteSpace(query.InspectorUserId)) { clauses.Add($"{Col("InspectorUserId")} = {P}Inspector"); p.Add("Inspector", query.InspectorUserId); } + if (query.States != null && query.States.Count > 0) { clauses.Add(InList("State", "States")); p.Add("States", InListValue(query.States)); } + if (query.ScheduledBefore.HasValue) { clauses.Add($"{Col("ScheduledOn")} <= {P}Before"); p.Add("Before", query.ScheduledBefore.Value); } + return string.Join(" AND ", clauses); + } + + public Task> QueryAsync(int departmentId, RmsInspectionQuery query) + { + query ??= new RmsInspectionQuery(); + var p = RmsPreventionSql.Paged(departmentId, query.Skip, query.Take); + return QueryAsync($"SELECT * FROM {Tbl("RmsInspections")} WHERE {Where(query, p)} ORDER BY {Col("ScheduledOn")} DESC, {Col("CreatedOn")} DESC, {Col("RmsInspectionId")} {Paging()}", p); + } + + public Task CountAsync(int departmentId, RmsInspectionQuery query) + { + query ??= new RmsInspectionQuery(); + var p = new DynamicParameters(); p.Add("DepartmentId", departmentId); + return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsInspections")} WHERE {Where(query, p)}", p); + } + + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) + => QueryAsync($"SELECT * FROM {Tbl("RmsInspections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id AND {Col("DeletedOn")} IS NULL ORDER BY {Col("CreatedOn")} DESC", new { DepartmentId = departmentId, Id = occupancyId }); + + public async Task> GetLastCompletedByOccupancyAsync(int departmentId, string programId) + { + var rows = await QueryAsync<(string OccupancyId, DateTime? Completed)>( + $"SELECT {Col("RmsOccupancyId")} AS OccupancyId, MAX({Col("CompletedOn")}) AS Completed FROM {Tbl("RmsInspections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInspectionProgramId")} = {P}ProgramId AND {Col("CompletedOn")} IS NOT NULL AND {Col("DeletedOn")} IS NULL GROUP BY {Col("RmsOccupancyId")}", + new { DepartmentId = departmentId, ProgramId = programId }); + return rows.Where(r => r.Completed.HasValue).ToDictionary(r => r.OccupancyId, r => r.Completed.Value, StringComparer.Ordinal); + } + + public Task> GetOpenForProgramAsync(int departmentId, string programId) + => QueryAsync($"SELECT * FROM {Tbl("RmsInspections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInspectionProgramId")} = {P}ProgramId AND {Col("State")} IN ({(int)RmsInspectionState.Scheduled}, {(int)RmsInspectionState.InProgress}, {(int)RmsInspectionState.ReinspectionRequired}) AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, ProgramId = programId }); + + public async Task TryBumpRowVersionAsync(int departmentId, string inspectionId, long expectedVersion, CancellationToken cancellationToken = default) + => await ExecuteAsync($"UPDATE {Tbl("RmsInspections")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1, {Col("ModifiedOn")} = {P}Now WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInspectionId")} = {P}Id AND {Col("RowVersion")} = {P}Expected", + new { DepartmentId = departmentId, Id = inspectionId, Expected = expectedVersion, Now = DateTime.UtcNow }, cancellationToken) == 1; + } + + public class RmsViolationsRepository : RmsRepositoryBase, IRmsViolationsRepository + { + public RmsViolationsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + private string OpenStates => $"{Col("State")} IN ({(int)RmsViolationState.Open}, {(int)RmsViolationState.Escalated})"; + + public Task GetByIdForDepartmentAsync(int departmentId, string violationId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsViolations")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsViolationId")} = {P}Id", new { DepartmentId = departmentId, Id = violationId }); + + public Task> GetForInspectionAsync(int departmentId, string inspectionId) + => QueryAsync($"SELECT * FROM {Tbl("RmsViolations")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInspectionId")} = {P}Id AND {Col("DeletedOn")} IS NULL ORDER BY {Col("Severity")} DESC, {Col("CreatedOn")}", new { DepartmentId = departmentId, Id = inspectionId }); + + public Task> GetForOccupancyAsync(int departmentId, string occupancyId, bool openOnly) + => QueryAsync($"SELECT * FROM {Tbl("RmsViolations")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id AND {Col("DeletedOn")} IS NULL{(openOnly ? " AND " + OpenStates : "")} ORDER BY {Col("CreatedOn")} DESC", new { DepartmentId = departmentId, Id = occupancyId }); + + public Task> GetOpenAsync(int departmentId, int take) + { + var p = RmsPreventionSql.Paged(departmentId, 0, take, 1000); + return QueryAsync($"SELECT * FROM {Tbl("RmsViolations")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {OpenStates} AND {Col("DeletedOn")} IS NULL ORDER BY {Col("DueOn")}, {Col("Severity")} DESC, {Col("RmsViolationId")} {Paging()}", p); + } + + public Task> GetOverdueNotEmittedAsync(int departmentId, DateTime utcNow, int take) + { + var p = RmsPreventionSql.Paged(departmentId, 0, take, 1000); p.Add("Now", utcNow); + return QueryAsync($"SELECT * FROM {Tbl("RmsViolations")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {OpenStates} AND {Col("DueOn")} < {P}Now AND {Col("OverdueEmittedOn")} IS NULL AND {Col("DeletedOn")} IS NULL ORDER BY {Col("DueOn")}, {Col("RmsViolationId")} {Paging()}", p); + } + + public Task CountOpenAsync(int departmentId) + => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsViolations")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {OpenStates} AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId }); + + public Task CountOverdueAsync(int departmentId, DateTime utcNow) + => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsViolations")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {OpenStates} AND {Col("DueOn")} < {P}Now AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Now = utcNow }); + + public async Task> CountOpenByOccupancyAsync(int departmentId, IEnumerable occupancyIds) + { + var ids = InListValue(occupancyIds); + if (ids.Length == 0) return new Dictionary(StringComparer.Ordinal); + var rows = await QueryAsync<(string OccupancyId, int Open)>( + $"SELECT {Col("RmsOccupancyId")} AS OccupancyId, COUNT(1) AS Open FROM {Tbl("RmsViolations")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsOccupancyId", "Ids")} AND {OpenStates} AND {Col("DeletedOn")} IS NULL GROUP BY {Col("RmsOccupancyId")}", + new { DepartmentId = departmentId, Ids = ids }); + return rows.ToDictionary(r => r.OccupancyId, r => r.Open, StringComparer.Ordinal); + } + } + + public class RmsHydrantsRepository : RmsRepositoryBase, IRmsHydrantsRepository + { + public RmsHydrantsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string hydrantId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsHydrantId")} = {P}Id", new { DepartmentId = departmentId, Id = hydrantId }); + + public Task GetByNumberAsync(int departmentId, string hydrantNumber) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("HydrantNumber")} = {P}Number AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Number = hydrantNumber }); + + public Task> GetAllLiveAsync(int departmentId) + => QueryAsync($"SELECT * FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL ORDER BY {Col("HydrantNumber")}", new { DepartmentId = departmentId }); + + public Task> GetByIdsAsync(int departmentId, IEnumerable hydrantIds) + { + var ids = InListValue(hydrantIds); + if (ids.Length == 0) return Task.FromResult>(new List()); + return QueryAsync($"SELECT * FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsHydrantId", "Ids")}", new { DepartmentId = departmentId, Ids = ids }); + } + + public Task> GetInBoundsAsync(int departmentId, decimal minLat, decimal maxLat, decimal minLon, decimal maxLon, int take) + { + var p = RmsPreventionSql.Paged(departmentId, 0, take, 5000); + p.Add("MinLat", minLat); p.Add("MaxLat", maxLat); p.Add("MinLon", minLon); p.Add("MaxLon", maxLon); + return QueryAsync($"SELECT * FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {Col("Latitude")} BETWEEN {P}MinLat AND {P}MaxLat AND {Col("Longitude")} BETWEEN {P}MinLon AND {P}MaxLon ORDER BY {Col("HydrantNumber")}, {Col("RmsHydrantId")} {Paging()}", p); + } + + public Task CountLiveAsync(int departmentId) + => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId }); + + public Task CountOutOfServiceAsync(int departmentId) + => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {Col("InService")} = {P}Off", new { DepartmentId = departmentId, Off = false }); + + public Task CountTestDueAsync(int departmentId, DateTime cutoffUtc) + => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {Col("InService")} = {P}On AND ({Col("LastTestedOn")} IS NULL OR {Col("LastTestedOn")} < {P}Cutoff)", new { DepartmentId = departmentId, On = true, Cutoff = cutoffUtc }); + } + + public class RmsHydrantFlowTestsRepository : RmsRepositoryBase, IRmsHydrantFlowTestsRepository + { + public RmsHydrantFlowTestsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForHydrantAsync(int departmentId, string hydrantId) + => QueryAsync($"SELECT * FROM {Tbl("RmsHydrantFlowTests")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsHydrantId")} = {P}Id ORDER BY {Col("TestedOn")} DESC", new { DepartmentId = departmentId, Id = hydrantId }); + } + + public class RmsHydrantMaintenancesRepository : RmsRepositoryBase, IRmsHydrantMaintenancesRepository + { + public RmsHydrantMaintenancesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForHydrantAsync(int departmentId, string hydrantId) + => QueryAsync($"SELECT * FROM {Tbl("RmsHydrantMaintenances")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsHydrantId")} = {P}Id ORDER BY {Col("PerformedOn")} DESC", new { DepartmentId = departmentId, Id = hydrantId }); + } + + public class RmsPermitTypesRepository : RmsRepositoryBase, IRmsPermitTypesRepository + { + public RmsPermitTypesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string permitTypeId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsPermitTypes")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsPermitTypeId")} = {P}Id", new { DepartmentId = departmentId, Id = permitTypeId }); + + public Task> GetForDepartmentAsync(int departmentId, bool includeInactive) + => QueryAsync($"SELECT * FROM {Tbl("RmsPermitTypes")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL{(includeInactive ? "" : $" AND {Col("IsActive")} = {P}Active")} ORDER BY {Col("Name")}", new { DepartmentId = departmentId, Active = true }); + } + + public class RmsPermitsRepository : RmsRepositoryBase, IRmsPermitsRepository + { + public RmsPermitsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string permitId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsPermits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsPermitId")} = {P}Id", new { DepartmentId = departmentId, Id = permitId }); + + private string Where(RmsPermitQuery query, DynamicParameters p) + { + var clauses = new List { $"{Col("DepartmentId")} = {P}DepartmentId", $"{Col("DeletedOn")} IS NULL" }; + if (!string.IsNullOrWhiteSpace(query.OccupancyId)) { clauses.Add($"{Col("RmsOccupancyId")} = {P}OccupancyId"); p.Add("OccupancyId", query.OccupancyId); } + if (!string.IsNullOrWhiteSpace(query.PermitTypeId)) { clauses.Add($"{Col("RmsPermitTypeId")} = {P}TypeId"); p.Add("TypeId", query.PermitTypeId); } + if (query.States != null && query.States.Count > 0) { clauses.Add(InList("State", "States")); p.Add("States", InListValue(query.States)); } + if (query.ExpiresBefore.HasValue) { clauses.Add($"{Col("ExpiresOn")} <= {P}Before"); p.Add("Before", query.ExpiresBefore.Value); } + return string.Join(" AND ", clauses); + } + + public Task> QueryAsync(int departmentId, RmsPermitQuery query) + { + query ??= new RmsPermitQuery(); + var p = RmsPreventionSql.Paged(departmentId, query.Skip, query.Take); + return QueryAsync($"SELECT * FROM {Tbl("RmsPermits")} WHERE {Where(query, p)} ORDER BY {Col("AppliedOn")} DESC, {Col("RmsPermitId")} {Paging()}", p); + } + + public Task CountAsync(int departmentId, RmsPermitQuery query) + { + query ??= new RmsPermitQuery(); + var p = new DynamicParameters(); p.Add("DepartmentId", departmentId); + return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsPermits")} WHERE {Where(query, p)}", p); + } + + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) + => QueryAsync($"SELECT * FROM {Tbl("RmsPermits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id AND {Col("DeletedOn")} IS NULL ORDER BY {Col("AppliedOn")} DESC", new { DepartmentId = departmentId, Id = occupancyId }); + + public Task> GetExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc, int take) + { + var p = RmsPreventionSql.Paged(departmentId, 0, take, 1000); p.Add("Now", utcNow); p.Add("Horizon", horizonUtc); + return QueryAsync($"SELECT * FROM {Tbl("RmsPermits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {(int)RmsPermitState.Issued} AND {Col("ExpiresOn")} IS NOT NULL AND {Col("ExpiresOn")} <= {P}Horizon AND {Col("DeletedOn")} IS NULL ORDER BY {Col("ExpiresOn")}, {Col("RmsPermitId")} {Paging()}", p); + } + + public Task CountExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc) + => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsPermits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {(int)RmsPermitState.Issued} AND {Col("ExpiresOn")} > {P}Now AND {Col("ExpiresOn")} <= {P}Horizon AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Now = utcNow, Horizon = horizonUtc }); + + public async Task TryBumpRowVersionAsync(int departmentId, string permitId, long expectedVersion, CancellationToken cancellationToken = default) + => await ExecuteAsync($"UPDATE {Tbl("RmsPermits")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1, {Col("ModifiedOn")} = {P}Now WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsPermitId")} = {P}Id AND {Col("RowVersion")} = {P}Expected", + new { DepartmentId = departmentId, Id = permitId, Expected = expectedVersion, Now = DateTime.UtcNow }, cancellationToken) == 1; + } + + public class RmsPlanReviewsRepository : RmsRepositoryBase, IRmsPlanReviewsRepository + { + public RmsPlanReviewsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForPermitAsync(int departmentId, string permitId) + => QueryAsync($"SELECT * FROM {Tbl("RmsPlanReviews")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsPermitId")} = {P}Id ORDER BY {Col("CycleNumber")}", new { DepartmentId = departmentId, Id = permitId }); + + public Task GetByIdForDepartmentAsync(int departmentId, string reviewId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsPlanReviews")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsPlanReviewId")} = {P}Id", new { DepartmentId = departmentId, Id = reviewId }); + } + + public class RmsCrrActivitiesRepository : RmsRepositoryBase, IRmsCrrActivitiesRepository + { + public RmsCrrActivitiesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string activityId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsCrrActivities")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsCrrActivityId")} = {P}Id", new { DepartmentId = departmentId, Id = activityId }); + + public Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) + { + var p = RmsPreventionSql.Paged(departmentId, 0, take, 2000); p.Add("Start", startUtc); p.Add("End", endUtc); + return QueryAsync($"SELECT * FROM {Tbl("RmsCrrActivities")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("OccurredOn")} >= {P}Start AND {Col("OccurredOn")} < {P}End AND {Col("DeletedOn")} IS NULL ORDER BY {Col("OccurredOn")} DESC, {Col("RmsCrrActivityId")} {Paging()}", p); + } + + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) + => QueryAsync($"SELECT * FROM {Tbl("RmsCrrActivities")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOccupancyId")} = {P}Id AND {Col("DeletedOn")} IS NULL ORDER BY {Col("OccurredOn")} DESC", new { DepartmentId = departmentId, Id = occupancyId }); + } + + public class RmsPreventionAttachmentsRepository : RmsRepositoryBase, IRmsPreventionAttachmentsRepository + { + private static readonly string[] MetadataColumns = + { + "RmsPreventionAttachmentId", "DepartmentId", "ProtectionId", "ParentKind", "ParentId", "FileName", "ContentType", "ByteSize", "Checksum", "Description", + "UploadedByUserId", "UploadedOn", "ScanState", "MetadataStripped", "Classification", "IsProtected", "ProtectedCatalogVersion", "CreatedOn", "ModifiedOn", "RowVersion", "DeletedOn" + }; + + public RmsPreventionAttachmentsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string attachmentId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsPreventionAttachments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsPreventionAttachmentId")} = {P}Id", new { DepartmentId = departmentId, Id = attachmentId }); + + public Task> GetMetadataForParentAsync(int departmentId, RmsPreventionParentKind parentKind, string parentId) + => QueryAsync($"SELECT {Cols(MetadataColumns)} FROM {Tbl("RmsPreventionAttachments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ParentKind")} = {P}Kind AND {Col("ParentId")} = {P}ParentId AND {Col("DeletedOn")} IS NULL ORDER BY {Col("UploadedOn")}", new { DepartmentId = departmentId, Kind = (int)parentKind, ParentId = parentId }); + } + + public class RmsPreventionSequencesRepository : RmsRepositoryBase, IRmsPreventionSequencesRepository + { + public RmsPreventionSequencesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task NextAsync(int departmentId, string kind, int year, CancellationToken cancellationToken = default) + { + var p = new { Id = Guid.NewGuid().ToString(), DepartmentId = departmentId, Kind = kind, Year = year, Now = DateTime.UtcNow }; + var sql = IsPostgres + ? $@"INSERT INTO {Tbl("RmsPreventionSequences")} ({Cols("RmsPreventionSequenceId", "DepartmentId", "Kind", "Year", "LastValue", "ModifiedOn")} + VALUES ({P}Id, {P}DepartmentId, {P}Kind, {P}Year, 1, {P}Now) + ON CONFLICT ({Cols("DepartmentId", "Kind", "Year")}) DO UPDATE SET {Col("LastValue")} = {Tbl("RmsPreventionSequences")}.{Col("LastValue")} + 1, {Col("ModifiedOn")} = {P}Now + RETURNING {Col("LastValue")}" + : $@"MERGE {Tbl("RmsPreventionSequences")} WITH (HOLDLOCK) AS target + USING (SELECT {P}DepartmentId AS DepartmentId, {P}Kind AS Kind, {P}Year AS Year) AS source + ON target.[DepartmentId] = source.DepartmentId AND target.[Kind] = source.Kind AND target.[Year] = source.Year + WHEN MATCHED THEN UPDATE SET [LastValue] = target.[LastValue] + 1, [ModifiedOn] = {P}Now + WHEN NOT MATCHED THEN INSERT ([RmsPreventionSequenceId], [DepartmentId], [Kind], [Year], [LastValue], [ModifiedOn]) VALUES ({P}Id, {P}DepartmentId, {P}Kind, {P}Year, 1, {P}Now) + OUTPUT inserted.[LastValue];"; + return ScalarAsync(sql, p, cancellationToken); + } + } + + public class RmsInvestigationCasesRepository : RmsRepositoryBase, IRmsInvestigationCasesRepository + { + public RmsInvestigationCasesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string caseId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsInvestigationCases")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationCaseId")} = {P}Id", new { DepartmentId = departmentId, Id = caseId }); + + public Task> GetByIdsAsync(int departmentId, IEnumerable caseIds) + { + var ids = InListValue(caseIds); + if (ids.Length == 0) return Task.FromResult>(new List()); + return QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationCases")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsInvestigationCaseId", "Ids")} AND {Col("DeletedOn")} IS NULL ORDER BY {Col("OpenedOn")} DESC", new { DepartmentId = departmentId, Ids = ids }); + } + + public Task> GetForDepartmentAsync(int departmentId, bool includeClosed, int skip, int take) + { + var p = RmsPreventionSql.Paged(departmentId, skip, take); + return QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationCases")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL{(includeClosed ? "" : $" AND {Col("State")} <> {(int)RmsInvestigationCaseState.Closed}")} ORDER BY {Col("OpenedOn")} DESC, {Col("RmsInvestigationCaseId")} {Paging()}", p); + } + + public Task CountOpenAsync(int departmentId) + => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsInvestigationCases")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {Col("State")} <> {(int)RmsInvestigationCaseState.Closed}", new { DepartmentId = departmentId }); + + public async Task TryBumpRowVersionAsync(int departmentId, string caseId, long expectedVersion, CancellationToken cancellationToken = default) + => await ExecuteAsync($"UPDATE {Tbl("RmsInvestigationCases")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1, {Col("ModifiedOn")} = {P}Now WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationCaseId")} = {P}Id AND {Col("RowVersion")} = {P}Expected", + new { DepartmentId = departmentId, Id = caseId, Expected = expectedVersion, Now = DateTime.UtcNow }, cancellationToken) == 1; + } + + public class RmsInvestigationCaseIncidentsRepository : RmsRepositoryBase, IRmsInvestigationCaseIncidentsRepository + { + public RmsInvestigationCaseIncidentsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForCaseAsync(int departmentId, string caseId) + => QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationCaseIncidents")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationCaseId")} = {P}Id ORDER BY {Col("LinkedOn")}", new { DepartmentId = departmentId, Id = caseId }); + + public Task> GetForRecordAsync(int departmentId, string recordId) + => QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationCaseIncidents")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId", new { DepartmentId = departmentId, RecordId = recordId }); + } + + public class RmsInvestigationCaseMembersRepository : RmsRepositoryBase, IRmsInvestigationCaseMembersRepository + { + public RmsInvestigationCaseMembersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForCaseAsync(int departmentId, string caseId) + => QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationCaseMembers")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationCaseId")} = {P}Id ORDER BY {Col("AddedOn")}", new { DepartmentId = departmentId, Id = caseId }); + + public Task> GetActiveForUserAsync(int departmentId, string userId) + => QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationCaseMembers")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("UserId")} = {P}UserId AND {Col("RemovedOn")} IS NULL", new { DepartmentId = departmentId, UserId = userId }); + + public Task GetByIdForDepartmentAsync(int departmentId, string memberId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsInvestigationCaseMembers")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationCaseMemberId")} = {P}Id", new { DepartmentId = departmentId, Id = memberId }); + } + + public class RmsInvestigationNotesRepository : RmsRepositoryBase, IRmsInvestigationNotesRepository + { + public RmsInvestigationNotesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string noteId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsInvestigationNotes")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationNoteId")} = {P}Id", new { DepartmentId = departmentId, Id = noteId }); + + public Task> GetForCaseAsync(int departmentId, string caseId) + => QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationNotes")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationCaseId")} = {P}Id AND {Col("DeletedOn")} IS NULL ORDER BY {Col("OccurredOn")}", new { DepartmentId = departmentId, Id = caseId }); + + public Task LockForCaseAsync(int departmentId, string caseId, DateTime utcNow, CancellationToken cancellationToken = default) + => ExecuteAsync($"UPDATE {Tbl("RmsInvestigationNotes")} SET {Col("IsLocked")} = {P}Locked, {Col("ModifiedOn")} = {P}Now WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationCaseId")} = {P}Id AND {Col("IsLocked")} = {P}Unlocked", new { DepartmentId = departmentId, Id = caseId, Now = utcNow, Locked = true, Unlocked = false }, cancellationToken); + } + + public class RmsInvestigationEvidenceRepository : RmsRepositoryBase, IRmsInvestigationEvidenceRepository + { + public RmsInvestigationEvidenceRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string evidenceId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsInvestigationEvidence")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationEvidenceId")} = {P}Id", new { DepartmentId = departmentId, Id = evidenceId }); + + public Task> GetForCaseAsync(int departmentId, string caseId) + => QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationEvidence")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationCaseId")} = {P}Id AND {Col("DeletedOn")} IS NULL ORDER BY {Col("CollectedOn")}", new { DepartmentId = departmentId, Id = caseId }); + } + + public class RmsInvestigationCustodyRepository : RmsRepositoryBase, IRmsInvestigationCustodyRepository + { + public RmsInvestigationCustodyRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForEvidenceAsync(int departmentId, string evidenceId) + => QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationCustody")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationEvidenceId")} = {P}Id ORDER BY {Col("Sequence")}", new { DepartmentId = departmentId, Id = evidenceId }); + } + + public class RmsInvestigationReferralsRepository : RmsRepositoryBase, IRmsInvestigationReferralsRepository + { + public RmsInvestigationReferralsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string referralId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsInvestigationReferrals")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationReferralId")} = {P}Id", new { DepartmentId = departmentId, Id = referralId }); + + public Task> GetForCaseAsync(int departmentId, string caseId) + => QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationReferrals")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationCaseId")} = {P}Id ORDER BY {Col("ReferredOn")}", new { DepartmentId = departmentId, Id = caseId }); + } + + public class RmsQualityRubricsRepository : RmsRepositoryBase, IRmsQualityRubricsRepository + { + public RmsQualityRubricsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string rubricId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsQualityRubrics")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsQualityRubricId")} = {P}Id", new { DepartmentId = departmentId, Id = rubricId }); + + public Task> GetForDepartmentAsync(int departmentId, bool includeInactive) + => QueryAsync($"SELECT * FROM {Tbl("RmsQualityRubrics")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL{(includeInactive ? "" : $" AND {Col("IsActive")} = {P}Active")} ORDER BY {Col("Name")}", new { DepartmentId = departmentId, Active = true }); + } + + public class RmsQualityReviewsRepository : RmsRepositoryBase, IRmsQualityReviewsRepository + { + public RmsQualityReviewsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string reviewId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsQualityReviews")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsQualityReviewId")} = {P}Id", new { DepartmentId = departmentId, Id = reviewId }); + + public Task> GetForRecordAsync(int departmentId, string recordId) + => QueryAsync($"SELECT * FROM {Tbl("RmsQualityReviews")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId ORDER BY {Col("SampledOn")} DESC", new { DepartmentId = departmentId, RecordId = recordId }); + + public Task> GetPendingAsync(int departmentId, int take) + { + var p = RmsPreventionSql.Paged(departmentId, 0, take, 1000); + return QueryAsync($"SELECT * FROM {Tbl("RmsQualityReviews")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ScoredOn")} IS NULL ORDER BY {Col("SampledOn")}, {Col("RmsQualityReviewId")} {Paging()}", p); + } + + public Task> GetScoredSinceAsync(int departmentId, DateTime sinceUtc, int take) + { + var p = RmsPreventionSql.Paged(departmentId, 0, take, 5000); p.Add("Since", sinceUtc); + return QueryAsync($"SELECT * FROM {Tbl("RmsQualityReviews")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ScoredOn")} >= {P}Since ORDER BY {Col("ScoredOn")} DESC, {Col("RmsQualityReviewId")} {Paging()}", p); + } + + public async Task> GetReviewedRecordIdsAsync(int departmentId, IEnumerable recordIds) + { + var ids = InListValue(recordIds); + if (ids.Length == 0) return new List(); + return await QueryAsync($"SELECT DISTINCT {Col("RecordId")} FROM {Tbl("RmsQualityReviews")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RecordId", "Ids")}", new { DepartmentId = departmentId, Ids = ids }); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index 79c341040..a36d65272 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -574,7 +574,7 @@ public async Task ApplyScanResultAsync(int departmentId, string attachment private static readonly string[] MetadataColumns = { "RmsRecordAttachmentId", "DepartmentId", "ProtectionId", "RecordId", "FileName", "ContentType", "ByteSize", "Checksum", - "StorageReference", "Description", "UploadedByUserId", "UploadedOn", "ScanState", "MetadataStripped", "IsProtected", + "StorageReference", "Description", "UploadedByUserId", "UploadedOn", "ScanState", "MetadataStripped", "MediaLocationRetained", "IsProtected", "ProtectedCatalogVersion", "Classification", "CreatedOn", "ModifiedOn", "RowVersion", "DeletedOn" }; @@ -588,6 +588,11 @@ public Task> GetMetadataForRecordAsync(int depa new { DepartmentId = departmentId, RecordId = recordId }); } + public Task CountByScanStateAsync(int departmentId, int scanState) + { + return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsRecordAttachments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ScanState")} = {P}ScanState AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, ScanState = scanState }); + } + public Task> GetPendingScanAsync(int departmentId, int take) { // Metadata only: the rescan loads bytes one attachment at a time so a sweep never holds a batch of them. @@ -703,6 +708,16 @@ public Task CountByStateAsync(int state) return ScalarAsync($"SELECT MIN({Col("CreatedOn")}) FROM {Tbl("DomainEventOutbox")} WHERE {Col("State")} = {P}State", new { State = (int)DomainEventOutboxState.Pending }); } + public Task CountByStateForDepartmentAsync(int departmentId, int state) + { + return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("DomainEventOutbox")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {P}State", new { DepartmentId = departmentId, State = state }); + } + + public Task GetOldestPendingCreatedOnForDepartmentAsync(int departmentId) + { + return ScalarAsync($"SELECT MIN({Col("CreatedOn")}) FROM {Tbl("DomainEventOutbox")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {P}State", new { DepartmentId = departmentId, State = (int)DomainEventOutboxState.Pending }); + } + public Task PurgeDispatchedOlderThanAsync(DateTime cutoffUtc, CancellationToken cancellationToken = default) { return ExecuteAsync( @@ -821,6 +836,16 @@ public override async Task InsertAsync(RmsAccessAudit entity, Ca public override Task UpdateAsync(RmsAccessAudit entity, CancellationToken cancellationToken, bool firstLevelOnly = false) => throw new InvalidOperationException("RMS access audits are append-only; retention uses its explicit erasure inventory."); + public Task CountByActionSinceAsync(int departmentId, int action, DateTime sinceUtc) + { + return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsAccessAudits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("Action")} = {P}Action AND {Col("OccurredOn")} >= {P}Since", new { DepartmentId = departmentId, Action = action, Since = sinceUtc }); + } + + public Task> GetForAggregateAsync(int departmentId, string aggregateId, int take) + { + return QueryAsync($"SELECT * FROM {Tbl("RmsAccessAudits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id ORDER BY {Col("OccurredOn")} DESC, {Col("RmsAccessAuditId")} DESC {Paging()}", new { DepartmentId = departmentId, Id = aggregateId, Skip = 0, Take = Math.Clamp(take, 1, 1000) }); + } + public Task> GetForRecordAsync(int departmentId, string recordId, int take) { return QueryAsync( diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index bf67a8af2..c993d667f 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -75,5 +75,8 @@ "RecordLegalHoldReleased": 157, "RecordEvidenceCaptured": 158, "RecordPurged": 159, - "RecordExportScheduled": 160 + "RecordExportScheduled": 160, + "RecordInspectionCompleted": 161, + "RecordViolationOverdue": 162, + "RecordPermitExpiring": 163 } diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs index e570cc1b0..09b16be74 100644 --- a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs +++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs @@ -174,6 +174,24 @@ private static Dictionary Load(string path) "Records|pl|SearchOnline", "Records|pl|SearchOffline", // Polish uses them verbatim. "Records|sv|SearchOnline", "Records|sv|SearchOffline", // So does Swedish. + // RMS-4/RMS-5 prevention, investigation, quality and health pages (2026-09-07): same word in the target language. + "Records|de|PitotPressure", "Records|es|PitotPressure", "Records|fr|PitotPressure", "Records|it|PitotPressure", + "Records|pl|PitotPressure", "Records|sv|PitotPressure", "Records|el|PitotPressure", // "Pitot (psi)" is a proper name plus a unit. + "Records|de|ScanState", // "Scan" + "Records|de|Trends", // "Trends" + "Records|es|Inspector", // "Inspector" + "Records|es|ResidualPressure", // "Residual (psi)" + "Records|fr|Conditions", // "Conditions" + "Records|fr|CriterionScore", // "Score (0-4)" + "Records|fr|InspectionNotes", // "Notes" + "Records|fr|Inspections", // "Inspections" + "Records|fr|ItemNote", // "Note" + "Records|fr|OccupancyStructure", // "Structure" + "Records|fr|PermitCode", // "Code" + "Records|fr|ReviewCycle", // "Cycle" + "Records|fr|SectionNumber", // "Section" + "Records|it|FileName", // "File" + // Counted phrases whose wording matches English. "CommunicationTest|es|ScopeRoles", // {0} roles "CommunicationTest|sv|ScopePerson", // 1 person diff --git a/Tests/Resgrid.Tests/Rms/BackOfficeExtensionTests.cs b/Tests/Resgrid.Tests/Rms/BackOfficeExtensionTests.cs index efd7bcb59..f5a1ca9df 100644 --- a/Tests/Resgrid.Tests/Rms/BackOfficeExtensionTests.cs +++ b/Tests/Resgrid.Tests/Rms/BackOfficeExtensionTests.cs @@ -108,6 +108,122 @@ public async Task E2_incident_scoped_numbering_without_the_call_subject_warns_bu validation.Issues.Should().Contain(i => i.Code == "no_call_subject" && i.Severity == "warning"); } + // ---- Per-definition cardinality (plan 5.2.1) ------------------------------------------------------------ + + private void RegisterCall(int callId) => _h.Calls.Setup(c => c.GetCallByIdAsync(callId, It.IsAny())) + .ReturnsAsync(new Call { CallId = callId, DepartmentId = Dept, Number = "C-" + callId, Name = "Incident " + callId, Type = "Fire", LoggedOn = new DateTime(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc) }); + + [Test] + public async Task SingleAuthoritative_allows_one_record_per_call_and_hands_back_the_one_that_exists() + { + RegisterCall(5100); + RegisterCall(5200); + await _h.CreateAndPublishAsync("ics-209", "Status summary", Simple(), d => + { + d.PermittedSubjectTypes = "call,incidentcommand"; + d.Cardinality = RmsRecordCardinality.SingleAuthoritative; + }); + + var first = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-209", CallId = 5100 }); + first.Record.CardinalityKey.Should().Be("single:5100:ics-209"); + + Func second = () => _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-209", CallId = 5100 }); + var thrown = await second.Should().ThrowAsync(); + thrown.Which.ExistingRecordId.Should().Be(first.Record.RmsOperationalRecordId, "the author is pointed at the Record that exists, not shown an error"); + thrown.Which.Cardinality.Should().Be(RmsRecordCardinality.SingleAuthoritative); + + var otherCall = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-209", CallId = 5200 }); + otherCall.Record.CardinalityKey.Should().Be("single:5200:ics-209", "the rule is per Call, not per department"); + + var noCall = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-209" }); + noCall.Record.CardinalityKey.Should().BeNull("every rule is keyed on the Call; without one there is nothing to enforce"); + } + + [Test] + public async Task A_cancelled_or_voided_record_releases_its_slot() + { + RegisterCall(5300); + await _h.CreateAndPublishAsync("ics-209", "Status summary", Simple(), d => + { + d.PermittedSubjectTypes = "call"; + d.Cardinality = RmsRecordCardinality.SingleAuthoritative; + }); + + var first = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-209", CallId = 5300 }); + var cancelled = await _h.Records.CancelAsync(Dept, Author, first.Record.RmsOperationalRecordId); + cancelled.Record.CardinalityKey.Should().BeNull(); + + var replacement = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-209", CallId = 5300 }); + replacement.Record.CardinalityKey.Should().Be("single:5300:ics-209", "an abandoned Record must not block the one that replaces it"); + } + + [Test] + public async Task OnePerSubjectPerCall_keys_on_the_unit_and_falls_back_to_the_author() + { + RegisterCall(5400); + await _h.CreateAndPublishAsync("unit-report", "Unit report", Simple(), d => + { + d.PermittedSubjectTypes = "call,unit"; + d.Cardinality = RmsRecordCardinality.OnePerSubjectPerCall; + }); + + var engine1 = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput + { + DefinitionKey = "unit-report", CallId = 5400, Details = new RmsOperationalRecordDetail { UnitId = 11 } + }); + var engine2 = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput + { + DefinitionKey = "unit-report", CallId = 5400, Details = new RmsOperationalRecordDetail { UnitId = 12 } + }); + + engine1.Record.CardinalityKey.Should().Be("subject:5400:unit-report:unit:11"); + engine2.Record.CardinalityKey.Should().Be("subject:5400:unit-report:unit:12", "two engine companies on one fire produce two company-level records"); + + Func duplicate = () => _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput + { + DefinitionKey = "unit-report", CallId = 5400, Details = new RmsOperationalRecordDetail { UnitId = 11 } + }); + (await duplicate.Should().ThrowAsync()).Which.ExistingRecordId.Should().Be(engine1.Record.RmsOperationalRecordId); + + // With no unit named, the subject is the author — which is what a per-person record is keyed on. + var self = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "unit-report", CallId = 5400 }); + self.Record.CardinalityKey.Should().Be("subject:5400:unit-report:person:" + Author); + } + + [Test] + public async Task MultiplePerCall_is_the_default_and_constrains_nothing() + { + RegisterCall(5500); + await _h.CreateAndPublishAsync("ics-214", "Activity log", Simple(), d => d.PermittedSubjectTypes = "call"); + _h.Version("ics-214", 1).Cardinality.Should().Be((int)RmsRecordCardinality.MultiplePerCall, "a definition that says nothing allows multiples"); + + for (var i = 0; i < 3; i++) + (await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-214", CallId = 5500 })) + .Record.CardinalityKey.Should().BeNull(); + } + + [Test] + public async Task A_cardinality_rule_without_the_call_subject_is_refused_and_a_tightening_change_is_breaking() + { + var validation = await _h.Definitions.ValidateAsync(Dept, new RecordDefinitionDraftInput + { + Name = "Single, no call subject", Schema = Simple(), PermittedSubjectTypes = "unit", + Cardinality = RmsRecordCardinality.SingleAuthoritative, Numbering = new RecordDefinitionNumbering { Prefix = "SNG" } + }); + validation.Issues.Should().Contain(i => i.Code == "no_call_subject" && i.Severity == "error", + "a rule keyed on the Call that can never see one is a rule that never fires"); + + await _h.CreateAndPublishAsync("tightening", "Tightening", Simple(), d => d.PermittedSubjectTypes = "call"); + var draft = await _h.Definitions.OpenDraftAsync(Dept, Admin, "tightening"); + var input = RecordDefinitionsService.ToDraftInput(draft, (await _h.Definitions.GetAsync(Dept, "tightening")).Definition); + input.Cardinality = RmsRecordCardinality.SingleAuthoritative; + await _h.Definitions.SaveDraftAsync(Dept, Admin, "tightening", draft.Version, draft.RowVersion, input); + + var diff = await _h.Definitions.DiffAsync(Dept, "tightening", 1, 2); + diff.Entries.Should().Contain(e => e.Key == "cardinality" && e.Breaking, + "Records legal under the old rule already exist, so tightening is a breaking change the publisher must see"); + } + // ---- E3: external identifier schemes -------------------------------------------------------------------- [Test] diff --git a/Tests/Resgrid.Tests/Rms/ClaimsAuthorizationHelperParityTests.cs b/Tests/Resgrid.Tests/Rms/ClaimsAuthorizationHelperParityTests.cs index 0a11a84dd..835af93ca 100644 --- a/Tests/Resgrid.Tests/Rms/ClaimsAuthorizationHelperParityTests.cs +++ b/Tests/Resgrid.Tests/Rms/ClaimsAuthorizationHelperParityTests.cs @@ -25,7 +25,8 @@ public class ClaimsAuthorizationHelperParityTests "CanViewRecords", "CanCreateRecord", "CanReviewRecords", "CanApproveRecords", "CanFinalizeRecords", "CanSubmitRecords", "CanAmendRecords", "CanVoidRecords", "CanExportRecords", "CanShareRecords", "CanReassignRecordDrafts", "CanViewLegacyRecords", "CanViewRestrictedRecords", "CanManageRecordDefinitions", - "CanPublishRecordDefinitions", "CanManageRecordReports", "CanManageRecordDisclosures", "CanManageRecordLegalHold" + "CanPublishRecordDefinitions", "CanManageRecordReports", "CanManageRecordDisclosures", "CanManageRecordLegalHold", + "CanAdministerRecordsPrevention" }; [Test] @@ -65,7 +66,7 @@ public void Both_startup_files_register_every_records_policy() .Where(f => f.Name.StartsWith("Record", StringComparison.Ordinal)) .Select(f => f.Name) .ToList(); - recordPolicies.Should().HaveCount(18); + recordPolicies.Should().HaveCount(19, "18 RMS-1 policies plus Record_PreventionAdmin (RMS-5)"); var webPolicies = RecordPolicies(webStartup); var servicesPolicies = RecordPolicies(servicesStartup); diff --git a/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs b/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs index 7ae89c94a..781a47c4a 100644 --- a/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs +++ b/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs @@ -162,6 +162,8 @@ public FakeRmsDefinitionStore(FakeRmsStore records = null) .ReturnsAsync((int d, string id) => Fills.Where(x => x.DepartmentId == d && x.RmsExternalOrderId == id).ToList()); FillsRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((int d, string id) => Fills.FirstOrDefault(x => x.DepartmentId == d && x.RmsExternalOrderFillId == id)); + FillsRepo.Setup(r => r.TryBumpRowVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long expected, CancellationToken c) => Bump(Fills.FirstOrDefault(x => x.DepartmentId == d && x.RmsExternalOrderFillId == id), expected, (x, v) => x.RowVersion = v, x => x.RowVersion)); ReferencesRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RmsExternalReference e, CancellationToken c, bool f) => { References.Add(e); return e; }); @@ -178,6 +180,8 @@ public FakeRmsDefinitionStore(FakeRmsStore records = null) .ReturnsAsync((int d) => Connectors.Where(x => x.DepartmentId == d).Select(Clone).ToList()); ConnectorsRepo.Setup(r => r.GetDueAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((DateTime now, int take) => Connectors.Where(x => x.IsReadyToRun && (!x.LastPolledOn.HasValue || x.LastPolledOn.Value.AddMinutes(x.PollIntervalMinutes) <= now)).Take(take).Select(Clone).ToList()); + ConnectorsRepo.Setup(r => r.TryBumpRowVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long expected, CancellationToken c) => Bump(Connectors.FirstOrDefault(x => x.DepartmentId == d && x.RmsExternalOrderConnectorId == id), expected, (x, v) => x.RowVersion = v, x => x.RowVersion)); ConnectorRunsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RmsExternalOrderConnectorRun e, CancellationToken c, bool f) => { ConnectorRuns.Add(e); return e; }); ConnectorRunsRepo.Setup(r => r.GetForConnectorAsync(It.IsAny(), It.IsAny(), It.IsAny())) diff --git a/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs b/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs index 45327e5c8..ae4490df4 100644 --- a/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs +++ b/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs @@ -306,6 +306,22 @@ public async Task Sync_is_bounded_tombstones_what_the_caller_cannot_read_and_res gated.Changes.Should().BeEmpty(); } + [Test] + public async Task A_transient_catalog_failure_is_a_retry_rather_than_a_reset_for_every_device() + { + Publish("shift-log", "Shift log"); + _definitions.Setup(d => d.ListAsync(Dept, It.IsAny())).ThrowsAsync(new TimeoutException("the database was busy")); + + var catalog = await _service.GetCatalogAsync(Dept, Me, new FieldRecordCatalogRequest { Origin = RmsOriginClient.Responder, AppVersion = "5.2.0" }); + catalog.Ok.Should().BeFalse(); + catalog.Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.CatalogUnavailable); + + var bundle = await _service.SyncAsync(Dept, Me, new FieldRecordSyncRequest { Origin = RmsOriginClient.Responder, AppVersion = "5.2.0", Since = 1_700_000_000_000, ScopeStamp = "scope-1" }); + bundle.Ok.Should().BeFalse(); + bundle.Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.CatalogUnavailable); + bundle.ResetRequired.Should().BeFalse("a database that hiccuped must not send every syncing device back for a full download at once"); + } + [Test] public void Version_comparison_is_numeric_and_tolerates_prereleases() { diff --git a/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs b/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs index e6740ca07..8198713a8 100644 --- a/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs +++ b/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs @@ -57,6 +57,34 @@ public sealed class PassthroughRecordsProtection : IRecordsProtectionService public Task RevealDisclosureProductionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); public Task RevealLegalHoldsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); public Task RevealSubmissionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + + // RMS-5 / catalog v13 rows + public Task ProtectOccupancyAsync(int departmentId, RmsOccupancy row, RmsOccupancy existing, string userId = null, CancellationToken cancellationToken = default) => Write("occupancy"); + public Task RevealOccupanciesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectOccupancyHazardAsync(int departmentId, RmsOccupancyHazard row, RmsOccupancyHazard existing, string userId = null, CancellationToken cancellationToken = default) => Write("occupancy hazard"); + public Task RevealOccupancyHazardsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectInspectionAsync(int departmentId, RmsInspection row, RmsInspection existing, string userId = null, CancellationToken cancellationToken = default) => Write("inspection"); + public Task RevealInspectionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectViolationAsync(int departmentId, RmsViolation row, RmsViolation existing, string userId = null, CancellationToken cancellationToken = default) => Write("violation"); + public Task RevealViolationsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectPermitAsync(int departmentId, RmsPermit row, RmsPermit existing, string userId = null, CancellationToken cancellationToken = default) => Write("permit"); + public Task RevealPermitsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectPlanReviewAsync(int departmentId, RmsPlanReview row, RmsPlanReview existing, string userId = null, CancellationToken cancellationToken = default) => Write("plan review"); + public Task RevealPlanReviewsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectInvestigationCaseAsync(int departmentId, RmsInvestigationCase row, RmsInvestigationCase existing, string userId = null, CancellationToken cancellationToken = default) => Write("investigation case"); + public Task RevealInvestigationCasesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectInvestigationNoteAsync(int departmentId, RmsInvestigationNote row, RmsInvestigationNote existing, string userId = null, CancellationToken cancellationToken = default) => Write("investigation note"); + public Task RevealInvestigationNotesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectInvestigationEvidenceAsync(int departmentId, RmsInvestigationEvidence row, RmsInvestigationEvidence existing, string userId = null, CancellationToken cancellationToken = default) => Write("investigation evidence"); + public Task RevealInvestigationEvidenceAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectInvestigationCustodyAsync(int departmentId, RmsInvestigationCustody row, string userId = null, CancellationToken cancellationToken = default) => Write("custody transfer"); + public Task RevealInvestigationCustodyAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectInvestigationReferralAsync(int departmentId, RmsInvestigationReferral row, RmsInvestigationReferral existing, string userId = null, CancellationToken cancellationToken = default) => Write("referral"); + public Task RevealInvestigationReferralsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectQualityReviewAsync(int departmentId, RmsQualityReview row, RmsQualityReview existing, string userId = null, CancellationToken cancellationToken = default) => Write("quality review"); + public Task RevealQualityReviewsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task ProtectPreventionAttachmentAsync(int departmentId, RmsPreventionAttachment row, RmsPreventionAttachment existing, string userId = null, CancellationToken cancellationToken = default) => Write("prevention attachment"); + public Task RevealPreventionAttachmentsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default) => Empty(); public Task RevealExportRunsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default) => Empty(); public Task ResolveSubmissionForWorkloadAsync(int departmentId, RmsSubmission submission, string purpose, CancellationToken cancellationToken = default) => Task.FromResult(AllowWorkload); public Task RevealForWorkloadAsync(int departmentId, RecordAggregate aggregate, string purpose, CancellationToken cancellationToken = default) => Empty(); diff --git a/Tests/Resgrid.Tests/Rms/RecordClaimsTests.cs b/Tests/Resgrid.Tests/Rms/RecordClaimsTests.cs index 56bde1431..8c376e088 100644 --- a/Tests/Resgrid.Tests/Rms/RecordClaimsTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordClaimsTests.cs @@ -192,7 +192,8 @@ public void Claims_are_not_duplicated_when_derived_twice() public void Catalog_covers_exactly_values_50_to_67_once_each() { var values = RecordPermissionCatalog.All.Select(d => (int)d.Type).OrderBy(v => v).ToList(); - values.Should().Equal(Enumerable.Range(RecordPermissionCatalog.FirstValue, 18)); + // 50-67 are RMS-1; 69 is RMS-5's PreventionAdmin (68 belongs to Unified Search and is not a Records permission). + values.Should().Equal(Enumerable.Range(RecordPermissionCatalog.FirstValue, 18).Concat(new[] { (int)PermissionTypes.RecordsPreventionAdmin })); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RecordDeploymentConnectorsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordDeploymentConnectorsServiceTests.cs index 6043a8098..3721affaa 100644 --- a/Tests/Resgrid.Tests/Rms/RecordDeploymentConnectorsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordDeploymentConnectorsServiceTests.cs @@ -1,8 +1,10 @@ using System; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Moq; using NUnit.Framework; using Resgrid.Config; using Resgrid.Model; @@ -326,6 +328,26 @@ public async Task The_cursor_is_followed_page_by_page_and_remembered_for_the_nex result.Run.RequestCount.Should().Be(2); result.Run.OrdersCreated.Should().Be(2); _h.Feed.CursorsSeen.Should().Equal(null, "p2"); _h.Defs.Connectors.Single().LastCursor.Should().BeNull("the last page ended the cursor"); + _h.Defs.OrdersRepo.Verify(r => r.GetForDepartmentAsync(Dept, true), Times.Once, + "the department's orders are matched against once per run, not re-read for every page"); + } + + [Test] + public async Task A_run_that_cannot_claim_the_connector_is_skipped_rather_than_run_twice() + { + var connector = await ReadyAsync(); + _h.Feed.Serve(Feed("v1", Order("O-1001", "open", Request("O-1")))); + // Another runner holds the connector: the claim is what stops two runs from spending the same hourly + // budget, importing the same pages and overwriting each other's cursor. + _h.Defs.ConnectorsRepo.Setup(r => r.TryBumpRowVersionAsync(Dept, connector.RmsExternalOrderConnectorId, It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var result = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + + result.Run.Outcome.Should().Be(RmsConnectorRunOutcomes.Rejected); + _h.Feed.Fetches.Should().Be(0); + _h.Defs.Orders.Should().BeEmpty(); + _h.Defs.ConnectorRuns.Should().ContainSingle("a skipped run is still recorded"); + _h.Defs.Connectors.Single().LastPolledOn.Should().BeNull("the connector row belongs to whoever holds the claim"); } #endregion diff --git a/Tests/Resgrid.Tests/Rms/RecordsCutoverServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsCutoverServiceTests.cs index 992bb5de9..9ea93872d 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsCutoverServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsCutoverServiceTests.cs @@ -136,7 +136,7 @@ public async Task Preview_reports_legacy_counts_permission_table_and_blocks_when preview.PermissionMapping.Single(r => r.Target == PermissionTypes.FinalizeRecords).Source.Should().Be(PermissionTypes.CreateLog); preview.PermissionMapping.Single(r => r.Target == PermissionTypes.DeleteRecord).EffectiveAction.Should().Be(PermissionActions.Everyone, "no DeleteLog row: everyone, matching AddDeleteLogClaims"); preview.PermissionMapping.Single(r => r.Target == PermissionTypes.ReviewRecords).EffectiveAction.Should().Be(PermissionActions.DepartmentAndGroupAdmins); - preview.PermissionMapping.Select(r => r.Target).Distinct().Should().HaveCount(18); + preview.PermissionMapping.Select(r => r.Target).Distinct().Should().HaveCount(19, "18 RMS-1 permissions plus RMS-5's PreventionAdmin"); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RecordsFieldRolloutServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsFieldRolloutServiceTests.cs index a8be8e48f..cf570e723 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsFieldRolloutServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsFieldRolloutServiceTests.cs @@ -42,8 +42,8 @@ public void SetUp() _events = new Mock(); _events.Setup(e => e.InsertBatchAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync((IEnumerable rows, CancellationToken c) => { _stored.AddRange(rows); return _stored.Count; }); - _events.Setup(e => e.GetForWindowAsync(Dept, It.IsAny(), It.IsAny())) - .ReturnsAsync((int d, DateTime since, int take) => _stored.Where(row => row.OccurredOn >= since).ToList()); + _events.Setup(e => e.GetForWindowAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, DateTime since, int take, CancellationToken c) => _stored.Where(row => row.OccurredOn >= since).ToList()); _authorization = new Mock(); _authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync(true); @@ -168,6 +168,24 @@ public void Summarize_counts_outcomes_versions_and_the_median_time_to_complete() summary.Versions.Should().Contain(version => version.AppVersion == "5.4.0" && version.Users == 1); } + [Test] + public void Versions_are_listed_newest_first_by_version_rather_than_by_text() + { + var now = DateTime.UtcNow; + var events = new List + { + Event("a", "5.2.0", RmsFieldRolloutEventTypes.Sync, "ok", now), + Event("b", "5.10.0", RmsFieldRolloutEventTypes.Sync, "ok", now), + Event("c", "5.9.1", RmsFieldRolloutEventTypes.Sync, "ok", now), + Event("d", "6.0.0", RmsFieldRolloutEventTypes.Sync, "ok", now) + }; + + var summary = RecordsFieldRolloutService.Summarize(RmsOriginClient.Responder, events, "5.2.0"); + + // 5.10 is a later version than 5.9 even though it sorts earlier as text. + summary.Versions.Select(v => v.AppVersion).Should().Equal("6.0.0", "5.10.0", "5.9.1", "5.2.0"); + } + [Test] public void An_app_that_reports_nothing_still_shows_no_numbers_rather_than_wrong_ones() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsHydrantsAndPermitsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsHydrantsAndPermitsServiceTests.cs new file mode 100644 index 000000000..fe8b10508 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsHydrantsAndPermitsServiceTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using static Resgrid.Tests.Rms.RmsPreventionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RMS-5 hydrants: NFPA 291 arithmetic, service state, CSV import, map layer and nearest lookup. + [TestFixture] + public class RecordsHydrantsServiceTests + { + private RmsPreventionHarness _h; + + [SetUp] + public void SetUp() => _h = new RmsPreventionHarness(); + + [TestCase(0.9, 2.5, 25, 840, RmsHydrantFlowClass.B)] + [TestCase(0.9, 2.5, 64, 1340, RmsHydrantFlowClass.A)] + [TestCase(0.8, 4.0, 40, 2410, RmsHydrantFlowClass.AA)] + [TestCase(0.7, 2.5, 9, 390, RmsHydrantFlowClass.C)] + public void Flow_follows_nfpa_291(double c, double d, int pitot, int expectedGpm, RmsHydrantFlowClass expectedClass) + { + var gpm = HydrantFlowCalculator.FlowGpm((decimal)c, (decimal)d, pitot); + gpm.Should().Be(expectedGpm); + HydrantFlowCalculator.Classify(gpm).Should().Be(expectedClass); + HydrantFlowCalculator.Classify(null).Should().Be(RmsHydrantFlowClass.Unknown); + HydrantFlowCalculator.FlowGpm(0, 2.5m, 25).Should().Be(0); + } + + [Test] + public async Task A_flow_test_updates_the_hydrant_and_maintenance_can_return_it_to_service() + { + var hydrant = await _h.HydrantsService.SaveAsync(Dept, Admin, new RmsHydrant { HydrantNumber = "H-101", Latitude = 45.5m, Longitude = -122.6m, Type = (int)RmsHydrantType.DryBarrel, MainSizeInches = 8 }); + hydrant.InService.Should().BeTrue(); + var test = await _h.HydrantsService.RecordFlowTestAsync(Dept, Admin, new RmsHydrantFlowTest { RmsHydrantId = hydrant.RmsHydrantId, StaticPressurePsi = 70, ResidualPressurePsi = 55, PitotPressurePsi = 64, OutletDiameterInches = 2.5m, Coefficient = 0.9m }); + test.FlowGpm.Should().Be(1340); + hydrant.FlowGpm.Should().Be(1340); + hydrant.FlowClass.Should().Be((int)RmsHydrantFlowClass.A); + hydrant.LastTestedOn.Should().NotBeNull(); + + Func noReason = () => _h.HydrantsService.SetServiceStateAsync(Dept, Admin, hydrant.RmsHydrantId, false, ""); + await noReason.Should().ThrowAsync(); + await _h.HydrantsService.SetServiceStateAsync(Dept, Admin, hydrant.RmsHydrantId, false, "Sheared by vehicle"); + (await _h.HydrantsService.GetMapLayerAsync(Dept, Member, null, null, null, null)).Should().ContainSingle(p => p.HydrantNumber == "H-101" && !p.InService && p.FlowClass == (int)RmsHydrantFlowClass.A); + await _h.HydrantsService.RecordMaintenanceAsync(Dept, Admin, new RmsHydrantMaintenance { RmsHydrantId = hydrant.RmsHydrantId, Kind = (int)RmsHydrantMaintenanceKind.Repair, ReturnedToService = true, Notes = "Replaced barrel" }); + hydrant.InService.Should().BeTrue(); + hydrant.OutOfServiceReason.Should().BeNull(); + (await _h.HydrantsService.GetAsync(Dept, Member, hydrant.RmsHydrantId)).Maintenance.Should().HaveCount(1); + (await _h.HydrantsService.CountTestDueAsync(Dept, DateTime.UtcNow.AddMonths(13))).Should().Be(1); + } + + [Test] + public async Task Csv_import_creates_updates_and_rejects_rows_and_the_nearest_lookup_orders_by_distance() + { + await _h.HydrantsService.SaveAsync(Dept, Admin, new RmsHydrant { HydrantNumber = "H-1", Latitude = 45.5m, Longitude = -122.6m }); + var result = await _h.HydrantsService.ImportCsvAsync(Dept, Admin, + "number,latitude,longitude,type,address,main_size,flow_gpm,owner\nH-1,45.5001,-122.6001,wet barrel,1 River Rd,6,1100,City\nH-2,45.6,-122.7,dry,,8,,\nH-3,bad,-122.7,dry,,,,\n,45.6,-122.7,dry,,,,\nH-4,45.51,-122.61,cistern,Tank Hill,,,Private Water Co\n"); + result.RowsRead.Should().Be(5); + result.Created.Should().Be(2); + result.Updated.Should().Be(1); + result.Rejected.Select(r => r.Error).Should().BeEquivalentTo(new[] { "Invalid coordinates", "Missing hydrant number" }); + var h1 = _h.Hydrants.Rows.Single(h => h.HydrantNumber == "H-1"); + h1.Type.Should().Be((int)RmsHydrantType.WetBarrel); + h1.FlowGpm.Should().Be(1100); + h1.FlowClass.Should().Be((int)RmsHydrantFlowClass.A); + h1.Source.Should().Be("manual", "an update keeps the row's original source"); + _h.Hydrants.Rows.Single(h => h.HydrantNumber == "H-2").Source.Should().Be("csv-import"); + _h.Hydrants.Rows.Single(h => h.HydrantNumber == "H-4").Type.Should().Be((int)RmsHydrantType.Cistern); + + var nearest = await _h.HydrantsService.GetNearestAsync(Dept, 45.5m, -122.6m, 2, 5000); + nearest.Select(h => h.HydrantNumber).Should().Equal("H-1", "H-4"); + (await _h.HydrantsService.GetMapLayerAsync(Dept, Member, 45.55m, 45.65m, -122.75m, -122.65m)).Should().ContainSingle(p => p.HydrantNumber == "H-2"); + } + } + + /// RMS-5 permits: application, plan review cycles, issue/expiry, fee reference and the expiry sweep (trigger 163). + [TestFixture] + public class RecordsPermitsServiceTests + { + private RmsPreventionHarness _h; + private RmsPermitType _hotWork; + private RmsPermitType _tent; + + [SetUp] + public async Task SetUp() + { + _h = new RmsPreventionHarness(); + _hotWork = await _h.PermitsService.SaveTypeAsync(Dept, Admin, new RmsPermitType { Name = "Hot work", Code = "HW", DefaultValidityDays = 30, RequiresPlanReview = false, FeeAmount = 75m, ConditionsTemplate = "Fire watch for 60 minutes after work ends.", IsActive = true }); + _tent = await _h.PermitsService.SaveTypeAsync(Dept, Admin, new RmsPermitType { Name = "Tent over 400 sq ft", Code = "TENT", DefaultValidityDays = 10, RequiresPlanReview = true, IsActive = true }); + } + + [Test] + public async Task A_permit_moves_through_review_issue_and_expiry_with_dates_from_its_type() + { + var occupancy = _h.SeedOccupancy(); + var permit = await _h.PermitsService.ApplyAsync(Dept, Admin, new RmsPermit { RmsPermitTypeId = _tent.RmsPermitTypeId, RmsOccupancyId = occupancy.RmsOccupancyId, ApplicantName = "Sam Applicant", ApplicantPhone = "555-0100", Description = "Festival tent" }); + permit.PermitNumber.Should().Be($"PRM-{DateTime.UtcNow.Year}-0001"); + permit.State.Should().Be((int)RmsPermitState.UnderReview, "the type requires plan review"); + permit.ApplicantName.Should().Be("Sam Applicant"); + _h.Protection.Writes.Should().Contain("permit"); + + var cycle1 = await _h.PermitsService.RecordPlanReviewAsync(Dept, Admin, permit.RmsPermitId, RmsPlanReviewOutcome.CorrectionsRequired, "Show exit spacing."); + cycle1.CycleNumber.Should().Be(1); + permit.State.Should().Be((int)RmsPermitState.UnderReview); + var cycle2 = await _h.PermitsService.RecordPlanReviewAsync(Dept, Admin, permit.RmsPermitId, RmsPlanReviewOutcome.Approved, "Exits acceptable."); + cycle2.CycleNumber.Should().Be(2); + permit.State.Should().Be((int)RmsPermitState.Approved); + + Func skip = () => _h.PermitsService.TransitionAsync(Dept, Admin, permit.RmsPermitId, RmsPermitState.Expired, null, null, null); + await skip.Should().ThrowAsync(); + await _h.PermitsService.TransitionAsync(Dept, Admin, permit.RmsPermitId, RmsPermitState.Issued, null, null, null); + permit.IssuedOn.Should().NotBeNull(); + permit.ExpiresOn.Should().BeCloseTo(permit.EffectiveOn.Value.AddDays(10), TimeSpan.FromSeconds(1)); + await _h.PermitsService.RecordFeePaidAsync(Dept, Admin, permit.RmsPermitId, 120m, "INV-2026-0009"); + permit.InvoiceReference.Should().Be("INV-2026-0009"); + + var aggregate = await _h.PermitsService.GetAsync(Dept, Member, permit.RmsPermitId); + aggregate.PlanReviews.Should().HaveCount(2); + aggregate.Type.Code.Should().Be("TENT"); + aggregate.Occupancy.RmsOccupancyId.Should().Be(occupancy.RmsOccupancyId); + } + + [Test] + public async Task Denial_and_revocation_need_a_reason_and_hot_work_skips_review() + { + var permit = await _h.PermitsService.ApplyAsync(Dept, Admin, new RmsPermit { RmsPermitTypeId = _hotWork.RmsPermitTypeId, ApplicantName = "Welder" }); + permit.State.Should().Be((int)RmsPermitState.Applied); + permit.Conditions.Should().Contain("Fire watch"); + permit.FeeAmount.Should().Be(75m); + Func deny = () => _h.PermitsService.TransitionAsync(Dept, Admin, permit.RmsPermitId, RmsPermitState.Denied, "", null, null); + await deny.Should().ThrowAsync(); + await _h.PermitsService.TransitionAsync(Dept, Admin, permit.RmsPermitId, RmsPermitState.Approved, null, null, null); + await _h.PermitsService.TransitionAsync(Dept, Admin, permit.RmsPermitId, RmsPermitState.Issued, null, DateTime.UtcNow, DateTime.UtcNow.AddDays(5)); + await _h.PermitsService.TransitionAsync(Dept, Admin, permit.RmsPermitId, RmsPermitState.Revoked, "Unsafe practice observed", null, null); + permit.DecisionReason.Should().Be("Unsafe practice observed"); + Func member = () => _h.PermitsService.ApplyAsync(Dept, Member, new RmsPermit { RmsPermitTypeId = _hotWork.RmsPermitTypeId }); + await member.Should().ThrowAsync(); + } + + [Test] + public async Task The_expiry_sweep_notifies_once_inside_the_window_and_expires_past_due_permits() + { + var now = DateTime.UtcNow; + var soon = await _h.PermitsService.ApplyAsync(Dept, Admin, new RmsPermit { RmsPermitTypeId = _hotWork.RmsPermitTypeId, ApplicantName = "A" }); + await _h.PermitsService.TransitionAsync(Dept, Admin, soon.RmsPermitId, RmsPermitState.Approved, null, null, null); + await _h.PermitsService.TransitionAsync(Dept, Admin, soon.RmsPermitId, RmsPermitState.Issued, null, now.AddDays(-20), now.AddDays(10)); + var past = await _h.PermitsService.ApplyAsync(Dept, Admin, new RmsPermit { RmsPermitTypeId = _hotWork.RmsPermitTypeId, ApplicantName = "B" }); + await _h.PermitsService.TransitionAsync(Dept, Admin, past.RmsPermitId, RmsPermitState.Approved, null, null, null); + await _h.PermitsService.TransitionAsync(Dept, Admin, past.RmsPermitId, RmsPermitState.Issued, null, now.AddDays(-40), now.AddDays(-1)); + var far = await _h.PermitsService.ApplyAsync(Dept, Admin, new RmsPermit { RmsPermitTypeId = _hotWork.RmsPermitTypeId, ApplicantName = "C" }); + await _h.PermitsService.TransitionAsync(Dept, Admin, far.RmsPermitId, RmsPermitState.Approved, null, null, null); + await _h.PermitsService.TransitionAsync(Dept, Admin, far.RmsPermitId, RmsPermitState.Issued, null, now, now.AddDays(200)); + + var (expired, notified) = await _h.PermitsService.SweepExpiryAsync(Dept, now, 30); + expired.Should().Be(1); + notified.Should().Be(1); + past.State.Should().Be((int)RmsPermitState.Expired); + soon.ExpiringEmittedOn.Should().NotBeNull(); + far.ExpiringEmittedOn.Should().BeNull(); + var evt = _h.Outbox.Enqueued.Should().ContainSingle().Which.Envelope; + evt.Trigger.Should().Be(WorkflowTriggerEventType.RecordPermitExpiring); + var payload = JObject.FromObject(evt.Payload); + payload["permit"]["type_code"].Value().Should().Be("HW"); + payload["permit"]["days_until_expiry"].Value().Should().Be(10); + payload.ToString().Should().NotContain("Applicant"); + + (await _h.PermitsService.SweepExpiryAsync(Dept, now.AddDays(1), 30)).Should().Be((0, 0), "no repeat notice, nothing else due"); + (await _h.PermitsService.CountAsync(Dept, Member, new RmsPermitQuery { States = new List { (int)RmsPermitState.Issued } })).Should().Be(2); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs new file mode 100644 index 000000000..7e5deb4f0 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs @@ -0,0 +1,177 @@ +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.Repositories; +using static Resgrid.Tests.Rms.RmsPreventionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RMS-5 inspection programs, code sets, inspections, violations, re-inspection and the daily sweep (RMS plan section 4.3). + [TestFixture] + public class RecordsInspectionsServiceTests + { + private RmsPreventionHarness _h; + private RmsOccupancy _occupancy; + private RmsCodeSet _codeSet; + private RmsCodeSection _exits; + private RmsCodeSection _extinguishers; + private RmsInspectionProgram _program; + + [SetUp] + public async Task SetUp() + { + _h = new RmsPreventionHarness(); + _occupancy = _h.SeedOccupancy(occupancyType: 3); + _codeSet = await _h.InspectionsService.SaveCodeSetAsync(Dept, Admin, new RmsCodeSet { Name = "IFC", Edition = "2021", IsActive = true }); + _exits = await _h.InspectionsService.SaveCodeSectionAsync(Dept, Admin, new RmsCodeSection { RmsCodeSetId = _codeSet.RmsCodeSetId, SectionNumber = "1031.2", Title = "Means of egress", DefaultSeverity = (int)RmsViolationSeverity.Critical, DefaultCorrectionDays = 1 }); + _extinguishers = await _h.InspectionsService.SaveCodeSectionAsync(Dept, Admin, new RmsCodeSection { RmsCodeSetId = _codeSet.RmsCodeSetId, SectionNumber = "906.1", Title = "Portable extinguishers", DefaultSeverity = (int)RmsViolationSeverity.Moderate, DefaultCorrectionDays = 30 }); + _program = await _h.InspectionsService.SaveProgramAsync(Dept, Admin, new RmsInspectionProgram { Name = "Annual life safety", FrequencyMonths = 12, OccupancyTypesCsv = "3,5", RmsCodeSetId = _codeSet.RmsCodeSetId, IsActive = true }, new List + { + new RmsInspectionChecklistItem { Key = "exits", Text = "Exits unobstructed", RmsCodeSectionId = _exits.RmsCodeSectionId, Required = true }, + new RmsInspectionChecklistItem { Key = "ext", Text = "Extinguishers tagged", RmsCodeSectionId = _extinguishers.RmsCodeSectionId, Required = true }, + new RmsInspectionChecklistItem { Key = "signs", Text = "Address visible from street", Required = false } + }); + } + + [Test] + public async Task Completing_an_inspection_opens_violations_from_failed_items_and_raises_trigger_161_without_the_notes() + { + var inspection = await _h.InspectionsService.ScheduleAsync(Dept, Admin, _occupancy.RmsOccupancyId, _program.RmsInspectionProgramId, DateTime.UtcNow, Admin); + inspection.InspectionNumber.Should().Be($"INSP-{DateTime.UtcNow.Year}-0001"); + await _h.InspectionsService.StartAsync(Dept, Admin, inspection.RmsInspectionId); + + var aggregate = await _h.InspectionsService.CompleteAsync(Dept, Admin, inspection.RmsInspectionId, new List + { + new RmsInspectionItemResult { Key = "exits", Passed = false, Note = "Rear exit chained shut" }, + new RmsInspectionItemResult { Key = "ext", Passed = true }, + new RmsInspectionItemResult { Key = "signs", Passed = false } + }, "Occupant cooperative; manager present.", "J. Manager"); + + aggregate.Inspection.Result.Should().Be((int)RmsInspectionResult.Fail); + aggregate.Inspection.State.Should().Be((int)RmsInspectionState.ReinspectionRequired); + aggregate.Inspection.Notes.Should().Be("Occupant cooperative; manager present.", "plaintext restored after the seal"); + aggregate.Violations.Should().HaveCount(2); + var exits = aggregate.Violations.Single(v => v.ChecklistItemKey == "exits"); + exits.Severity.Should().Be((int)RmsViolationSeverity.Critical); + exits.RmsCodeSectionId.Should().Be(_exits.RmsCodeSectionId); + exits.DueOn.Should().BeCloseTo(DateTime.UtcNow.AddDays(1), TimeSpan.FromMinutes(1)); + exits.Description.Should().Be("Rear exit chained shut"); + aggregate.Violations.Single(v => v.ChecklistItemKey == "signs").Severity.Should().Be((int)RmsViolationSeverity.Moderate, "no code section: the default severity applies"); + _occupancy.LastInspectedOn.Should().NotBeNull(); + + var evt = _h.Outbox.Enqueued.Should().ContainSingle().Which.Envelope; + evt.Trigger.Should().Be(WorkflowTriggerEventType.RecordInspectionCompleted); + var payload = JObject.FromObject(evt.Payload); + payload["inspection"]["violation_count"].Value().Should().Be(2); + payload["inspection"]["critical_violation_count"].Value().Should().Be(1); + payload["inspection"]["result"].Value().Should().Be("Fail"); + payload.ToString().Should().NotContain("Occupant cooperative").And.NotContain("J. Manager"); + payload["record"]["kind"].Value().Should().Be("Prevention"); + _h.Outbox.Dispatched.Should().HaveCount(1); + } + + [Test] + public async Task Required_items_must_be_inspected_and_a_clean_inspection_passes() + { + var inspection = await _h.InspectionsService.ScheduleAsync(Dept, Admin, _occupancy.RmsOccupancyId, _program.RmsInspectionProgramId, DateTime.UtcNow, null); + Func incomplete = () => _h.InspectionsService.CompleteAsync(Dept, Admin, inspection.RmsInspectionId, new List { new RmsInspectionItemResult { Key = "exits", Passed = true } }, null, null); + (await incomplete.Should().ThrowAsync()).WithMessage("*ext*"); + + var aggregate = await _h.InspectionsService.CompleteAsync(Dept, Admin, inspection.RmsInspectionId, new List { new RmsInspectionItemResult { Key = "exits", Passed = true }, new RmsInspectionItemResult { Key = "ext", Passed = true } }, null, null); + aggregate.Inspection.Result.Should().Be((int)RmsInspectionResult.Pass); + aggregate.Inspection.State.Should().Be((int)RmsInspectionState.Completed); + aggregate.Violations.Should().BeEmpty(); + await _h.InspectionsService.CloseAsync(Dept, Admin, inspection.RmsInspectionId); + inspection.State.Should().Be((int)RmsInspectionState.Closed); + } + + [Test] + public async Task Reinspection_follows_the_violations_and_closing_waits_for_verification() + { + var inspection = await _h.InspectionsService.ScheduleAsync(Dept, Admin, _occupancy.RmsOccupancyId, _program.RmsInspectionProgramId, DateTime.UtcNow, null); + await _h.InspectionsService.CompleteAsync(Dept, Admin, inspection.RmsInspectionId, new List { new RmsInspectionItemResult { Key = "exits", Passed = false }, new RmsInspectionItemResult { Key = "ext", Passed = true } }, null, null); + var violation = _h.Violations.Rows.Single(); + + Func close = () => _h.InspectionsService.CloseAsync(Dept, Admin, inspection.RmsInspectionId); + await close.Should().ThrowAsync(); + + var child = await _h.InspectionsService.ScheduleReinspectionAsync(Dept, Admin, inspection.RmsInspectionId, DateTime.UtcNow.AddDays(1)); + child.ParentInspectionId.Should().Be(inspection.RmsInspectionId); + violation.ReinspectionId.Should().Be(child.RmsInspectionId); + + await _h.InspectionsService.TransitionViolationAsync(Dept, Admin, violation.RmsViolationId, RmsViolationState.Corrected, null); + Func waivedWithoutReason = () => _h.InspectionsService.TransitionViolationAsync(Dept, Admin, violation.RmsViolationId, RmsViolationState.Waived, null); + await waivedWithoutReason.Should().ThrowAsync("Corrected cannot go to Waived"); + await _h.InspectionsService.TransitionViolationAsync(Dept, Admin, violation.RmsViolationId, RmsViolationState.Verified, null); + violation.VerifiedByUserId.Should().Be(Admin); + await _h.InspectionsService.CloseAsync(Dept, Admin, inspection.RmsInspectionId); + await _h.InspectionsService.IssueNoticeAsync(Dept, Admin, inspection.RmsInspectionId, "NOV-77"); + inspection.NoticeReference.Should().Be("NOV-77"); + } + + [Test] + public async Task The_sweep_generates_due_inspections_once_and_emits_overdue_violations_exactly_once() + { + var other = _h.SeedOccupancy("Office", "9 Main St", occupancyType: 1); + var now = DateTime.UtcNow; + (await _h.InspectionsService.GenerateDueInspectionsAsync(Dept, now)).Should().Be(1, "only the type-3 occupancy matches the program; it was never inspected"); + (await _h.InspectionsService.GenerateDueInspectionsAsync(Dept, now)).Should().Be(0, "an open inspection blocks a second"); + var generated = _h.Inspections.Rows.Single(); + generated.RmsOccupancyId.Should().Be(_occupancy.RmsOccupancyId); + generated.InspectionNumber.Should().StartWith("INSP-"); + + await _h.InspectionsService.CompleteAsync(Dept, Admin, generated.RmsInspectionId, new List { new RmsInspectionItemResult { Key = "exits", Passed = false }, new RmsInspectionItemResult { Key = "ext", Passed = true } }, null, null); + (await _h.InspectionsService.GenerateDueInspectionsAsync(Dept, now)).Should().Be(0, "ReinspectionRequired still counts as open"); + (await _h.InspectionsService.GenerateDueInspectionsAsync(Dept, now.AddMonths(13))).Should().Be(0, "a re-inspection is the path, not a new cycle, while it stays open"); + + var violation = _h.Violations.Rows.Single(); + (await _h.InspectionsService.EmitOverdueViolationsAsync(Dept, now)).Should().Be(0, "due tomorrow"); + (await _h.InspectionsService.EmitOverdueViolationsAsync(Dept, now.AddDays(3))).Should().Be(1); + (await _h.InspectionsService.EmitOverdueViolationsAsync(Dept, now.AddDays(4))).Should().Be(0, "emitted once"); + violation.OverdueEmittedOn.Should().NotBeNull(); + var evt = _h.Outbox.Enqueued.Last().Envelope; + evt.Trigger.Should().Be(WorkflowTriggerEventType.RecordViolationOverdue); + var payload = JObject.FromObject(evt.Payload); + payload["violation"]["code_section_number"].Value().Should().Be("1031.2"); + payload["violation"]["days_overdue"].Value().Should().BeGreaterThanOrEqualTo(1); + payload["violation"]["severity"].Value().Should().Be("Critical"); + payload.ToString().Should().NotContain("Description"); + + _h.DisabledFlags.Add(FeatureFlagKeys.RecordsPreventionInspections); + (await _h.InspectionsService.GenerateDueInspectionsAsync(Dept, now.AddYears(2))).Should().Be(0, "a disabled module never sweeps"); + } + + [Test] + public async Task Code_sections_import_from_csv_and_skip_duplicates_and_headers() + { + var created = await _h.InspectionsService.ImportCodeSectionsAsync(Dept, Admin, _codeSet.RmsCodeSetId, + "section,title,text,severity,days\n1031.2,Means of egress,\"Exits shall be, at all times, unobstructed\",4,1\n315.3,Storage,,2,14\n\n907.8,\"Alarm testing\",Annual,3,30\n"); + created.Should().Be(2, "1031.2 already exists"); + var sections = await _h.InspectionsService.GetCodeSectionsAsync(Dept, Member, _codeSet.RmsCodeSetId); + sections.Select(s => s.SectionNumber).Should().BeEquivalentTo(new[] { "1031.2", "906.1", "315.3", "907.8" }); + sections.Single(s => s.SectionNumber == "907.8").Title.Should().Be("Alarm testing"); + sections.Single(s => s.SectionNumber == "315.3").DefaultCorrectionDays.Should().Be(14); + RecordsInspectionsService_CsvSplitPin(); + } + + private static void RecordsInspectionsService_CsvSplitPin() + { + Resgrid.Services.Records.RecordsInspectionsService.CsvSplit("a,\"b,c\",\"d\"\"e\"").Should().Equal("a", "b,c", "d\"e"); + } + + [Test] + public async Task Only_prevention_administrators_change_inspection_data() + { + Func schedule = () => _h.InspectionsService.ScheduleAsync(Dept, Member, _occupancy.RmsOccupancyId, _program.RmsInspectionProgramId, DateTime.UtcNow, null); + await schedule.Should().ThrowAsync(); + (await _h.InspectionsService.GetProgramsAsync(Dept, Member, false)).Should().ContainSingle(p => p.Name == "Annual life safety"); + (await _h.InspectionsService.ListAsync(Dept, Member, new RmsInspectionQuery())).Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs new file mode 100644 index 000000000..57166bdd7 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using static Resgrid.Tests.Rms.RmsPreventionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RMS-5 investigations (RMS plan section 4.4): case-level authorization on top of RecordRestricted_View, audited reads, append-only custody, separated findings approval, closure locks. + [TestFixture] + public class RecordsInvestigationsServiceTests + { + private RmsPreventionHarness _h; + private const string Investigator = "inv-1"; + private const string Reviewer = "rev-1"; + + [SetUp] + public void SetUp() + { + _h = new RmsPreventionHarness(); + _h.RestrictedViewers.Add(Investigator); + _h.RestrictedViewers.Add(Reviewer); + } + + [Test] + public async Task Opening_a_case_makes_the_opener_lead_and_department_admins_are_not_members_by_default() + { + var investigation = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Warehouse fire", null, 42, "Fire in loading dock, origin unknown."); + investigation.CaseNumber.Should().Be($"INV-{DateTime.UtcNow.Year}-0001"); + investigation.IncidentSummary.Should().Be("Fire in loading dock, origin unknown."); + _h.Protection.Writes.Should().Contain("investigation case"); + (await _h.InvestigationsService.ListMyCasesAsync(Dept, Admin, false)).Should().ContainSingle(); + + Func otherAdmin = () => _h.InvestigationsService.GetAsync(Dept, Admin2, investigation.RmsInvestigationCaseId); + await otherAdmin.Should().ThrowAsync("department administrator status is not a bypass"); + _h.Audits.Rows.Should().Contain(a => a.Action == (int)RmsAccessAuditAction.Denied && a.CorrelationId == investigation.RmsInvestigationCaseId && a.ActorUserId == Admin2 && !a.Successful); + (await _h.InvestigationsService.ListMyCasesAsync(Dept, Admin2, true)).Should().BeEmpty(); + + Func noRestricted = () => _h.InvestigationsService.OpenAsync(Dept, Member, "x", null, null, null); + await noRestricted.Should().ThrowAsync(); + + var aggregate = await _h.InvestigationsService.GetAsync(Dept, Admin, investigation.RmsInvestigationCaseId, "10.0.0.5"); + aggregate.CallerRole.Should().Be(RmsInvestigationRole.Lead); + _h.Audits.Rows.Should().Contain(a => a.Action == (int)RmsAccessAuditAction.Read && a.Purpose == RecordsInvestigationsService.AuditPurpose && a.CorrelationId == investigation.RmsInvestigationCaseId && a.IpAddress == "10.0.0.5" && a.RecordId == null); + } + + [Test] + public async Task Members_notes_evidence_and_custody_follow_the_case_rules() + { + var investigation = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Warehouse fire", null, null, null); + var caseId = investigation.RmsInvestigationCaseId; + await _h.InvestigationsService.AddMemberAsync(Dept, Admin, caseId, Investigator, RmsInvestigationRole.Investigator); + await _h.InvestigationsService.AddMemberAsync(Dept, Admin, caseId, Reviewer, RmsInvestigationRole.Reviewer); + Func outsider = () => _h.InvestigationsService.AddMemberAsync(Dept, Admin, caseId, Outsider, RmsInvestigationRole.ReadOnly); + await outsider.Should().ThrowAsync(); + Func nonLead = () => _h.InvestigationsService.AddMemberAsync(Dept, Investigator, caseId, Member, RmsInvestigationRole.ReadOnly); + await nonLead.Should().ThrowAsync(); + + var note = await _h.InvestigationsService.AddNoteAsync(Dept, Investigator, caseId, RmsInvestigationNoteKind.Interview, DateTime.UtcNow, "Night watchman", "Saw smoke at 02:10."); + note.Body.Should().Be("Saw smoke at 02:10."); + Func reviewerNote = () => _h.InvestigationsService.AddNoteAsync(Dept, Reviewer, caseId, RmsInvestigationNoteKind.SceneNote, DateTime.UtcNow, null, "x"); + await reviewerNote.Should().ThrowAsync(); + investigation.State.Should().Be((int)RmsInvestigationCaseState.Active, "the first work item activates the case"); + + var evidence = await _h.InvestigationsService.AddEvidenceAsync(Dept, Investigator, caseId, new RmsInvestigationEvidence { Kind = (int)RmsInvestigationEvidenceKind.Physical, Description = "Extension cord, melted", CollectedFrom = "Dock bay 2" }); + evidence.EvidenceNumber.Should().Be($"EV-{DateTime.UtcNow.Year}-0001"); + evidence.CurrentCustodianUserId.Should().Be(Investigator); + var chain = await _h.InvestigationsService.GetCustodyChainAsync(Dept, Reviewer, evidence.RmsInvestigationEvidenceId); + chain.Should().ContainSingle(c => c.Sequence == 1 && c.Reason == "Collected"); + + var transfer = await _h.InvestigationsService.TransferCustodyAsync(Dept, Investigator, evidence.RmsInvestigationEvidenceId, null, "State fire marshal lab", "Arc mapping analysis", RmsEvidenceState.Transferred); + transfer.Sequence.Should().Be(2); + transfer.FromUserId.Should().Be(Investigator); + transfer.ToExternal.Should().Be("State fire marshal lab"); + evidence.State.Should().Be((int)RmsEvidenceState.Transferred); + evidence.CurrentCustodianExternal.Should().Be("State fire marshal lab"); + (await _h.InvestigationsService.GetCustodyChainAsync(Dept, Admin, evidence.RmsInvestigationEvidenceId)).Should().HaveCount(2); + Func noReason = () => _h.InvestigationsService.TransferCustodyAsync(Dept, Investigator, evidence.RmsInvestigationEvidenceId, Admin, null, "", RmsEvidenceState.InStorage); + await noReason.Should().ThrowAsync(); + + var referral = await _h.InvestigationsService.AddReferralAsync(Dept, Admin, caseId, "County Sheriff", "Possible incendiary", "SO-26-118"); + await _h.InvestigationsService.UpdateReferralStateAsync(Dept, Investigator, referral.RmsInvestigationReferralId, RmsReferralState.Acknowledged); + referral.State.Should().Be((int)RmsReferralState.Acknowledged); + + var aggregate = await _h.InvestigationsService.GetAsync(Dept, Reviewer, caseId); + aggregate.CallerRole.Should().Be(RmsInvestigationRole.Reviewer); + aggregate.Notes.Should().HaveCount(1); aggregate.Evidence.Should().HaveCount(1); aggregate.Referrals.Should().HaveCount(1); aggregate.Members.Should().HaveCount(3); + } + + [Test] + public async Task Findings_need_a_second_person_to_approve_and_closure_locks_the_notes() + { + var investigation = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Warehouse fire", null, null, null); + var caseId = investigation.RmsInvestigationCaseId; + await _h.InvestigationsService.AddMemberAsync(Dept, Admin, caseId, Reviewer, RmsInvestigationRole.Reviewer); + var note = await _h.InvestigationsService.AddNoteAsync(Dept, Admin, caseId, RmsInvestigationNoteKind.SceneNote, DateTime.UtcNow, null, "V pattern on east wall."); + _h.Reports.Setup(r => r.GetByIdForDepartmentAsync(Dept, "rep-1")).ReturnsAsync(new RmsIncidentReport { RmsIncidentReportId = "rep-1", DepartmentId = Dept, CurrentRevisionId = "rev-3", RecordNumber = "INC-2026-0100" }); + var link = await _h.InvestigationsService.LinkIncidentAsync(Dept, Admin, caseId, "rep-1"); + link.PinnedRevisionId.Should().Be("rev-3"); + + Func closeEarly = () => _h.InvestigationsService.CloseAsync(Dept, Admin, caseId, "done"); + await closeEarly.Should().ThrowAsync("findings are not approved"); + + await _h.InvestigationsService.RecordFindingsAsync(Dept, Admin, caseId, RmsFireCauseClassification.Accidental, "Overloaded extension cord", "Dock bay 2, floor level", "Accidental electrical fire.", true); + investigation.State.Should().Be((int)RmsInvestigationCaseState.PendingReview); + investigation.RecommendsIncidentAmendment.Should().BeTrue(); + Func selfApprove = () => _h.InvestigationsService.ApproveFindingsAsync(Dept, Admin, caseId); + await selfApprove.Should().ThrowAsync("the author may not approve their own findings"); + await _h.InvestigationsService.ReturnFindingsAsync(Dept, Reviewer, caseId, "Cite the arc mapping."); + investigation.State.Should().Be((int)RmsInvestigationCaseState.Active); + await _h.InvestigationsService.RecordFindingsAsync(Dept, Admin, caseId, RmsFireCauseClassification.Accidental, "Overloaded extension cord (arc mapping confirms)", "Dock bay 2", "Accidental electrical fire.", true); + await _h.InvestigationsService.ApproveFindingsAsync(Dept, Reviewer, caseId); + investigation.FindingsApprovedByUserId.Should().Be(Reviewer); + _h.Audits.Rows.Should().Contain(a => a.Action == (int)RmsAccessAuditAction.Sign && a.CorrelationId == caseId); + + Func reviewerCloses = () => _h.InvestigationsService.CloseAsync(Dept, Reviewer, caseId, "done"); + await reviewerCloses.Should().ThrowAsync(); + await _h.InvestigationsService.CloseAsync(Dept, Admin, caseId, "Cause determined; referred to insurer."); + investigation.IsClosed.Should().BeTrue(); + note.IsLocked.Should().BeTrue(); + Func editLocked = () => _h.InvestigationsService.UpdateNoteAsync(Dept, Admin, note.RmsInvestigationNoteId, null, "changed"); + await editLocked.Should().ThrowAsync(); + Func addToClosed = () => _h.InvestigationsService.AddNoteAsync(Dept, Admin, caseId, RmsInvestigationNoteKind.Other, DateTime.UtcNow, null, "late"); + await addToClosed.Should().ThrowAsync(); + + await _h.InvestigationsService.ReopenAsync(Dept, Admin, caseId, "New witness."); + investigation.IsClosed.Should().BeFalse(); + note.IsLocked.Should().BeTrue("locked notes stay locked; new notes may be added"); + await _h.InvestigationsService.AddNoteAsync(Dept, Admin, caseId, RmsInvestigationNoteKind.Interview, DateTime.UtcNow, "Neighbor", "Heard a pop."); + (await _h.InvestigationsService.GetAsync(Dept, Admin, caseId)).Notes.Should().HaveCount(2); + } + + [Test] + public async Task Export_is_audited_and_refused_while_content_is_concealed() + { + var investigation = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Warehouse fire", null, null, "Summary"); + var caseId = investigation.RmsInvestigationCaseId; + await _h.InvestigationsService.AddEvidenceAsync(Dept, Admin, caseId, new RmsInvestigationEvidence { Description = "Cord" }); + var packet = Encoding.UTF8.GetString(await _h.InvestigationsService.ExportAsync(Dept, Admin, caseId, "10.0.0.9")); + packet.Should().Contain("resgrid.investigation-packet.v1").And.Contain("Cord").And.Contain("\"custody\""); + _h.Audits.Rows.Should().Contain(a => a.Action == (int)RmsAccessAuditAction.Export && a.CorrelationId == caseId && a.IpAddress == "10.0.0.9"); + (await _h.InvestigationsService.GetAccessAuditAsync(Dept, Admin, caseId, 50)).Should().NotBeEmpty(); + + var concealed = new Mock(); + concealed.Setup(p => p.RevealInvestigationCasesAsync(Dept, It.IsAny>(), default)).ReturnsAsync(new ProtectedReadResult { IsProtected = true, RedactedFields = new List { "rmsinvestigationcases.findings" }, ProtectedReason = "step_up_required" }); + concealed.Setup(p => p.RevealInvestigationNotesAsync(Dept, It.IsAny>(), default)).ReturnsAsync(new ProtectedReadResult()); + concealed.Setup(p => p.RevealInvestigationEvidenceAsync(Dept, It.IsAny>(), default)).ReturnsAsync(new ProtectedReadResult()); + concealed.Setup(p => p.RevealInvestigationReferralsAsync(Dept, It.IsAny>(), default)).ReturnsAsync(new ProtectedReadResult()); + concealed.Setup(p => p.RevealPreventionAttachmentsAsync(Dept, It.IsAny>(), false, default)).ReturnsAsync(new ProtectedReadResult()); + var service = new RecordsInvestigationsService(_h.Gate, _h.Cases, _h.CaseIncidents, _h.CaseMembers, _h.CaseNotes, _h.Evidence, _h.Custody, _h.Referrals, _h.Attachments, _h.Reports.Object, _h.Audits, _h.Authorization.Object, concealed.Object, _h.UnitOfWork.Object); + Func export = () => service.ExportAsync(Dept, Admin, caseId); + (await export.Should().ThrowAsync()).Which.Reason.Should().Be("step_up_required"); + } + + [Test] + public async Task Investigation_files_are_restricted_and_readable_only_by_members() + { + var investigation = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Warehouse fire", null, null, null); + var caseId = investigation.RmsInvestigationCaseId; + var bytes = Encoding.UTF8.GetBytes("scene sketch"); + var attachment = await _h.AttachmentsService.AddAsync(Dept, Admin, RmsPreventionParentKind.InvestigationCase, caseId, "sketch.txt", "text/plain", bytes, "Scene sketch", false); + attachment.Classification.Should().Be((int)RmsEvidenceClassification.Restricted); + attachment.Checksum.Should().NotBeNullOrEmpty(); + _h.Protection.Writes.Should().Contain("prevention attachment"); + + Func otherAdmin = () => _h.AttachmentsService.GetWithDataAsync(Dept, Admin2, attachment.RmsPreventionAttachmentId); + await otherAdmin.Should().ThrowAsync(); + await _h.InvestigationsService.AddMemberAsync(Dept, Admin, caseId, Reviewer, RmsInvestigationRole.ReadOnly); + var read = await _h.AttachmentsService.GetWithDataAsync(Dept, Reviewer, attachment.RmsPreventionAttachmentId); + read.Data.Should().Equal(bytes); + _h.Audits.Rows.Should().Contain(a => a.Action == (int)RmsAccessAuditAction.Export && a.Purpose == "Investigation file downloaded" && a.ActorUserId == Reviewer); + Func readOnlyUpload = () => _h.AttachmentsService.AddAsync(Dept, Reviewer, RmsPreventionParentKind.InvestigationCase, caseId, "x.txt", "text/plain", bytes, null, false); + await readOnlyUpload.Should().ThrowAsync(); + (await _h.InvestigationsService.GetAsync(Dept, Admin, caseId)).Attachments.Should().ContainSingle(a => a.FileName == "sketch.txt"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsOccupancyServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsOccupancyServiceTests.cs new file mode 100644 index 000000000..4cf2b9a2c --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsOccupancyServiceTests.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using static Resgrid.Tests.Rms.RmsPreventionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RMS-5 occupancy master, crosswalk, ownership switch and the Contacts projection (RMS plan section 4.3). + [TestFixture] + public class RecordsOccupancyServiceTests + { + private RmsPreventionHarness _h; + + [SetUp] + public void SetUp() => _h = new RmsPreventionHarness(); + + [Test] + public async Task Saving_an_occupancy_numbers_it_seals_it_and_records_field_provenance() + { + var saved = await _h.OccupancyService.SaveAsync(Dept, Admin, new RmsOccupancy { Name = "Riverside Mill", AddressText = "12 River Road", City = "Portland", GateCode = "4471", HazmatOnSite = true, OccupancyType = 3 }); + saved.OccupancyNumber.Should().Be($"OCC-{DateTime.UtcNow.Year}-0001"); + saved.NormalizedAddress.Should().Be("12 RIVER RD PORTLAND"); + saved.GateCode.Should().Be("4471", "the caller gets its plaintext back after the seal"); + _h.Protection.Writes.Should().Contain("occupancy"); + _h.Provenance.Rows.Should().Contain(p => p.FieldKey == "GateCode" && p.SourceKind == 0 && p.ReviewedOn != null); + _h.Audits.Rows.Should().ContainSingle(a => a.Purpose == "Occupancy created" && a.CorrelationId == saved.RmsOccupancyId && a.RecordId == null); + + var aggregate = await _h.OccupancyService.GetAsync(Dept, Member, saved.RmsOccupancyId); + aggregate.Occupancy.Name.Should().Be("Riverside Mill"); + aggregate.Provenance.Should().NotBeEmpty(); + } + + [Test] + public async Task Changing_prevention_data_needs_the_prevention_administrator_permission_and_the_module_flag() + { + Func asMember = () => _h.OccupancyService.SaveAsync(Dept, Member, new RmsOccupancy { Name = "x" }); + await asMember.Should().ThrowAsync(); + Func asOutsider = () => _h.OccupancyService.ListAsync(Dept, Outsider, new RmsOccupancyQuery()); + await asOutsider.Should().ThrowAsync(); + + _h.DisabledFlags.Add(FeatureFlagKeys.RecordsPreventionOccupancy); + Func disabled = () => _h.OccupancyService.ListAsync(Dept, Admin, new RmsOccupancyQuery()); + (await disabled.Should().ThrowAsync()).Which.Module.Should().Be(RecordsPreventionModule.Occupancy); + (await _h.OccupancyService.IsModuleEnabledAsync(Dept)).Should().BeFalse(); + + _h.DisabledFlags.Clear(); + _h.RecordsUsable = false; + (await _h.OccupancyService.IsModuleEnabledAsync(Dept)).Should().BeFalse("a module flag never bypasses the Records cutover"); + } + + [Test] + public async Task Hazards_and_role_separated_contact_links_hang_off_the_master() + { + var o = _h.SeedOccupancy(); + _h.Contacts.Setup(c => c.GetByIdAsync("c-owner")).ReturnsAsync(new Contact { ContactId = "c-owner", DepartmentId = Dept, CompanyName = "Owner LLC", ContactType = 1 }); + _h.Contacts.Setup(c => c.GetByIdAsync("c-site")).ReturnsAsync(new Contact { ContactId = "c-site", DepartmentId = Dept, FirstName = "Site", LastName = "Manager" }); + + var hazard = await _h.OccupancyService.SaveHazardAsync(Dept, Admin, new RmsOccupancyHazard { RmsOccupancyId = o.RmsOccupancyId, Title = "Propane tank", Severity = 4, ShouldAlert = true, Description = "500 gal, NE corner" }); + hazard.Description.Should().Be("500 gal, NE corner"); + var owner = await _h.OccupancyService.LinkContactAsync(Dept, Admin, o.RmsOccupancyId, "c-owner", RmsOccupancyContactRole.Owner, false); + var site = await _h.OccupancyService.LinkContactAsync(Dept, Admin, o.RmsOccupancyId, "c-site", RmsOccupancyContactRole.Site, true); + await _h.OccupancyService.LinkContactAsync(Dept, Admin, o.RmsOccupancyId, "c-owner", RmsOccupancyContactRole.EmergencyContact, true); + + var aggregate = await _h.OccupancyService.GetAsync(Dept, Admin, o.RmsOccupancyId); + aggregate.Hazards.Should().ContainSingle(h => h.Title == "Propane tank"); + aggregate.ContactLinks.Should().HaveCount(3); + aggregate.ContactLinks.Count(l => l.IsPrimary).Should().Be(1, "only one primary link per occupancy"); + aggregate.ContactLinks.Single(l => l.IsPrimary).Role.Should().Be((int)RmsOccupancyContactRole.EmergencyContact); + + Func foreign = () => _h.OccupancyService.LinkContactAsync(Dept, Admin, o.RmsOccupancyId, "c-missing", RmsOccupancyContactRole.Site, false); + await foreign.Should().ThrowAsync(); + await _h.OccupancyService.UnlinkContactAsync(Dept, Admin, owner.RmsOccupancyContactLinkId); + (await _h.OccupancyService.GetAsync(Dept, Admin, o.RmsOccupancyId)).ContactLinks.Should().HaveCount(2); + } + + private void SeedContactsWorld() + { + _h.Contacts.Setup(c => c.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new Contact { ContactId = "c1", DepartmentId = Dept, CompanyName = "Riverside Mill", ContactType = 1, PhysicalAddressId = 10, LocationGpsCoordinates = "45.5,-122.6" }, + new Contact { ContactId = "c2", DepartmentId = Dept, CompanyName = "Mill Annex", ContactType = 1, LocationGpsCoordinates = "45.50010,-122.60010" }, + new Contact { ContactId = "c3", DepartmentId = Dept, FirstName = "Pat", LastName = "Person", ContactType = 0, LocationGpsCoordinates = "45.9,-122.9" }, + new Contact { ContactId = "c4", DepartmentId = Dept, CompanyName = "No Site Corp", ContactType = 1 } + }); + _h.Contacts.Setup(c => c.GetByIdAsync("c1")).ReturnsAsync(new Contact { ContactId = "c1", DepartmentId = Dept, CompanyName = "Riverside Mill", ContactType = 1, PhysicalAddressId = 10 }); + _h.Addresses.Setup(a => a.GetByIdAsync(10)).ReturnsAsync(new Address { AddressId = 10, Address1 = "12 River Road", City = "Portland", State = "OR", PostalCode = "97201" }); + var preplan = new ContactPreplan { ContactPreplanId = "pp1", DepartmentId = Dept, ContactId = "c1", ConstructionType = 2, RoofType = 1, OccupancyType = 5, GateCode = "4471", KnoxBoxLocation = "Front door, left", TacticalSummary = "Sprinklered", HazmatOnSite = true, NearestHydrantLocation = "SE corner", NextReviewDue = DateTime.UtcNow.AddMonths(6) }; + _h.ContactPreplans.Setup(p => p.GetPreplansByDepartmentIdAsync(Dept)).ReturnsAsync(new List { preplan }); + _h.ContactPreplans.Setup(p => p.GetPreplanByContactIdAsync("c1", Dept)).ReturnsAsync(preplan); + _h.ContactHazards.Setup(h => h.GetHazardsByContactIdAsync("c1", Dept)).ReturnsAsync(new List { new ContactPreplanHazard { ContactPreplanHazardId = "ch1", ContactPreplanId = "pp1", ContactId = "c1", Title = "Propane", Severity = 3, ShouldAlert = true, Description = "500 gal" } }); + _h.Pois.Setup(p => p.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List { new Poi { PoiId = 77, Name = "Water tower", Address = "1 Hill Ct", Latitude = 45.7, Longitude = -122.7 } }); + } + + [Test] + public async Task Inventory_groups_sources_by_address_and_proximity_and_suggests_matching_occupancies() + { + SeedContactsWorld(); + var existing = _h.SeedOccupancy("Riverside Mill (RMS)", "12 River Rd, Portland OR 97201"); + + var result = await _h.OccupancyService.InventoryCandidatesAsync(Dept, Admin); + result.SourcesScanned.Should().Be(3, "pre-plan c1, company c2 with coordinates, POI 77; the person and the site-less company are skipped"); + result.CandidatesCreated.Should().Be(3); + var rows = _h.Crosswalks.Rows; + var pp = rows.Single(r => r.SourceKind == (int)RmsOccupancyCrosswalkSourceKind.ContactPreplan); + pp.SuggestedOccupancyId.Should().Be(existing.RmsOccupancyId); + pp.MatchConfidence.Should().Be(90); + pp.ContactId.Should().Be("c1"); + var annex = rows.Single(r => r.SourceId == "c2"); + annex.GroupKey.Should().Be(pp.GroupKey, "the annex sits 13 m from the mill, so it shares the group"); + rows.Single(r => r.SourceKind == (int)RmsOccupancyCrosswalkSourceKind.Poi).GroupKey.Should().NotBe(pp.GroupKey); + rows.Should().NotContain(r => r.SourceId == "c3" || r.SourceId == "c4"); + + var status = await _h.OccupancyService.GetReconciliationStatusAsync(Dept); + status.State.Should().Be(RmsOccupancyOwnershipState.Reconciling); + status.Candidates.Should().Be(3); + status.UnreconciledPreplans.Should().Be(1); + status.CanSwitchToRecords.Should().BeFalse(); + + (await _h.OccupancyService.InventoryCandidatesAsync(Dept, Admin)).CandidatesUpdated.Should().Be(3, "a second inventory updates rather than duplicates"); + _h.Crosswalks.Rows.Should().HaveCount(3); + } + + [Test] + public async Task Linking_a_preplan_candidate_creates_an_occupancy_with_provenance_hazards_and_a_site_link_then_the_switch_becomes_possible() + { + SeedContactsWorld(); + await _h.OccupancyService.InventoryCandidatesAsync(Dept, Admin); + var pp = _h.Crosswalks.Rows.Single(r => r.SourceKind == (int)RmsOccupancyCrosswalkSourceKind.ContactPreplan); + + var occupancy = await _h.OccupancyService.LinkCandidateAsync(Dept, Admin, pp.RmsOccupancyCrosswalkId, null); + occupancy.GateCode.Should().Be("4471"); + occupancy.KnoxBoxLocation.Should().Be("Front door, left"); + occupancy.WaterSupplyNotes.Should().Contain("Nearest hydrant: SE corner"); + occupancy.OccupancyType.Should().Be(5); + occupancy.HazmatOnSite.Should().BeTrue(); + _h.Hazards.Rows.Should().ContainSingle(h => h.RmsOccupancyId == occupancy.RmsOccupancyId && h.SourceContactPreplanHazardId == "ch1" && h.Description == "500 gal"); + _h.Provenance.Rows.Where(p => p.RmsOccupancyId == occupancy.RmsOccupancyId).Should().Contain(p => p.FieldKey == "GateCode" && p.SourceKind == (int)RmsOccupancyCrosswalkSourceKind.ContactPreplan && p.SourceId == "pp1"); + _h.Links.Rows.Should().ContainSingle(l => l.RmsOccupancyId == occupancy.RmsOccupancyId && l.ContactId == "c1" && l.Role == (int)RmsOccupancyContactRole.Site && l.IsPrimary); + pp.State.Should().Be((int)RmsOccupancyCrosswalkState.Linked); + _h.Protection.Writes.Should().Contain("occupancy").And.Contain("occupancy hazard"); + + Func again = () => _h.OccupancyService.LinkCandidateAsync(Dept, Admin, pp.RmsOccupancyCrosswalkId, null); + await again.Should().ThrowAsync(); + + Func early = () => _h.OccupancyService.SwitchWriteOwnershipAsync(Dept, Admin, "go live"); + (await early.Should().ThrowAsync()).WithMessage("*undecided candidate*"); + + foreach (var candidate in _h.Crosswalks.Rows.Where(r => r.State == (int)RmsOccupancyCrosswalkState.Candidate).ToList()) + await _h.OccupancyService.RejectCandidateAsync(Dept, Admin, candidate.RmsOccupancyCrosswalkId, "not a structure we track"); + var status = await _h.OccupancyService.GetReconciliationStatusAsync(Dept); + status.CanSwitchToRecords.Should().BeTrue(); + + var ownership = await _h.OccupancyService.SwitchWriteOwnershipAsync(Dept, Admin, "go live"); + ownership.State.Should().Be((int)RmsOccupancyOwnershipState.RecordsOwned); + (await _h.OccupancyService.IsRecordsOwnedAsync(Dept)).Should().BeTrue(); + Func twice = () => _h.OccupancyService.SwitchWriteOwnershipAsync(Dept, Admin, "again"); + await twice.Should().ThrowAsync(); + } + + [Test] + public async Task A_concealed_preplan_is_never_copied_as_the_sentinel() + { + SeedContactsWorld(); + _h.ProtectedReads.Setup(r => r.ResolveContactPreplansForReadAsync(Dept, It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectedReadResult { IsProtected = true, RedactedFields = new List { "contactpreplans.gatecode" }, ProtectedReason = "step_up_required" }); + await _h.OccupancyService.InventoryCandidatesAsync(Dept, Admin); + var pp = _h.Crosswalks.Rows.Single(r => r.SourceKind == (int)RmsOccupancyCrosswalkSourceKind.ContactPreplan); + Func link = () => _h.OccupancyService.LinkCandidateAsync(Dept, Admin, pp.RmsOccupancyCrosswalkId, null); + (await link.Should().ThrowAsync()).Which.Reason.Should().Be("step_up_required"); + _h.Occupancies.Rows.Should().BeEmpty(); + pp.State.Should().Be((int)RmsOccupancyCrosswalkState.Candidate); + } + + [Test] + public async Task The_dispatch_projection_carries_the_contacts_shape_and_follows_merges() + { + SeedContactsWorld(); + await _h.OccupancyService.InventoryCandidatesAsync(Dept, Admin); + var pp = _h.Crosswalks.Rows.Single(r => r.SourceKind == (int)RmsOccupancyCrosswalkSourceKind.ContactPreplan); + var occupancy = await _h.OccupancyService.LinkCandidateAsync(Dept, Admin, pp.RmsOccupancyCrosswalkId, null); + _h.Violations.Rows.Add(new RmsViolation { RmsViolationId = "v1", DepartmentId = Dept, RmsOccupancyId = occupancy.RmsOccupancyId, RmsInspectionId = "i1", State = (int)RmsViolationState.Open, Severity = 3, CreatedOn = DateTime.UtcNow, ModifiedOn = DateTime.UtcNow }); + + var projection = await _h.OccupancyService.GetDispatchProjectionForContactAsync(Dept, "c1"); + projection.ContractVersion.Should().Be(OccupancyDispatchProjectionV1.CurrentContractVersion); + projection.GateCode.Should().Be("4471"); + projection.Hazards.Should().ContainSingle(h => h.Title == "Propane" && h.ShouldAlert); + projection.Contacts.Should().ContainSingle(c => c.ContactId == "c1" && c.Role == (int)RmsOccupancyContactRole.Site); + projection.OpenViolationCount.Should().Be(1); + projection.Sources.Should().Contain("ContactPreplan"); + + (await _h.OccupancyService.GetPreplanProjectionsAsync(Dept, new[] { "c1" })).Should().BeEmpty("Contacts still owns writes, so Contacts reads its own rows"); + await _h.OccupancyService.RejectCandidateAsync(Dept, Admin, _h.Crosswalks.Rows.Single(r => r.SourceId == "c2").RmsOccupancyCrosswalkId, "dup"); + await _h.OccupancyService.RejectCandidateAsync(Dept, Admin, _h.Crosswalks.Rows.Single(r => r.SourceId == "77").RmsOccupancyCrosswalkId, "poi"); + await _h.OccupancyService.SwitchWriteOwnershipAsync(Dept, Admin, "go live"); + + var view = (await _h.OccupancyService.GetPreplanProjectionsAsync(Dept, new[] { "c1", "c9" }))["c1"]; + view.ContactPreplanId.Should().Be("occ:" + occupancy.RmsOccupancyId); + view.ContactId.Should().Be("c1"); + view.GateCode.Should().Be("4471"); + view.NearestHydrantLocation.Should().BeNull("no RMS hydrant is linked; the pre-plan text moved into the water notes"); + view.Hazards.Should().ContainSingle(h => h.Title == "Propane" && h.ContactPreplanId == view.ContactPreplanId); + + var survivor = _h.SeedOccupancy("Riverside Campus", "12 River Rd"); + await _h.OccupancyService.MergeAsync(Dept, Admin, occupancy.RmsOccupancyId, survivor.RmsOccupancyId); + occupancy.Status.Should().Be((int)RmsOccupancyStatus.Merged); + (await _h.OccupancyService.GetOccupancyIdForContactAsync(Dept, "c1")).Should().Be(survivor.RmsOccupancyId); + (await _h.OccupancyService.GetDispatchProjectionForContactAsync(Dept, "c1")).Hazards.Should().ContainSingle(h => h.Title == "Propane", "hazards moved to the survivor"); + } + + [Test] + public async Task Ownership_lookup_failures_leave_contacts_in_charge() + { + var broken = new Mock(); + broken.Setup(o => o.GetForDepartmentAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("relation does not exist")); + var service = new RecordsOccupancyService(_h.Gate, _h.Occupancies, _h.Links, _h.Hazards, _h.Crosswalks, _h.Provenance, broken.Object, _h.Violations, _h.Hydrants, _h.ContactPreplans.Object, _h.ContactHazards.Object, _h.Contacts.Object, _h.Addresses.Object, _h.Pois.Object, _h.ProtectedReads.Object, _h.Grant.Object, _h.Protection, _h.UnitOfWork.Object); + (await service.IsRecordsOwnedAsync(Dept)).Should().BeFalse(); + (await service.GetPreplanProjectionsAsync(Dept, new[] { "c1" })).Should().BeEmpty(); + } + + [Test] + public async Task Deleting_is_refused_while_violations_are_open_and_review_marks_provenance() + { + var o = _h.SeedOccupancy(); + _h.Violations.Rows.Add(new RmsViolation { RmsViolationId = "v1", DepartmentId = Dept, RmsOccupancyId = o.RmsOccupancyId, RmsInspectionId = "i1", State = (int)RmsViolationState.Open, CreatedOn = DateTime.UtcNow, ModifiedOn = DateTime.UtcNow }); + Func delete = () => _h.OccupancyService.DeleteAsync(Dept, Admin, o.RmsOccupancyId); + await delete.Should().ThrowAsync(); + _h.Violations.Rows[0].State = (int)RmsViolationState.Verified; + + var reviewed = await _h.OccupancyService.MarkReviewedAsync(Dept, Admin, o.RmsOccupancyId, 18); + reviewed.NextReviewDue.Should().BeCloseTo(DateTime.UtcNow.AddMonths(18), TimeSpan.FromMinutes(1)); + reviewed.IsReviewOverdue(DateTime.UtcNow).Should().BeFalse(); + + await _h.OccupancyService.DeleteAsync(Dept, Admin, o.RmsOccupancyId); + (await _h.OccupancyService.GetAsync(Dept, Admin, o.RmsOccupancyId)).Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsPermissionRowsTests.cs b/Tests/Resgrid.Tests/Rms/RecordsPermissionRowsTests.cs index 6c2a80a25..b39c58a8b 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsPermissionRowsTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsPermissionRowsTests.cs @@ -22,6 +22,7 @@ public class RecordsPermissionRowsTests private static readonly PermissionTypes[] AdminAnchored = { + PermissionTypes.RecordsPreventionAdmin, PermissionTypes.SubmitRecords, PermissionTypes.ShareRecordsExternally, PermissionTypes.ViewRestrictedRecords, diff --git a/Tests/Resgrid.Tests/Rms/RecordsQualityAndTelemetryTests.cs b/Tests/Resgrid.Tests/Rms/RecordsQualityAndTelemetryTests.cs new file mode 100644 index 000000000..e5377ea5d --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsQualityAndTelemetryTests.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using static Resgrid.Tests.Rms.RmsPreventionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RMS-4 optional post-finalization quality review (RMS plan section 4.7): rubrics, deterministic sampling, weighted scoring, trends, and the non-mutating rule. + [TestFixture] + public class RecordsQualityReviewServiceTests + { + private RmsPreventionHarness _h; + private List _finalized; + + [SetUp] + public void SetUp() + { + _h = new RmsPreventionHarness(); + _finalized = Enumerable.Range(1, 12).Select(i => new RmsOperationalRecord { RmsOperationalRecordId = $"rec-{i}", DepartmentId = Dept, DefinitionKey = i % 3 == 0 ? "training" : "run", State = (int)RmsRecordState.Finalized, RecordNumber = $"RUN-2026-{i:0000}", AuthorUserId = i % 2 == 0 ? "author-a" : "author-b", CurrentRevisionId = $"rev-{i}", FinalizedOn = DateTime.UtcNow.AddDays(-i) }).ToList(); + _h.Records.Setup(r => r.GetFinalizedSinceAsync(Dept, It.IsAny())).ReturnsAsync(_finalized); + _h.Units.Setup(u => u.GetForRecordsAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => ids.Select(id => new RmsRecordUnitResponse { RecordId = id, UnitId = 2 }).ToList()); + } + + private Task Rubric(string key = null, int size = 5) => _h.QualityService.SaveRubricAsync(Dept, Admin, new RmsQualityRubric { Name = "Run report QA", DefinitionKey = key, SampleSize = size, IsActive = true }, new List + { + new RmsQualityCriterion { Key = "times", Text = "Unit times complete", Weight = 3 }, + new RmsQualityCriterion { Key = "narrative", Text = "Narrative answers who/what/where", Weight = 2 }, + new RmsQualityCriterion { Key = "spelling", Text = "Spelling", Weight = 1 } + }); + + [Test] + public async Task Sampling_is_deterministic_bounded_and_skips_reviewed_records() + { + var rubric = await Rubric("run", 4); + var first = await _h.QualityService.SampleAsync(Dept, Admin, rubric.RmsQualityRubricId, DateTime.UtcNow.AddDays(-30)); + first.Should().HaveCount(4); + first.Should().OnlyContain(r => r.DefinitionKey == "run" && r.ScoredOn == null && r.RevisionId != null); + first.Should().OnlyContain(r => r.UnitId == 2, "the first responding unit is snapshotted for the unit trend"); + var second = await _h.QualityService.SampleAsync(Dept, Admin, rubric.RmsQualityRubricId, DateTime.UtcNow.AddDays(-30)); + second.Select(r => r.RecordId).Should().NotIntersectWith(first.Select(r => r.RecordId), "a record is sampled once per rubric"); + second.Should().HaveCount(4); + (await _h.QualityService.GetPendingAsync(Dept, Admin, 50)).Should().HaveCount(8); + Func member = () => _h.QualityService.SampleAsync(Dept, Member, rubric.RmsQualityRubricId, DateTime.UtcNow.AddDays(-30)); + await member.Should().ThrowAsync(); + Func memberRubric = () => _h.QualityService.SaveRubricAsync(Dept, Member, new RmsQualityRubric { Name = "x", IsActive = true }, new List { new RmsQualityCriterion { Text = "y" } }); + await memberRubric.Should().ThrowAsync(); + } + + [Test] + public async Task Scoring_is_weighted_complete_and_never_by_the_author_and_trends_aggregate() + { + var rubric = await Rubric(); + var sample = await _h.QualityService.SampleAsync(Dept, Admin, rubric.RmsQualityRubricId, DateTime.UtcNow.AddDays(-30)); + var review = sample.First(); + Func partial = () => _h.QualityService.ScoreAsync(Dept, Admin, review.RmsQualityReviewId, new List { new RmsQualityFinding { Key = "times", Score = 100 } }, null, false); + await partial.Should().ThrowAsync(); + Func self = () => _h.QualityService.ScoreAsync(Dept, review.AuthorUserId, review.RmsQualityReviewId, Findings(100, 100, 100), null, false); + _h.Reviewers.Add(review.AuthorUserId); + await self.Should().ThrowAsync(); + + var scored = await _h.QualityService.ScoreAsync(Dept, Admin, review.RmsQualityReviewId, Findings(100, 50, 0), "Narrative thin.", true); + scored.Score.Should().Be(67, "(3*100 + 2*50 + 1*0) / 6 = 66.7"); + scored.Note.Should().Be("Narrative thin."); + scored.AmendmentRecommended.Should().BeTrue(); + _h.Protection.Writes.Should().Contain("quality review"); + _h.Audits.Rows.Should().Contain(a => a.RecordId == review.RecordId && a.Purpose == "Quality review scored"); + Func twice = () => _h.QualityService.ScoreAsync(Dept, Admin, review.RmsQualityReviewId, Findings(1, 1, 1), null, false); + await twice.Should().ThrowAsync(); + + var other = sample.First(r => r.AuthorUserId != review.AuthorUserId); + await _h.QualityService.ScoreAsync(Dept, Admin2, other.RmsQualityReviewId, Findings(100, 100, 100), null, false); + var trends = await _h.QualityService.GetTrendsAsync(Dept, Admin, DateTime.UtcNow.AddDays(-1)); + trends.Scored.Should().Be(2); + trends.ByAuthor.Should().HaveCount(2); + trends.ByAuthor.First().AverageScore.Should().BeLessThan(trends.ByAuthor.Last().AverageScore); + trends.ByCriterion.Single(c => c.Key == "spelling").AverageScore.Should().Be(50); + trends.ByCriterion.Single(c => c.Key == "times").Label.Should().Be("Unit times complete"); + (await _h.QualityService.GetForRecordAsync(Dept, Member, review.RecordId)).Should().ContainSingle(r => r.Score == 67); + } + + private static List Findings(int times, int narrative, int spelling) => new List + { + new RmsQualityFinding { Key = "times", Score = times }, new RmsQualityFinding { Key = "narrative", Score = narrative }, new RmsQualityFinding { Key = "spelling", Score = spelling } + }; + } + + /// RMS-4 release telemetry: every counter comes from an existing table, and the alert rules are pinned. + [TestFixture] + public class RecordsReleaseTelemetryServiceTests + { + private Mock _outbox; + private Mock _attachments; + private Mock _records; + private Mock _dueStates; + private Mock _submissions; + private Mock _runs; + private Mock _protection; + private RmsPreventionHarness _h; + private RecordsReleaseTelemetryService _service; + + [SetUp] + public void SetUp() + { + _h = new RmsPreventionHarness(); + _outbox = new Mock(); + _attachments = new Mock(); + _records = new Mock(); + _dueStates = new Mock(); + _submissions = new Mock(); + _runs = new Mock(); + _protection = new Mock(); + _runs.Setup(r => r.GetByDepartmentIdPagedAsync(Dept, 1, 500)).ReturnsAsync(new List + { + new WorkflowRun { Status = (int)WorkflowRunStatus.Completed, StartedOn = DateTime.UtcNow.AddHours(-1) }, + new WorkflowRun { Status = (int)WorkflowRunStatus.Skipped, StartedOn = DateTime.UtcNow.AddHours(-2) }, + new WorkflowRun { Status = (int)WorkflowRunStatus.Failed, StartedOn = DateTime.UtcNow.AddHours(-3) }, + new WorkflowRun { Status = (int)WorkflowRunStatus.Completed, StartedOn = DateTime.UtcNow.AddDays(-9) } + }); + _protection.Setup(p => p.IsProtectionEnforcedAsync(Dept)).ReturnsAsync(true); + _protection.Setup(p => p.GetPinnedCatalogVersionAsync(Dept)).ReturnsAsync(13); + _service = new RecordsReleaseTelemetryService(_h.Cutover.Object, _h.Authorization.Object, _h.Audits, _outbox.Object, _runs.Object, _attachments.Object, _records.Object, _dueStates.Object, _submissions.Object, + _h.Inspections, _h.Violations, _h.Permits, _h.Hydrants, _protection.Object, _h.Gate); + } + + [Test] + public async Task Counters_and_alerts_come_from_the_tables_the_platform_already_writes() + { + _h.Audits.Rows.Add(new RmsAccessAudit { DepartmentId = Dept, Action = (int)RmsAccessAuditAction.LegacyWriteDenied, OccurredOn = DateTime.UtcNow.AddHours(-2) }); + _h.Audits.Rows.Add(new RmsAccessAudit { DepartmentId = Dept, Action = (int)RmsAccessAuditAction.LegacyWriteDenied, OccurredOn = DateTime.UtcNow.AddDays(-3) }); + _h.Audits.Rows.Add(new RmsAccessAudit { DepartmentId = Dept, Action = (int)RmsAccessAuditAction.Denied, OccurredOn = DateTime.UtcNow.AddMinutes(-5) }); + _outbox.Setup(o => o.CountByStateForDepartmentAsync(Dept, (int)DomainEventOutboxState.Pending)).ReturnsAsync(4); + _outbox.Setup(o => o.CountByStateForDepartmentAsync(Dept, (int)DomainEventOutboxState.Failed)).ReturnsAsync(0); + _outbox.Setup(o => o.GetOldestPendingCreatedOnForDepartmentAsync(Dept)).ReturnsAsync(DateTime.UtcNow.AddSeconds(-90)); + _attachments.Setup(a => a.CountByScanStateAsync(Dept, (int)RmsAttachmentScanState.Rejected)).ReturnsAsync(1); + _records.Setup(r => r.CountCreatedSinceAsync(Dept, It.IsAny())).ReturnsAsync(12); + _records.Setup(r => r.CountFinalizedSinceAsync(Dept, It.IsAny())).ReturnsAsync(9); + _dueStates.Setup(d => d.CountOverdueAsync(Dept)).ReturnsAsync(2); + _submissions.Setup(s => s.CountByStateAsync(Dept, (int)RmsSubmissionState.Failed)).ReturnsAsync(1); + _h.Violations.Rows.Add(new RmsViolation { DepartmentId = Dept, RmsViolationId = "v", State = (int)RmsViolationState.Open, DueOn = DateTime.UtcNow.AddDays(-2) }); + + var t = await _service.GetAsync(Dept, Admin, 24); + t.LegacyWriteAttempts.Should().Be(1, "one denial inside the 24 h window"); + t.AuthorizationDenials.Should().Be(1); + t.OutboxPending.Should().Be(4); + t.OutboxLagAlert.Should().BeTrue(); + t.WorkflowRunsCompleted.Should().Be(1); t.WorkflowRunsSkipped.Should().Be(1); t.WorkflowRunsFailed.Should().Be(1); + t.RecordsCreated.Should().Be(12); t.RecordsFinalized.Should().Be(9); t.RecordsOverdue.Should().Be(2); + t.SubmissionsFailed.Should().Be(1); + t.ViolationsOverdue.Should().Be(1); + t.ProtectionEnforced.Should().BeTrue(); t.ProtectedCatalogVersion.Should().Be(13); + t.RecordsActive.Should().BeTrue(); + t.Alerts.Should().Contain(a => a.StartsWith("Outbox backlog")).And.Contain(a => a.Contains("legacy Log/UnitLog write attempt")).And.Contain(a => a.Contains("NERIS submission")); + t.Warnings.Should().Contain(w => w.Contains("rejected by the scanner")).And.Contain(w => w.Contains("overdue")).And.Contain(w => w.Contains("code violation")); + + Func member = () => _service.GetAsync(Dept, Member, 24); + await member.Should().ThrowAsync(); + } + + [Test] + public async Task A_failing_counter_source_degrades_to_a_warning_and_the_snapshot_still_logs() + { + _outbox.Setup(o => o.CountByStateForDepartmentAsync(Dept, It.IsAny())).ThrowsAsync(new InvalidOperationException("db down")); + var t = await _service.LogSnapshotAsync(Dept); + t.Warnings.Should().Contain("outbox counters unavailable."); + t.OutboxLagAlert.Should().BeFalse(); + t.WindowHours.Should().Be(24); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsRms5WorkflowTriggerTests.cs b/Tests/Resgrid.Tests/Rms/RecordsRms5WorkflowTriggerTests.cs new file mode 100644 index 000000000..f2b854609 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsRms5WorkflowTriggerTests.cs @@ -0,0 +1,56 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; +using Scriban.Runtime; + +namespace Resgrid.Tests.Rms +{ + /// Workflow integration for the RMS-5 triggers 161-163 (inspection completed, violation overdue, permit expiring): catalog, samples, and no content leakage. + [TestFixture] + public class RecordsRms5WorkflowTriggerTests + { + private static readonly WorkflowTriggerEventType[] Triggers = { WorkflowTriggerEventType.RecordInspectionCompleted, WorkflowTriggerEventType.RecordViolationOverdue, WorkflowTriggerEventType.RecordPermitExpiring }; + + [Test] + public void Every_rms5_trigger_is_a_records_trigger_with_a_catalog_a_protection_block_and_no_content() + { + foreach (var trigger in Triggers) + { + WorkflowTriggerEventTypes.IsRecordsTrigger(trigger).Should().BeTrue(); + var catalog = WorkflowTemplateVariableCatalog.GetVariableCatalog(trigger); + catalog.Should().Contain(v => v.Name == "event.name").And.Contain(v => v.Name == "protection.is_protected").And.Contain(v => v.Name == "record.kind"); + catalog.Should().NotContain(v => v.Name.Contains("notes") || v.Name.Contains("description") || v.Name.Contains("applicant") || v.Name.Contains("signature") || v.Name.Contains("corrective"), + $"{trigger} must never expose inspection notes, violation text or applicant identity"); + } + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordInspectionCompleted).Should().Contain(v => v.Name == "inspection.violation_count").And.NotContain(v => v.Name.StartsWith("permit.")); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordViolationOverdue).Should().Contain(v => v.Name == "violation.days_overdue").And.NotContain(v => v.Name.StartsWith("inspection.")); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordPermitExpiring).Should().Contain(v => v.Name == "permit.days_until_expiry").And.NotContain(v => v.Name.StartsWith("violation.")); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordExportScheduled).Should().NotContain(v => v.Name.StartsWith("inspection.") || v.Name.StartsWith("violation.") || v.Name.StartsWith("permit.")); + } + + [Test] + public void Every_catalog_variable_has_a_sample_value() + { + foreach (var trigger in Triggers) + { + var data = (ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData(trigger); + foreach (var descriptor in WorkflowTemplateVariableCatalog.GetVariableCatalog(trigger)) + Has(data, descriptor.Name).Should().BeTrue($"{trigger} sample data must carry {descriptor.Name}"); + ((ScriptObject)data["record"])["kind"].Should().Be("Prevention"); + } + } + + private static bool Has(ScriptObject root, string path) + { + object current = root; + foreach (var part in path.Split('.')) + { + if (current is ScriptObject obj && obj.ContainsKey(part)) { current = obj[part]; continue; } + return false; + } + return true; + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs b/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs index abb84f1b6..d7e9e0f17 100644 --- a/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs @@ -58,6 +58,19 @@ public void Records_services_resolve_from_the_container() Resolve().Should().NotBeNull(); // RMS-3e (2026-09-05): ADP seam, ambient grant context and department report exports Resolve().Should().NotBeNull(); + // RMS-5 prevention + investigations, RMS-4 quality review and release telemetry (2026-09-07) + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().BeOfType("Contacts asks the occupancy service who owns structure writes"); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index d94b5059e..b5694b89e 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -28,12 +28,15 @@ public void Permission_types_50_to_67_are_the_registry_names() { 58, "ShareRecordsExternally" }, { 59, "ViewRestrictedRecords" }, { 60, "ViewLegacyRecords" }, { 61, "ViewGroupRecords" }, { 62, "ManageRecordDefinitions" }, { 63, "PublishRecordDefinitions" }, { 64, "ManageRecordReports" }, { 65, "ManageRecordDisclosures" }, { 66, "ManageRecordLegalHold" }, - { 67, "ReassignRecordDrafts" } + { 67, "ReassignRecordDrafts" }, { 69, "RecordsPreventionAdmin" } }; foreach (var kv in expected) Enum.GetName(typeof(PermissionTypes), kv.Key).Should().Be(kv.Value); + // 68 is Unified Search's ManageSearchIndex; RMS-5 took 69 from the pool released on 2026-08-27. + Enum.IsDefined(typeof(PermissionTypes), 68).Should().BeFalse("68 is reserved for Unified Search's ManageSearchIndex, which is not authored yet"); + // 40-49 belong to other pending plans; RMS must not have taken any of them. foreach (var value in Enumerable.Range(40, 10)) Enum.IsDefined(typeof(PermissionTypes), value).Should().BeFalse($"PermissionTypes {value} is reserved for another plan"); @@ -123,8 +126,11 @@ public void Workflow_triggers_in_the_rms_3e_block_are_the_registry_values() ((int)WorkflowTriggerEventType.RecordPurged).Should().Be(159); ((int)WorkflowTriggerEventType.RecordExportScheduled).Should().Be(160); - foreach (var value in Enumerable.Range(161, 3)) - Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved in the RMS block 2"); + // 161-163 were the block 2 buffer until RMS-5 took them on 2026-09-07 (registry section 3.2). + ((int)WorkflowTriggerEventType.RecordInspectionCompleted).Should().Be(161); + ((int)WorkflowTriggerEventType.RecordViolationOverdue).Should().Be(162); + ((int)WorkflowTriggerEventType.RecordPermitExpiring).Should().Be(163); + WorkflowTriggerEventTypes.IsRecordsTrigger(WorkflowTriggerEventType.RecordPermitExpiring).Should().BeTrue(); WorkflowTriggerEventTypes.IsRecordsTrigger(WorkflowTriggerEventType.RecordExportScheduled).Should().BeTrue(); WorkflowTriggerEventTypes.IsRecordsTrigger((WorkflowTriggerEventType)151).Should().BeFalse("151 belongs to AI Dispatch"); } @@ -148,6 +154,13 @@ public void Feature_flag_keys_match_the_registry() FeatureFlagKeys.RecordsFieldUnit.Should().Be("Records.Field.Unit"); FeatureFlagKeys.RecordsFieldIncidentCommand.Should().Be("Records.Field.IncidentCommand"); FeatureFlagKeys.RecordsFieldDispatch.Should().Be("Records.Field.Dispatch"); + FeatureFlagKeys.RecordsPreventionOccupancy.Should().Be("Records.Prevention.Occupancy"); + FeatureFlagKeys.RecordsPreventionInspections.Should().Be("Records.Prevention.Inspections"); + FeatureFlagKeys.RecordsPreventionHydrants.Should().Be("Records.Prevention.Hydrants"); + FeatureFlagKeys.RecordsPreventionPermits.Should().Be("Records.Prevention.Permits"); + FeatureFlagKeys.RecordsPreventionCrr.Should().Be("Records.Prevention.Crr"); + FeatureFlagKeys.RecordsInvestigations.Should().Be("Records.Investigations"); + FeatureFlagKeys.RecordsQualityReview.Should().Be("Records.QualityReview"); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs b/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs new file mode 100644 index 000000000..b185db23a --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// In-memory IRepository<T>: the entity instance handed in is the one stored, matching the fixture rule the RMS suites rely on. + public class InMemoryRepo : IRepository where T : class, IEntity + { + public List Rows { get; } = new List(); + public int Inserts { get; private set; } + public int Updates { get; private set; } + + protected static int DeptOf(T e) => (int)e.GetType().GetProperty("DepartmentId").GetValue(e); + protected static DateTime? DeletedOf(T e) => e.GetType().GetProperty("DeletedOn")?.GetValue(e) as DateTime?; + protected IEnumerable Live(int departmentId) => Rows.Where(r => DeptOf(r) == departmentId && DeletedOf(r) == null); + protected static string S(T e, string property) => e.GetType().GetProperty(property)?.GetValue(e) as string; + protected static int I(T e, string property) => (int)(e.GetType().GetProperty(property)?.GetValue(e) ?? 0); + + public Task> GetAllAsync() => Task.FromResult>(Rows.ToList()); + public Task GetByIdAsync(object id) => Task.FromResult(Rows.FirstOrDefault(r => Equals(r.IdValue?.ToString(), id?.ToString()))); + public Task> GetAllByDepartmentIdAsync(int departmentId) => Task.FromResult>(Rows.Where(r => DeptOf(r) == departmentId).ToList()); + public Task InsertAsync(T entity, CancellationToken cancellationToken, bool firstLevelOnly = false) { Rows.Add(entity); Inserts++; return Task.FromResult(entity); } + public Task UpdateAsync(T entity, CancellationToken cancellationToken, bool firstLevelOnly = false) { if (!Rows.Contains(entity)) { Rows.RemoveAll(r => Equals(r.IdValue, entity.IdValue)); Rows.Add(entity); } Updates++; return Task.FromResult(entity); } + public Task DeleteAsync(T entity, CancellationToken cancellationToken) => Task.FromResult(Rows.Remove(entity) || Rows.RemoveAll(r => Equals(r.IdValue, entity.IdValue)) > 0); + public Task SaveOrUpdateAsync(T entity, CancellationToken cancellationToken, bool firstLevelOnly = false) => Rows.Contains(entity) ? UpdateAsync(entity, cancellationToken, firstLevelOnly) : InsertAsync(entity, cancellationToken, firstLevelOnly); + public Task> GetAllByUserIdAsync(string userId) => Task.FromResult>(new List()); + public Task DeleteMultipleAsync(T entity, string parentKeyName, object parentKeyId, List ids, CancellationToken cancellationToken) => Task.FromResult(true); + } + + public class FakeSequences : InMemoryRepo, IRmsPreventionSequencesRepository + { + public Task NextAsync(int departmentId, string kind, int year, CancellationToken cancellationToken = default) + { + var row = Rows.FirstOrDefault(r => r.DepartmentId == departmentId && r.Kind == kind && r.Year == year); + if (row == null) { row = new RmsPreventionSequence { RmsPreventionSequenceId = Guid.NewGuid().ToString(), DepartmentId = departmentId, Kind = kind, Year = year }; Rows.Add(row); } + row.LastValue++; + return Task.FromResult(row.LastValue); + } + } + + public class FakeAudits : InMemoryRepo, IRmsAccessAuditsRepository + { + public Task> GetForRecordAsync(int departmentId, string recordId, int take) => Task.FromResult>(Rows.Where(a => a.DepartmentId == departmentId && a.RecordId == recordId).Take(take).ToList()); + public Task CountByActionSinceAsync(int departmentId, int action, DateTime sinceUtc) => Task.FromResult(Rows.Count(a => a.DepartmentId == departmentId && a.Action == action && a.OccurredOn >= sinceUtc)); + public Task> GetForAggregateAsync(int departmentId, string aggregateId, int take) => Task.FromResult>(Rows.Where(a => a.DepartmentId == departmentId && a.CorrelationId == aggregateId).OrderByDescending(a => a.OccurredOn).Take(take).ToList()); + } + + public class FakeOccupancies : InMemoryRepo, IRmsOccupanciesRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string occupancyId) => Task.FromResult(Rows.FirstOrDefault(o => o.DepartmentId == departmentId && o.RmsOccupancyId == occupancyId)); + public Task> GetByIdsAsync(int departmentId, IEnumerable occupancyIds) { var ids = occupancyIds.ToHashSet(); return Task.FromResult>(Rows.Where(o => o.DepartmentId == departmentId && ids.Contains(o.RmsOccupancyId)).ToList()); } + public Task> QueryAsync(int departmentId, RmsOccupancyQuery query) => Task.FromResult>(Live(departmentId).Where(o => o.Status != (int)RmsOccupancyStatus.Merged && (query.Status == null || o.Status == query.Status) && (string.IsNullOrEmpty(query.Search) || (o.Name ?? "").Contains(query.Search, StringComparison.OrdinalIgnoreCase))).OrderBy(o => o.Name).Skip(query.Skip).Take(query.Take).ToList()); + public Task CountAsync(int departmentId, RmsOccupancyQuery query) => Task.FromResult(Live(departmentId).Count(o => o.Status != (int)RmsOccupancyStatus.Merged)); + public Task> GetByNormalizedAddressAsync(int departmentId, string normalizedAddress) => Task.FromResult>(Live(departmentId).Where(o => o.NormalizedAddress == normalizedAddress).ToList()); + public Task> GetAllLiveAsync(int departmentId) => Task.FromResult>(Live(departmentId).Where(o => o.Status != (int)RmsOccupancyStatus.Merged).ToList()); + public Task CountLiveAsync(int departmentId) => Task.FromResult(Live(departmentId).Count(o => o.Status != (int)RmsOccupancyStatus.Merged)); + public Task CountReviewOverdueAsync(int departmentId, DateTime utcNow) => Task.FromResult(Live(departmentId).Count(o => o.Status == (int)RmsOccupancyStatus.Active && o.NextReviewDue < utcNow)); + public Task TryBumpRowVersionAsync(int departmentId, string occupancyId, long expectedVersion, CancellationToken cancellationToken = default) => Task.FromResult(true); + } + + public class FakeContactLinks : InMemoryRepo, IRmsOccupancyContactLinksRepository + { + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Live(departmentId).Where(l => l.RmsOccupancyId == occupancyId).ToList()); + public Task> GetForContactAsync(int departmentId, string contactId) => Task.FromResult>(Live(departmentId).Where(l => l.ContactId == contactId).ToList()); + public Task> GetForContactsAsync(int departmentId, IEnumerable contactIds) { var ids = contactIds.ToHashSet(); return Task.FromResult>(Live(departmentId).Where(l => ids.Contains(l.ContactId)).ToList()); } + public Task GetByIdForDepartmentAsync(int departmentId, string linkId) => Task.FromResult(Rows.FirstOrDefault(l => l.DepartmentId == departmentId && l.RmsOccupancyContactLinkId == linkId)); + } + + public class FakeOccupancyHazards : InMemoryRepo, IRmsOccupancyHazardsRepository + { + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Live(departmentId).Where(h => h.RmsOccupancyId == occupancyId).ToList()); + public Task> GetForOccupanciesAsync(int departmentId, IEnumerable occupancyIds) { var ids = occupancyIds.ToHashSet(); return Task.FromResult>(Live(departmentId).Where(h => ids.Contains(h.RmsOccupancyId)).ToList()); } + public Task GetByIdForDepartmentAsync(int departmentId, string hazardId) => Task.FromResult(Rows.FirstOrDefault(h => h.DepartmentId == departmentId && h.RmsOccupancyHazardId == hazardId)); + } + + public class FakeCrosswalks : InMemoryRepo, IRmsOccupancyCrosswalksRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string crosswalkId) => Task.FromResult(Rows.FirstOrDefault(c => c.DepartmentId == departmentId && c.RmsOccupancyCrosswalkId == crosswalkId)); + public Task GetBySourceAsync(int departmentId, RmsOccupancyCrosswalkSourceKind sourceKind, string sourceId) => Task.FromResult(Rows.FirstOrDefault(c => c.DepartmentId == departmentId && c.SourceKind == (int)sourceKind && c.SourceId == sourceId)); + public Task> GetByStateAsync(int departmentId, RmsOccupancyCrosswalkState state, int skip, int take) => Task.FromResult>(Rows.Where(c => c.DepartmentId == departmentId && c.State == (int)state).Skip(skip).Take(take).ToList()); + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Rows.Where(c => c.DepartmentId == departmentId && c.RmsOccupancyId == occupancyId).ToList()); + public Task> GetForContactAsync(int departmentId, string contactId) => Task.FromResult>(Rows.Where(c => c.DepartmentId == departmentId && c.ContactId == contactId).ToList()); + public Task> GetForContactsAsync(int departmentId, IEnumerable contactIds) { var ids = contactIds.ToHashSet(); return Task.FromResult>(Rows.Where(c => c.DepartmentId == departmentId && c.ContactId != null && ids.Contains(c.ContactId)).ToList()); } + public Task> GetAllForDepartmentAsync(int departmentId) => Task.FromResult>(Rows.Where(c => c.DepartmentId == departmentId).ToList()); + public Task CountByStateAsync(int departmentId, RmsOccupancyCrosswalkState state) => Task.FromResult(Rows.Count(c => c.DepartmentId == departmentId && c.State == (int)state)); + } + + public class FakeProvenance : InMemoryRepo, IRmsOccupancyFieldProvenancesRepository + { + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Rows.Where(p => p.DepartmentId == departmentId && p.RmsOccupancyId == occupancyId).ToList()); + public Task DeleteForOccupancyAsync(int departmentId, string occupancyId, CancellationToken cancellationToken = default) => Task.FromResult(Rows.RemoveAll(p => p.DepartmentId == departmentId && p.RmsOccupancyId == occupancyId)); + } + + public class FakeOwnerships : InMemoryRepo, IRmsOccupancyOwnershipsRepository + { + public Task GetForDepartmentAsync(int departmentId) => Task.FromResult(Rows.FirstOrDefault(o => o.DepartmentId == departmentId)); + } + + public class FakeCodeSets : InMemoryRepo, IRmsCodeSetsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string codeSetId) => Task.FromResult(Rows.FirstOrDefault(c => c.DepartmentId == departmentId && c.RmsCodeSetId == codeSetId)); + public Task> GetForDepartmentAsync(int departmentId, bool includeInactive) => Task.FromResult>(Live(departmentId).Where(c => includeInactive || c.IsActive).ToList()); + } + + public class FakeCodeSections : InMemoryRepo, IRmsCodeSectionsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string sectionId) => Task.FromResult(Rows.FirstOrDefault(c => c.DepartmentId == departmentId && c.RmsCodeSectionId == sectionId)); + public Task> GetForCodeSetAsync(int departmentId, string codeSetId) => Task.FromResult>(Live(departmentId).Where(c => c.RmsCodeSetId == codeSetId).ToList()); + public Task> GetByIdsAsync(int departmentId, IEnumerable sectionIds) { var ids = sectionIds.ToHashSet(); return Task.FromResult>(Rows.Where(c => c.DepartmentId == departmentId && ids.Contains(c.RmsCodeSectionId)).ToList()); } + } + + public class FakePrograms : InMemoryRepo, IRmsInspectionProgramsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string programId) => Task.FromResult(Rows.FirstOrDefault(p => p.DepartmentId == departmentId && p.RmsInspectionProgramId == programId)); + public Task> GetForDepartmentAsync(int departmentId, bool includeInactive) => Task.FromResult>(Live(departmentId).Where(p => includeInactive || p.IsActive).ToList()); + } + + public class FakeInspections : InMemoryRepo, IRmsInspectionsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string inspectionId) => Task.FromResult(Rows.FirstOrDefault(i => i.DepartmentId == departmentId && i.RmsInspectionId == inspectionId)); + private IEnumerable Filter(int departmentId, RmsInspectionQuery q) => Live(departmentId).Where(i => (q.OccupancyId == null || i.RmsOccupancyId == q.OccupancyId) && (q.ProgramId == null || i.RmsInspectionProgramId == q.ProgramId) && (q.States == null || q.States.Count == 0 || q.States.Contains(i.State)) && (q.ScheduledBefore == null || i.ScheduledOn <= q.ScheduledBefore)); + public Task> QueryAsync(int departmentId, RmsInspectionQuery query) => Task.FromResult>(Filter(departmentId, query).Skip(query.Skip).Take(query.Take).ToList()); + public Task CountAsync(int departmentId, RmsInspectionQuery query) => Task.FromResult(Filter(departmentId, query).Count()); + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Live(departmentId).Where(i => i.RmsOccupancyId == occupancyId).ToList()); + public Task> GetLastCompletedByOccupancyAsync(int departmentId, string programId) => Task.FromResult>(Live(departmentId).Where(i => i.RmsInspectionProgramId == programId && i.CompletedOn.HasValue).GroupBy(i => i.RmsOccupancyId).ToDictionary(g => g.Key, g => g.Max(i => i.CompletedOn.Value))); + public Task> GetOpenForProgramAsync(int departmentId, string programId) => Task.FromResult>(Live(departmentId).Where(i => i.RmsInspectionProgramId == programId && (i.State == (int)RmsInspectionState.Scheduled || i.State == (int)RmsInspectionState.InProgress || i.State == (int)RmsInspectionState.ReinspectionRequired)).ToList()); + public Task TryBumpRowVersionAsync(int departmentId, string inspectionId, long expectedVersion, CancellationToken cancellationToken = default) => Task.FromResult(true); + } + + public class FakeViolations : InMemoryRepo, IRmsViolationsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string violationId) => Task.FromResult(Rows.FirstOrDefault(v => v.DepartmentId == departmentId && v.RmsViolationId == violationId)); + public Task> GetForInspectionAsync(int departmentId, string inspectionId) => Task.FromResult>(Live(departmentId).Where(v => v.RmsInspectionId == inspectionId).ToList()); + public Task> GetForOccupancyAsync(int departmentId, string occupancyId, bool openOnly) => Task.FromResult>(Live(departmentId).Where(v => v.RmsOccupancyId == occupancyId && (!openOnly || v.IsOpen)).ToList()); + public Task> GetOpenAsync(int departmentId, int take) => Task.FromResult>(Live(departmentId).Where(v => v.IsOpen).Take(take).ToList()); + public Task> GetOverdueNotEmittedAsync(int departmentId, DateTime utcNow, int take) => Task.FromResult>(Live(departmentId).Where(v => v.IsOpen && v.DueOn < utcNow && v.OverdueEmittedOn == null).Take(take).ToList()); + public Task CountOpenAsync(int departmentId) => Task.FromResult(Live(departmentId).Count(v => v.IsOpen)); + public Task CountOverdueAsync(int departmentId, DateTime utcNow) => Task.FromResult(Live(departmentId).Count(v => v.IsOpen && v.DueOn < utcNow)); + public Task> CountOpenByOccupancyAsync(int departmentId, IEnumerable occupancyIds) { var ids = occupancyIds.ToHashSet(); return Task.FromResult>(Live(departmentId).Where(v => v.IsOpen && ids.Contains(v.RmsOccupancyId)).GroupBy(v => v.RmsOccupancyId).ToDictionary(g => g.Key, g => g.Count())); } + } + + public class FakeHydrants : InMemoryRepo, IRmsHydrantsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string hydrantId) => Task.FromResult(Rows.FirstOrDefault(h => h.DepartmentId == departmentId && h.RmsHydrantId == hydrantId)); + public Task GetByNumberAsync(int departmentId, string hydrantNumber) => Task.FromResult(Live(departmentId).FirstOrDefault(h => string.Equals(h.HydrantNumber, hydrantNumber, StringComparison.OrdinalIgnoreCase))); + public Task> GetAllLiveAsync(int departmentId) => Task.FromResult>(Live(departmentId).ToList()); + public Task> GetByIdsAsync(int departmentId, IEnumerable hydrantIds) { var ids = hydrantIds.ToHashSet(); return Task.FromResult>(Rows.Where(h => h.DepartmentId == departmentId && ids.Contains(h.RmsHydrantId)).ToList()); } + public Task> GetInBoundsAsync(int departmentId, decimal minLat, decimal maxLat, decimal minLon, decimal maxLon, int take) => Task.FromResult>(Live(departmentId).Where(h => h.Latitude >= minLat && h.Latitude <= maxLat && h.Longitude >= minLon && h.Longitude <= maxLon).Take(take).ToList()); + public Task CountLiveAsync(int departmentId) => Task.FromResult(Live(departmentId).Count()); + public Task CountOutOfServiceAsync(int departmentId) => Task.FromResult(Live(departmentId).Count(h => !h.InService)); + public Task CountTestDueAsync(int departmentId, DateTime cutoffUtc) => Task.FromResult(Live(departmentId).Count(h => h.InService && (h.LastTestedOn == null || h.LastTestedOn < cutoffUtc))); + } + + public class FakeFlowTests : InMemoryRepo, IRmsHydrantFlowTestsRepository + { + public Task> GetForHydrantAsync(int departmentId, string hydrantId) => Task.FromResult>(Rows.Where(t => t.DepartmentId == departmentId && t.RmsHydrantId == hydrantId).ToList()); + } + + public class FakeMaintenance : InMemoryRepo, IRmsHydrantMaintenancesRepository + { + public Task> GetForHydrantAsync(int departmentId, string hydrantId) => Task.FromResult>(Rows.Where(t => t.DepartmentId == departmentId && t.RmsHydrantId == hydrantId).ToList()); + } + + public class FakePermitTypes : InMemoryRepo, IRmsPermitTypesRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string permitTypeId) => Task.FromResult(Rows.FirstOrDefault(t => t.DepartmentId == departmentId && t.RmsPermitTypeId == permitTypeId)); + public Task> GetForDepartmentAsync(int departmentId, bool includeInactive) => Task.FromResult>(Live(departmentId).Where(t => includeInactive || t.IsActive).ToList()); + } + + public class FakePermits : InMemoryRepo, IRmsPermitsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string permitId) => Task.FromResult(Rows.FirstOrDefault(p => p.DepartmentId == departmentId && p.RmsPermitId == permitId)); + private IEnumerable Filter(int departmentId, RmsPermitQuery q) => Live(departmentId).Where(p => (q.OccupancyId == null || p.RmsOccupancyId == q.OccupancyId) && (q.PermitTypeId == null || p.RmsPermitTypeId == q.PermitTypeId) && (q.States == null || q.States.Count == 0 || q.States.Contains(p.State)) && (q.ExpiresBefore == null || p.ExpiresOn <= q.ExpiresBefore)); + public Task> QueryAsync(int departmentId, RmsPermitQuery query) => Task.FromResult>(Filter(departmentId, query).Skip(query.Skip).Take(query.Take).ToList()); + public Task CountAsync(int departmentId, RmsPermitQuery query) => Task.FromResult(Filter(departmentId, query).Count()); + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Live(departmentId).Where(p => p.RmsOccupancyId == occupancyId).ToList()); + public Task> GetExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc, int take) => Task.FromResult>(Live(departmentId).Where(p => p.State == (int)RmsPermitState.Issued && p.ExpiresOn != null && p.ExpiresOn <= horizonUtc).Take(take).ToList()); + public Task CountExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc) => Task.FromResult(Live(departmentId).Count(p => p.State == (int)RmsPermitState.Issued && p.ExpiresOn > utcNow && p.ExpiresOn <= horizonUtc)); + public Task TryBumpRowVersionAsync(int departmentId, string permitId, long expectedVersion, CancellationToken cancellationToken = default) => Task.FromResult(true); + } + + public class FakePlanReviews : InMemoryRepo, IRmsPlanReviewsRepository + { + public Task> GetForPermitAsync(int departmentId, string permitId) => Task.FromResult>(Rows.Where(r => r.DepartmentId == departmentId && r.RmsPermitId == permitId).ToList()); + public Task GetByIdForDepartmentAsync(int departmentId, string reviewId) => Task.FromResult(Rows.FirstOrDefault(r => r.DepartmentId == departmentId && r.RmsPlanReviewId == reviewId)); + } + + public class FakeCrr : InMemoryRepo, IRmsCrrActivitiesRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string activityId) => Task.FromResult(Rows.FirstOrDefault(a => a.DepartmentId == departmentId && a.RmsCrrActivityId == activityId)); + public Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) => Task.FromResult>(Live(departmentId).Where(a => a.OccurredOn >= startUtc && a.OccurredOn < endUtc).Take(take).ToList()); + public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Live(departmentId).Where(a => a.RmsOccupancyId == occupancyId).ToList()); + } + + public class FakePreventionAttachments : InMemoryRepo, IRmsPreventionAttachmentsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string attachmentId) => Task.FromResult(Rows.FirstOrDefault(a => a.DepartmentId == departmentId && a.RmsPreventionAttachmentId == attachmentId)); + public Task> GetMetadataForParentAsync(int departmentId, RmsPreventionParentKind parentKind, string parentId) => Task.FromResult>(Live(departmentId).Where(a => a.ParentKind == (int)parentKind && a.ParentId == parentId).ToList()); + } + + public class FakeCases : InMemoryRepo, IRmsInvestigationCasesRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string caseId) => Task.FromResult(Rows.FirstOrDefault(c => c.DepartmentId == departmentId && c.RmsInvestigationCaseId == caseId)); + public Task> GetByIdsAsync(int departmentId, IEnumerable caseIds) { var ids = caseIds.ToHashSet(); return Task.FromResult>(Live(departmentId).Where(c => ids.Contains(c.RmsInvestigationCaseId)).ToList()); } + public Task> GetForDepartmentAsync(int departmentId, bool includeClosed, int skip, int take) => Task.FromResult>(Live(departmentId).Where(c => includeClosed || !c.IsClosed).Skip(skip).Take(take).ToList()); + public Task CountOpenAsync(int departmentId) => Task.FromResult(Live(departmentId).Count(c => !c.IsClosed)); + public Task TryBumpRowVersionAsync(int departmentId, string caseId, long expectedVersion, CancellationToken cancellationToken = default) => Task.FromResult(true); + } + + public class FakeCaseIncidents : InMemoryRepo, IRmsInvestigationCaseIncidentsRepository + { + public Task> GetForCaseAsync(int departmentId, string caseId) => Task.FromResult>(Rows.Where(i => i.DepartmentId == departmentId && i.RmsInvestigationCaseId == caseId).ToList()); + public Task> GetForRecordAsync(int departmentId, string recordId) => Task.FromResult>(Rows.Where(i => i.DepartmentId == departmentId && i.RecordId == recordId).ToList()); + } + + public class FakeCaseMembers : InMemoryRepo, IRmsInvestigationCaseMembersRepository + { + public Task> GetForCaseAsync(int departmentId, string caseId) => Task.FromResult>(Rows.Where(m => m.DepartmentId == departmentId && m.RmsInvestigationCaseId == caseId).ToList()); + public Task> GetActiveForUserAsync(int departmentId, string userId) => Task.FromResult>(Rows.Where(m => m.DepartmentId == departmentId && m.UserId == userId && m.IsActive).ToList()); + public Task GetByIdForDepartmentAsync(int departmentId, string memberId) => Task.FromResult(Rows.FirstOrDefault(m => m.DepartmentId == departmentId && m.RmsInvestigationCaseMemberId == memberId)); + } + + public class FakeCaseNotes : InMemoryRepo, IRmsInvestigationNotesRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string noteId) => Task.FromResult(Rows.FirstOrDefault(n => n.DepartmentId == departmentId && n.RmsInvestigationNoteId == noteId)); + public Task> GetForCaseAsync(int departmentId, string caseId) => Task.FromResult>(Live(departmentId).Where(n => n.RmsInvestigationCaseId == caseId).ToList()); + public Task LockForCaseAsync(int departmentId, string caseId, DateTime utcNow, CancellationToken cancellationToken = default) { var n = 0; foreach (var note in Live(departmentId).Where(x => x.RmsInvestigationCaseId == caseId && !x.IsLocked)) { note.IsLocked = true; n++; } return Task.FromResult(n); } + } + + public class FakeEvidence : InMemoryRepo, IRmsInvestigationEvidenceRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string evidenceId) => Task.FromResult(Rows.FirstOrDefault(e => e.DepartmentId == departmentId && e.RmsInvestigationEvidenceId == evidenceId)); + public Task> GetForCaseAsync(int departmentId, string caseId) => Task.FromResult>(Live(departmentId).Where(e => e.RmsInvestigationCaseId == caseId).ToList()); + } + + public class FakeCustody : InMemoryRepo, IRmsInvestigationCustodyRepository + { + public Task> GetForEvidenceAsync(int departmentId, string evidenceId) => Task.FromResult>(Rows.Where(c => c.DepartmentId == departmentId && c.RmsInvestigationEvidenceId == evidenceId).OrderBy(c => c.Sequence).ToList()); + } + + public class FakeReferrals : InMemoryRepo, IRmsInvestigationReferralsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string referralId) => Task.FromResult(Rows.FirstOrDefault(r => r.DepartmentId == departmentId && r.RmsInvestigationReferralId == referralId)); + public Task> GetForCaseAsync(int departmentId, string caseId) => Task.FromResult>(Rows.Where(r => r.DepartmentId == departmentId && r.RmsInvestigationCaseId == caseId).ToList()); + } + + public class FakeRubrics : InMemoryRepo, IRmsQualityRubricsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string rubricId) => Task.FromResult(Rows.FirstOrDefault(r => r.DepartmentId == departmentId && r.RmsQualityRubricId == rubricId)); + public Task> GetForDepartmentAsync(int departmentId, bool includeInactive) => Task.FromResult>(Live(departmentId).Where(r => includeInactive || r.IsActive).ToList()); + } + + public class FakeQualityReviews : InMemoryRepo, IRmsQualityReviewsRepository + { + public Task GetByIdForDepartmentAsync(int departmentId, string reviewId) => Task.FromResult(Rows.FirstOrDefault(r => r.DepartmentId == departmentId && r.RmsQualityReviewId == reviewId)); + public Task> GetForRecordAsync(int departmentId, string recordId) => Task.FromResult>(Rows.Where(r => r.DepartmentId == departmentId && r.RecordId == recordId).ToList()); + public Task> GetPendingAsync(int departmentId, int take) => Task.FromResult>(Rows.Where(r => r.DepartmentId == departmentId && r.ScoredOn == null).Take(take).ToList()); + public Task> GetScoredSinceAsync(int departmentId, DateTime sinceUtc, int take) => Task.FromResult>(Rows.Where(r => r.DepartmentId == departmentId && r.ScoredOn >= sinceUtc).Take(take).ToList()); + public Task> GetReviewedRecordIdsAsync(int departmentId, IEnumerable recordIds) { var ids = recordIds.ToHashSet(); return Task.FromResult>(Rows.Where(r => r.DepartmentId == departmentId && ids.Contains(r.RecordId)).Select(r => r.RecordId).Distinct().ToList()); } + } + + /// Captures enqueued outbox envelopes so a test can assert on trigger and payload without a database. + public class FakeOutbox : IDomainEventOutboxService + { + public List<(int DepartmentId, DomainEventEnvelope Envelope)> Enqueued { get; } = new List<(int, DomainEventEnvelope)>(); + public List Dispatched { get; } = new List(); + public Task EnqueueAsync(int departmentId, string producerSubsystem, DomainEventEnvelope envelope, CancellationToken cancellationToken = default) + { + Enqueued.Add((departmentId, envelope)); + return Task.FromResult(new DomainEventOutboxEntry { DomainEventOutboxId = Enqueued.Count, DepartmentId = departmentId, EventName = envelope.EventName, TriggerEventType = (int)envelope.Trigger }); + } + public Task DispatchAfterCommitAsync(IEnumerable domainEventOutboxIds, CancellationToken cancellationToken = default) { Dispatched.AddRange(domainEventOutboxIds); return Task.FromResult(Dispatched.Count); } + public Task DispatchPendingAsync(string leaseOwner, int batchSize, CancellationToken cancellationToken = default) => Task.FromResult(0); + public Task GetHealthAsync() => Task.FromResult(new DomainEventOutboxHealth()); + public Task PurgeDispatchedAsync(int olderThanDays, CancellationToken cancellationToken = default) => Task.FromResult(0); + } + + /// + /// One department with Records active and every RMS-5 flag on; three people: an administrator with the + /// prevention permission and restricted view, a member with neither, and a second administrator. Every fake + /// repository is exposed so a test can seed rows or inspect what a service wrote. + /// + public sealed class RmsPreventionHarness + { + public const int Dept = 7; + public const string Admin = "admin-1"; + public const string Admin2 = "admin-2"; + public const string Member = "member-1"; + public const string Outsider = "outsider-1"; + + public Mock Cutover { get; } = new Mock(); + public Mock Flags { get; } = new Mock(); + public Mock Authorization { get; } = new Mock(); + public Mock UnitOfWork { get; } = new Mock(); + public Mock ProtectedReads { get; } = new Mock(); + public Mock Grant { get; } = new Mock(); + public Mock ContactPreplans { get; } = new Mock(); + public Mock ContactHazards { get; } = new Mock(); + public Mock Contacts { get; } = new Mock(); + public Mock Addresses { get; } = new Mock(); + public Mock Pois { get; } = new Mock(); + public Mock Reports { get; } = new Mock(); + public Mock Records { get; } = new Mock(); + public Mock Units { get; } = new Mock(); + public Mock Scanner { get; } = new Mock(); + public PassthroughRecordsProtection Protection { get; } = new PassthroughRecordsProtection(); + public FakeOutbox Outbox { get; } = new FakeOutbox(); + public HashSet DisabledFlags { get; } = new HashSet(); + public HashSet PreventionAdmins { get; } = new HashSet { Admin, Admin2 }; + public HashSet RestrictedViewers { get; } = new HashSet { Admin, Admin2 }; + public HashSet Reviewers { get; } = new HashSet { Admin, Admin2 }; + public HashSet DepartmentAdmins { get; } = new HashSet { Admin, Admin2 }; + public bool RecordsUsable { get; set; } = true; + + public FakeSequences Sequences { get; } = new FakeSequences(); + public FakeAudits Audits { get; } = new FakeAudits(); + public FakeOccupancies Occupancies { get; } = new FakeOccupancies(); + public FakeContactLinks Links { get; } = new FakeContactLinks(); + public FakeOccupancyHazards Hazards { get; } = new FakeOccupancyHazards(); + public FakeCrosswalks Crosswalks { get; } = new FakeCrosswalks(); + public FakeProvenance Provenance { get; } = new FakeProvenance(); + public FakeOwnerships Ownerships { get; } = new FakeOwnerships(); + public FakeCodeSets CodeSets { get; } = new FakeCodeSets(); + public FakeCodeSections CodeSections { get; } = new FakeCodeSections(); + public FakePrograms Programs { get; } = new FakePrograms(); + public FakeInspections Inspections { get; } = new FakeInspections(); + public FakeViolations Violations { get; } = new FakeViolations(); + public FakeHydrants Hydrants { get; } = new FakeHydrants(); + public FakeFlowTests FlowTests { get; } = new FakeFlowTests(); + public FakeMaintenance Maintenance { get; } = new FakeMaintenance(); + public FakePermitTypes PermitTypes { get; } = new FakePermitTypes(); + public FakePermits Permits { get; } = new FakePermits(); + public FakePlanReviews PlanReviews { get; } = new FakePlanReviews(); + public FakeCrr Crr { get; } = new FakeCrr(); + public FakePreventionAttachments Attachments { get; } = new FakePreventionAttachments(); + public FakeCases Cases { get; } = new FakeCases(); + public FakeCaseIncidents CaseIncidents { get; } = new FakeCaseIncidents(); + public FakeCaseMembers CaseMembers { get; } = new FakeCaseMembers(); + public FakeCaseNotes CaseNotes { get; } = new FakeCaseNotes(); + public FakeEvidence Evidence { get; } = new FakeEvidence(); + public FakeCustody Custody { get; } = new FakeCustody(); + public FakeReferrals Referrals { get; } = new FakeReferrals(); + public FakeRubrics Rubrics { get; } = new FakeRubrics(); + public FakeQualityReviews QualityReviews { get; } = new FakeQualityReviews(); + + public RecordsPreventionGate Gate { get; } + public RecordsOccupancyService OccupancyService { get; } + public RecordsInspectionsService InspectionsService { get; } + public RecordsHydrantsService HydrantsService { get; } + public RecordsPermitsService PermitsService { get; } + public RecordsCrrService CrrService { get; } + public RecordsPreventionAttachmentsService AttachmentsService { get; } + public RecordsInvestigationsService InvestigationsService { get; } + public RecordsQualityReviewService QualityService { get; } + + public RmsPreventionHarness() + { + Cutover.Setup(c => c.GetModuleStateAsync(It.IsAny(), It.IsAny())).ReturnsAsync((int d, bool b) => new RecordsModuleState { DepartmentId = d, FlagEnabled = RecordsUsable, Activated = RecordsUsable, ActivatedOn = DateTime.UtcNow.AddDays(-30), CutoverState = RecordsUsable ? RmsDepartmentCutoverState.Active : (RmsDepartmentCutoverState?)null }); + Flags.Setup(f => f.IsEnabledAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync((string key, int d, bool dv, IDictionary ctx) => !DisabledFlags.Contains(key)); + Authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync((string u, int d) => u != Outsider); + Authorization.Setup(a => a.IsDepartmentAdminAsync(It.IsAny(), Dept)).ReturnsAsync((string u, int d) => DepartmentAdmins.Contains(u)); + Authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync((string u, int d, PermissionTypes p) => + p == PermissionTypes.RecordsPreventionAdmin ? PreventionAdmins.Contains(u) : p == PermissionTypes.ViewRestrictedRecords ? RestrictedViewers.Contains(u) : p == PermissionTypes.ReviewRecords ? Reviewers.Contains(u) : DepartmentAdmins.Contains(u)); + Authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); + ProtectedReads.Setup(r => r.ResolveContactPreplansForReadAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new ProtectedReadResult()); + ProtectedReads.Setup(r => r.ResolveContactPreplanHazardsForReadAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new ProtectedReadResult()); + Scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new Resgrid.Model.Providers.RecordAttachmentScanResult { State = RmsAttachmentScanState.Skipped }); + Grant.SetupGet(g => g.UserId).Returns(Admin); + + Gate = new RecordsPreventionGate(Cutover.Object, Flags.Object, Authorization.Object, Sequences, Audits); + OccupancyService = new RecordsOccupancyService(Gate, Occupancies, Links, Hazards, Crosswalks, Provenance, Ownerships, Violations, Hydrants, ContactPreplans.Object, ContactHazards.Object, Contacts.Object, Addresses.Object, Pois.Object, ProtectedReads.Object, Grant.Object, Protection, UnitOfWork.Object); + InspectionsService = new RecordsInspectionsService(Gate, CodeSets, CodeSections, Programs, Inspections, Violations, Occupancies, Attachments, Protection, Outbox, UnitOfWork.Object); + HydrantsService = new RecordsHydrantsService(Gate, Hydrants, FlowTests, Maintenance, Attachments); + PermitsService = new RecordsPermitsService(Gate, PermitTypes, Permits, PlanReviews, Occupancies, Attachments, Protection, Outbox, UnitOfWork.Object); + CrrService = new RecordsCrrService(Gate, Crr, Occupancies); + AttachmentsService = new RecordsPreventionAttachmentsService(Gate, Attachments, CaseMembers, Evidence, Scanner.Object, Protection); + InvestigationsService = new RecordsInvestigationsService(Gate, Cases, CaseIncidents, CaseMembers, CaseNotes, Evidence, Custody, Referrals, Attachments, Reports.Object, Audits, Authorization.Object, Protection, UnitOfWork.Object); + QualityService = new RecordsQualityReviewService(Gate, Rubrics, QualityReviews, Records.Object, Units.Object, Authorization.Object, Protection, Audits); + } + + public RmsOccupancy SeedOccupancy(string name = "Riverside Mill", string address = "12 River Rd", decimal? lat = 45.5m, decimal? lon = -122.6m, int occupancyType = 3) + { + var o = new RmsOccupancy { RmsOccupancyId = Guid.NewGuid().ToString(), DepartmentId = Dept, ProtectionId = Guid.NewGuid().ToString(), OccupancyNumber = $"OCC-2026-{Occupancies.Rows.Count + 1:0000}", Name = name, Status = (int)RmsOccupancyStatus.Active, AddressText = address, NormalizedAddress = AddressNormalizer.Normalize(address), Latitude = lat, Longitude = lon, OccupancyType = occupancyType, CreatedOn = DateTime.UtcNow.AddYears(-2), ModifiedOn = DateTime.UtcNow.AddYears(-2), RowVersion = 1 }; + Occupancies.Rows.Add(o); + return o; + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RmsProtectedFieldsCatalogTests.cs b/Tests/Resgrid.Tests/Rms/RmsProtectedFieldsCatalogTests.cs index 178c93f57..549626cea 100644 --- a/Tests/Resgrid.Tests/Rms/RmsProtectedFieldsCatalogTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsProtectedFieldsCatalogTests.cs @@ -26,7 +26,7 @@ public void Every_rms_seam_field_is_cataloged_at_version_10() foreach (var fieldId in RmsProtectedFields.AllFieldIds()) { entries.Should().ContainKey(fieldId, $"the write seam can encrypt {fieldId}, so the catalog must own it"); - entries[fieldId].AddedInCatalogVersion.Should().BeInRange(ProtectedFieldCatalog.RecordsCatalogVersion, ProtectedFieldCatalog.RecordsTypedValuesCatalogVersion, $"{fieldId} ships with the RMS catalog bump (v10) or the typed-values follow-up (v11)"); + entries[fieldId].AddedInCatalogVersion.Should().BeInRange(ProtectedFieldCatalog.RecordsCatalogVersion, ProtectedFieldCatalog.PreventionCatalogVersion, $"{fieldId} ships with the RMS catalog bump (v10), the typed-values follow-up (v11) or the RMS-5 prevention family (v13)"); } } diff --git a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs index 34cd08c56..34e13e424 100644 --- a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs @@ -55,9 +55,9 @@ public async Task Scan_counts_every_binding_and_derives_the_range_and_nights() "every catalog binding is counted"); result.TotalRows.Should().Be(10000L * AdpTableBindings.V1.Count); - // 450,000 rows / 100 rps = 4500s + 46×30s overhead = 5880s; ×1.25 = 7350s → 125 min P50 (catalog v11 binds 46 tables). - result.EstimatedP50Minutes.Should().Be(125); - result.EstimatedP90Minutes.Should().Be(250); + // 620,000 rows / 100 rps = 6200s + 62×30s overhead = 8060s; ×1.25 = 10075s → 168 min P50 (catalog v13 binds 62 tables). + result.EstimatedP50Minutes.Should().Be(168); + result.EstimatedP90Minutes.Should().Be(336); result.ProjectedNights.Should().Be(1, "the P90 estimate still fits one 480-minute window"); result.BenchmarkRowsPerSecond.Should().Be(100); } diff --git a/Tests/Resgrid.Tests/Services/ContactsServicePreplanTests.cs b/Tests/Resgrid.Tests/Services/ContactsServicePreplanTests.cs new file mode 100644 index 000000000..a75c185fc --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ContactsServicePreplanTests.cs @@ -0,0 +1,614 @@ +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.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Web.Services.Controllers.v4; + +namespace Resgrid.Tests.Services +{ + /// + /// Contacts plan Phase A: pre-plans, hazards, site attachments and call surfacing on ContactsService, + /// plus the signed contact-file link validator. Repositories are mocked; the service composes. The + /// ADP write net (catalog v12) is stubbed to an allow and its calls are asserted. + /// + [TestFixture] + public class ContactsServicePreplanTests + { + private const int DepartmentId = 7; + private const string ContactId = "contact-1"; + private const string OtherContactId = "contact-2"; + + private Mock _contactsRepo; + private Mock _notesRepo; + private Mock _categoryRepo; + private Mock _noteTypesRepo; + private Mock _associationsRepo; + private Mock _preplanRepo; + private Mock _hazardRepo; + private Mock _attachmentRepo; + private Mock _callsRepo; + private Mock _callContactsRepo; + private Mock _events; + private Mock _protectedWrites; + private Mock _ownershipGate; + private List _audits; + private ContactsService _service; + + [SetUp] + public void SetUp() + { + _contactsRepo = new Mock(); + _notesRepo = new Mock(); + _categoryRepo = new Mock(); + _noteTypesRepo = new Mock(); + _associationsRepo = new Mock(); + _preplanRepo = new Mock(); + _hazardRepo = new Mock(); + _attachmentRepo = new Mock(); + _callsRepo = new Mock(); + _callContactsRepo = new Mock(); + _events = new Mock(); + _protectedWrites = new Mock(); + _audits = new List(); + + _events.Setup(x => x.SendMessage(It.IsAny())).Callback(a => _audits.Add(a)); + + // The ADP write net runs on every save (catalog v12). Stubbed to a plain allow here — a loose mock + // returns a null Task and NREs at the await. + _protectedWrites.Setup(x => x.PrepareContactPreplanWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + _protectedWrites.Setup(x => x.PrepareContactPreplanHazardWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + _protectedWrites.Setup(x => x.PrepareContactAttachmentWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + _contactsRepo.Setup(x => x.GetByIdAsync(ContactId)).ReturnsAsync(new Contact { ContactId = ContactId, DepartmentId = DepartmentId, ContactType = 1, CompanyName = "Main Street Mill" }); + _contactsRepo.Setup(x => x.GetByIdAsync(OtherContactId)).ReturnsAsync(new Contact { ContactId = OtherContactId, DepartmentId = DepartmentId, ContactType = 0, FirstName = "Ada", LastName = "Lovelace" }); + + _preplanRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((p, _, __) => + { + if (string.IsNullOrWhiteSpace(p.ContactPreplanId)) + p.ContactPreplanId = Guid.NewGuid().ToString(); + return p; + }); + _hazardRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((h, _, __) => + { + if (string.IsNullOrWhiteSpace(h.ContactPreplanHazardId)) + h.ContactPreplanHazardId = Guid.NewGuid().ToString(); + return h; + }); + _attachmentRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((a, _, __) => + { + if (a.ContactAttachmentId == 0) + a.ContactAttachmentId = 42; + return a; + }); + _hazardRepo.Setup(x => x.GetHazardsByPreplanIdAsync(It.IsAny(), DepartmentId)).ReturnsAsync(new List()); + + _ownershipGate = new Mock(); + _ownershipGate.Setup(x => x.IsRecordsOwnedAsync(It.IsAny())).ReturnsAsync(false); + _ownershipGate.Setup(x => x.GetPreplanProjectionsAsync(It.IsAny(), It.IsAny>(), It.IsAny())).ReturnsAsync(new Dictionary()); + + _service = new ContactsService(_contactsRepo.Object, _notesRepo.Object, _categoryRepo.Object, _noteTypesRepo.Object, + _associationsRepo.Object, _preplanRepo.Object, _hazardRepo.Object, _attachmentRepo.Object, _callsRepo.Object, + _callContactsRepo.Object, _events.Object, + new Lazy(() => _protectedWrites.Object), + new Lazy(() => _ownershipGate.Object)); + } + + #region RMS-5 structure ownership gate + + [Test] + public async Task Preplan_writes_are_refused_once_records_owns_structure_data() + { + _ownershipGate.Setup(x => x.IsRecordsOwnedAsync(DepartmentId)).ReturnsAsync(true); + Func save = () => _service.SavePreplanAsync(new ContactPreplan { DepartmentId = DepartmentId, ContactId = ContactId }, "user-1", null, null); + (await save.Should().ThrowAsync()).WithMessage(IContactPreplanOwnershipGate.RecordsOwnedReason); + Func delete = () => _service.DeletePreplanAsync(ContactId, DepartmentId, "user-1", null, null); + await delete.Should().ThrowAsync(); + _preplanRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Preplan_reads_project_from_records_once_it_owns_structure_data() + { + _ownershipGate.Setup(x => x.IsRecordsOwnedAsync(DepartmentId)).ReturnsAsync(true); + var projected = new ContactPreplan { ContactPreplanId = "occ:1", DepartmentId = DepartmentId, ContactId = ContactId, GateCode = "4471", TacticalSummary = "Sprinklered", Hazards = new List { new ContactPreplanHazard { ContactPreplanHazardId = "occ:h1", ContactId = ContactId, Title = "Propane", Severity = 3 } } }; + _ownershipGate.Setup(x => x.GetPreplanProjectionsAsync(DepartmentId, It.IsAny>(), It.IsAny())).ReturnsAsync(new Dictionary { [ContactId] = projected }); + + var preplan = await _service.GetPreplanByContactIdAsync(ContactId, DepartmentId); + preplan.Should().BeSameAs(projected); + (await _service.GetHazardsByContactIdAsync(ContactId, DepartmentId)).Should().ContainSingle(h => h.Title == "Propane"); + _preplanRepo.Verify(x => x.GetPreplanByContactIdAsync(It.IsAny(), It.IsAny()), Times.Never, "Contacts must not read its own pre-plan rows once Records owns the master"); + } + + #endregion + + #region Alert notes + + [Test] + public void IsLiveAlertNote_filters_expired_deleted_non_alert_and_foreign_notes() + { + var now = new DateTime(2026, 9, 7, 12, 0, 0, DateTimeKind.Utc); + ContactNote Note(Action mutate) + { + var note = new ContactNote { DepartmentId = DepartmentId, ShouldAlert = true, IsDeleted = false, ExpiresOn = null }; + mutate(note); + return note; + } + + ContactsService.IsLiveAlertNote(Note(n => { }), DepartmentId, now).Should().BeTrue("a ShouldAlert note with no expiry never expires"); + ContactsService.IsLiveAlertNote(Note(n => n.ExpiresOn = now.AddDays(1)), DepartmentId, now).Should().BeTrue(); + ContactsService.IsLiveAlertNote(Note(n => n.ExpiresOn = now.AddMinutes(-1)), DepartmentId, now).Should().BeFalse("expired"); + ContactsService.IsLiveAlertNote(Note(n => n.IsDeleted = true), DepartmentId, now).Should().BeFalse("deleted"); + ContactsService.IsLiveAlertNote(Note(n => n.ShouldAlert = false), DepartmentId, now).Should().BeFalse("not an alert"); + ContactsService.IsLiveAlertNote(Note(n => n.DepartmentId = DepartmentId + 1), DepartmentId, now).Should().BeFalse("another department"); + ContactsService.IsLiveAlertNote(null, DepartmentId, now).Should().BeFalse(); + } + + [Test] + public async Task GetAlertNotesByContactIds_returns_only_live_alert_notes_per_contact() + { + _notesRepo.Setup(x => x.GetContactNotesByContactIdAsync(ContactId)).ReturnsAsync(new List + { + new ContactNote { ContactNoteId = "live", DepartmentId = DepartmentId, ShouldAlert = true }, + new ContactNote { ContactNoteId = "expired", DepartmentId = DepartmentId, ShouldAlert = true, ExpiresOn = DateTime.UtcNow.AddDays(-1) }, + new ContactNote { ContactNoteId = "plain", DepartmentId = DepartmentId, ShouldAlert = false } + }); + _notesRepo.Setup(x => x.GetContactNotesByContactIdAsync(OtherContactId)).ReturnsAsync(new List()); + + var result = await _service.GetAlertNotesByContactIdsAsync(DepartmentId, new[] { ContactId, OtherContactId, ContactId, null }); + + result.Keys.Should().BeEquivalentTo(new[] { ContactId, OtherContactId }); + result[ContactId].Select(x => x.ContactNoteId).Should().Equal("live"); + result[OtherContactId].Should().BeEmpty(); + } + + #endregion + + #region Pre-plans + + [Test] + public async Task SavePreplan_creates_a_new_plan_runs_the_protected_write_net_and_audits_without_the_gate_code() + { + _preplanRepo.Setup(x => x.GetPreplanByContactIdAsync(ContactId, DepartmentId)).ReturnsAsync((ContactPreplan)null); + ContactPreplan persisted = null; + _preplanRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((p, _, __) => persisted = p.CloneJson()) + .ReturnsAsync((p, _, __) => { p.ContactPreplanId = "plan-1"; return p; }); + + var saved = await _service.SavePreplanAsync(new ContactPreplan + { + ContactId = ContactId, + DepartmentId = DepartmentId, + GateCode = "4321#", + KnoxBoxLocation = "North door", + NextReviewDue = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }, "user-1", "10.0.0.1", "test-agent"); + + persisted.AddedByUserId.Should().Be("user-1"); + persisted.IsDeleted.Should().BeFalse(); + saved.ContactPreplanId.Should().Be("plan-1"); + saved.GateCode.Should().Be("4321#", "an unprotected department stores and returns the plaintext"); + saved.Hazards.Should().NotBeNull(); + + // The ADP net (catalog v12) runs after the insert with the assigned id and no stored row to restore from. + _protectedWrites.Verify(x => x.PrepareContactPreplanWriteAsync(DepartmentId, It.Is(p => p.ContactPreplanId == "plan-1"), + null, null, null, true, It.IsAny()), Times.Once); + + _audits.Should().ContainSingle(); + _audits[0].Type.Should().Be(AuditLogTypes.ContactPreplanAdded); + _audits[0].UserId.Should().Be("user-1"); + _audits[0].IpAddress.Should().Be("10.0.0.1"); + _audits[0].After.Should().NotContain("4321#", "audit snapshots never carry the gate code"); + _audits[0].After.Should().Contain(ContactsService.GateCodeAuditMask); + } + + [Test] + public async Task SavePreplan_re_persists_the_enveloped_row_when_the_protected_net_changed_it() + { + _preplanRepo.Setup(x => x.GetPreplanByContactIdAsync(ContactId, DepartmentId)).ReturnsAsync((ContactPreplan)null); + _protectedWrites.Setup(x => x.PrepareContactPreplanWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, p, __, ___, ____, _____, ______) => + { + p.GateCode = "rgdp:1:1:contactpreplans.gatecode=="; + p.IsProtected = true; + }) + .ReturnsAsync(new ProtectedWriteResult { Success = true, Changed = true, IsProtected = true }); + + var saved = await _service.SavePreplanAsync(new ContactPreplan { ContactId = ContactId, DepartmentId = DepartmentId, GateCode = "4321#" }, "user-1", null, null); + + saved.GateCode.Should().Be("rgdp:1:1:contactpreplans.gatecode=="); + _preplanRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2), + "the enveloped row is written back after the broker sealed it"); + _audits[0].After.Should().NotContain("4321#").And.NotContain("rgdp:", "neither plaintext nor ciphertext reaches the audit log"); + } + + [Test] + public async Task SavePreplan_fails_closed_when_the_protected_write_is_blocked() + { + _preplanRepo.Setup(x => x.GetPreplanByContactIdAsync(ContactId, DepartmentId)).ReturnsAsync((ContactPreplan)null); + _protectedWrites.Setup(x => x.PrepareContactPreplanWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectedWriteResult { Success = false, Reason = "broker_unavailable" }); + + Func act = () => _service.SavePreplanAsync(new ContactPreplan { ContactId = ContactId, DepartmentId = DepartmentId, GateCode = "4321#" }, "user-1", null, null); + + (await act.Should().ThrowAsync()).Which.Message.Should().Contain("broker_unavailable"); + } + + [Test] + public async Task SavePreplan_updates_the_existing_row_keeps_creation_provenance_and_hands_the_stored_row_to_the_net() + { + var existing = new ContactPreplan + { + ContactPreplanId = "plan-1", + ContactId = ContactId, + DepartmentId = DepartmentId, + AddedOn = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + AddedByUserId = "creator", + GateCode = "1111" + }; + _preplanRepo.Setup(x => x.GetPreplanByContactIdAsync(ContactId, DepartmentId)).ReturnsAsync(existing); + + var saved = await _service.SavePreplanAsync(new ContactPreplan + { + ContactId = ContactId, + DepartmentId = DepartmentId, + ContactPreplanId = "something-else", + GateCode = "2222" + }, "editor", "10.0.0.1", "test-agent"); + + saved.ContactPreplanId.Should().Be("plan-1", "one live pre-plan per contact: the save lands on the existing row"); + saved.AddedByUserId.Should().Be("creator"); + saved.AddedOn.Should().Be(existing.AddedOn); + saved.EditedByUserId.Should().Be("editor"); + saved.GateCode.Should().Be("2222"); + + // The stored row is what a REDACTED sentinel restores from (an editor without a grant round-trips the placeholder). + _protectedWrites.Verify(x => x.PrepareContactPreplanWriteAsync(DepartmentId, It.IsAny(), existing, + null, null, true, It.IsAny()), Times.Once); + + _audits.Should().ContainSingle(); + _audits[0].Type.Should().Be(AuditLogTypes.ContactPreplanUpdated); + _audits[0].Before.Should().NotContain("1111"); + _audits[0].After.Should().NotContain("2222"); + } + + [Test] + public async Task SavePreplan_rejects_a_contact_from_another_department() + { + _contactsRepo.Setup(x => x.GetByIdAsync("foreign")).ReturnsAsync(new Contact { ContactId = "foreign", DepartmentId = DepartmentId + 1 }); + + Func act = () => _service.SavePreplanAsync(new ContactPreplan { ContactId = "foreign", DepartmentId = DepartmentId }, "user-1", null, null); + + await act.Should().ThrowAsync(); + _preplanRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _audits.Should().BeEmpty(); + } + + [Test] + public async Task GetPreplanByContactId_returns_the_stored_values_untouched_and_loads_live_hazards() + { + _preplanRepo.Setup(x => x.GetPreplanByContactIdAsync(ContactId, DepartmentId)).ReturnsAsync(new ContactPreplan + { + ContactPreplanId = "plan-1", + ContactId = ContactId, + DepartmentId = DepartmentId, + GateCode = "rgdp:1:1:contactpreplans.gatecode==" + }); + _hazardRepo.Setup(x => x.GetHazardsByPreplanIdAsync("plan-1", DepartmentId)).ReturnsAsync(new List + { + new ContactPreplanHazard { ContactPreplanHazardId = "h1", Title = "Propane tank" }, + new ContactPreplanHazard { ContactPreplanHazardId = "h2", Title = "Old", IsDeleted = true } + }); + + var preplan = await _service.GetPreplanByContactIdAsync(ContactId, DepartmentId); + + preplan.GateCode.Should().Be("rgdp:1:1:contactpreplans.gatecode==", "the service never decrypts: the caller runs the protected read with its grant"); + preplan.Hazards.Select(x => x.ContactPreplanHazardId).Should().Equal("h1"); + } + + [Test] + public async Task GetPreplansDueForReview_returns_only_overdue_live_plans_without_gate_codes() + { + var now = DateTime.UtcNow; + _preplanRepo.Setup(x => x.GetPreplansByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + new ContactPreplan { ContactPreplanId = "overdue", NextReviewDue = now.AddDays(-1), GateCode = "x" }, + new ContactPreplan { ContactPreplanId = "future", NextReviewDue = now.AddDays(30) }, + new ContactPreplan { ContactPreplanId = "unscheduled" }, + new ContactPreplan { ContactPreplanId = "deleted", NextReviewDue = now.AddDays(-1), IsDeleted = true } + }); + + var due = await _service.GetPreplansDueForReviewAsync(DepartmentId); + + due.Select(x => x.ContactPreplanId).Should().Equal("overdue"); + due[0].GateCode.Should().BeNull(); + } + + #endregion + + #region Hazards + + [Test] + public async Task SaveHazard_creates_the_preplan_shell_when_the_contact_has_none_and_runs_the_net() + { + _preplanRepo.SetupSequence(x => x.GetPreplanByContactIdAsync(ContactId, DepartmentId)) + .ReturnsAsync((ContactPreplan)null) + .ReturnsAsync((ContactPreplan)null); + + var saved = await _service.SaveHazardAsync(new ContactPreplanHazard + { + ContactId = ContactId, + DepartmentId = DepartmentId, + Title = "Ammonia refrigeration", + HazardType = (int)ContactPreplanHazardTypes.Hazmat, + Severity = (int)ContactPreplanHazardSeverities.Danger, + ShouldAlert = true + }, "user-1", null, null); + + saved.ContactPreplanHazardId.Should().NotBeNullOrWhiteSpace(); + saved.ContactPreplanId.Should().NotBeNullOrWhiteSpace("the hazard hangs off the shell pre-plan"); + saved.AddedByUserId.Should().Be("user-1"); + + _preplanRepo.Verify(x => x.SaveOrUpdateAsync(It.Is(p => p.ContactId == ContactId), It.IsAny(), It.IsAny()), Times.Once); + _protectedWrites.Verify(x => x.PrepareContactPreplanHazardWriteAsync(DepartmentId, It.Is(h => h.Title == "Ammonia refrigeration"), + null, null, null, true, It.IsAny()), Times.Once); + _audits.Select(x => x.Type).Should().Equal(AuditLogTypes.ContactPreplanAdded, AuditLogTypes.ContactPreplanUpdated); + } + + [Test] + public async Task SaveHazard_refuses_to_move_a_hazard_between_contacts() + { + _preplanRepo.Setup(x => x.GetPreplanByContactIdAsync(ContactId, DepartmentId)).ReturnsAsync(new ContactPreplan { ContactPreplanId = "plan-1", ContactId = ContactId, DepartmentId = DepartmentId }); + _hazardRepo.Setup(x => x.GetByIdAsync("h-other")).ReturnsAsync(new ContactPreplanHazard { ContactPreplanHazardId = "h-other", ContactId = OtherContactId, DepartmentId = DepartmentId, Title = "Dog" }); + + Func act = () => _service.SaveHazardAsync(new ContactPreplanHazard { ContactPreplanHazardId = "h-other", ContactId = ContactId, DepartmentId = DepartmentId, Title = "Dog" }, "user-1", null, null); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task DeleteHazard_soft_deletes_and_ignores_other_departments() + { + _hazardRepo.Setup(x => x.GetByIdAsync("h1")).ReturnsAsync(new ContactPreplanHazard { ContactPreplanHazardId = "h1", ContactId = ContactId, DepartmentId = DepartmentId, Title = "Dog" }); + _hazardRepo.Setup(x => x.GetByIdAsync("h-foreign")).ReturnsAsync(new ContactPreplanHazard { ContactPreplanHazardId = "h-foreign", DepartmentId = DepartmentId + 1, Title = "Dog" }); + + (await _service.DeleteHazardAsync("h-foreign", DepartmentId, "user-1", null, null)).Should().BeFalse(); + (await _service.DeleteHazardAsync("h1", DepartmentId, "user-1", null, null)).Should().BeTrue(); + + _hazardRepo.Verify(x => x.SaveOrUpdateAsync(It.Is(h => h.ContactPreplanHazardId == "h1" && h.IsDeleted && h.EditedByUserId == "user-1"), It.IsAny(), It.IsAny()), Times.Once); + _hazardRepo.Verify(x => x.SaveOrUpdateAsync(It.Is(h => h.ContactPreplanHazardId == "h-foreign"), It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion + + #region Attachments + + [Test] + public async Task SaveContactAttachment_stamps_provenance_size_runs_the_net_and_audits_metadata_only() + { + var data = new byte[] { 1, 2, 3, 4, 5 }; + + var saved = await _service.SaveContactAttachmentAsync(new ContactAttachment + { + ContactId = ContactId, + DepartmentId = DepartmentId, + FileName = "floorplan.pdf", + FileType = "application/pdf", + ContactAttachmentType = (int)ContactAttachmentTypes.FloorPlan, + Data = data + }, "user-1", "10.0.0.1", "test-agent"); + + saved.ContactAttachmentId.Should().Be(42); + saved.Size.Should().Be(5); + saved.Name.Should().Be("floorplan.pdf", "the title defaults to the file name"); + saved.AddedByUserId.Should().Be("user-1"); + + // The identity id is the AAD row key, so the net runs after the insert assigned it. + _protectedWrites.Verify(x => x.PrepareContactAttachmentWriteAsync(DepartmentId, It.Is(a => a.ContactAttachmentId == 42), + null, null, true, It.IsAny()), Times.Once); + + _audits.Should().ContainSingle(); + _audits[0].Type.Should().Be(AuditLogTypes.ContactAttachmentAdded); + _audits[0].After.Should().Contain("floorplan.pdf").And.NotContain("\"Data\":\"AQID", "the blob never enters the audit log"); + } + + [Test] + public async Task SaveContactAttachment_requires_file_data() + { + Func act = () => _service.SaveContactAttachmentAsync(new ContactAttachment { ContactId = ContactId, DepartmentId = DepartmentId, FileName = "x.pdf" }, "user-1", null, null); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task DeleteContactAttachment_soft_deletes_own_department_only() + { + _attachmentRepo.Setup(x => x.GetAttachmentByIdAsync(1)).ReturnsAsync(new ContactAttachment { ContactAttachmentId = 1, ContactId = ContactId, DepartmentId = DepartmentId, FileName = "a.pdf", Data = new byte[] { 1 } }); + _attachmentRepo.Setup(x => x.GetAttachmentByIdAsync(2)).ReturnsAsync(new ContactAttachment { ContactAttachmentId = 2, ContactId = ContactId, DepartmentId = DepartmentId + 1, FileName = "b.pdf" }); + + (await _service.DeleteContactAttachmentAsync(2, DepartmentId, "user-1", null, null)).Should().BeFalse(); + (await _service.DeleteContactAttachmentAsync(1, DepartmentId, "user-1", null, null)).Should().BeTrue(); + + _attachmentRepo.Verify(x => x.SaveOrUpdateAsync(It.Is(a => a.ContactAttachmentId == 1 && a.IsDeleted), It.IsAny(), It.IsAny()), Times.Once); + _audits.Should().ContainSingle().Which.Type.Should().Be(AuditLogTypes.ContactAttachmentRemoved); + } + + [Test] + public async Task GetContactAttachments_filters_by_type_and_never_returns_deleted_rows() + { + _attachmentRepo.Setup(x => x.GetAttachmentMetaByContactIdAsync(ContactId, DepartmentId)).ReturnsAsync(new List + { + new ContactAttachment { ContactAttachmentId = 1, ContactAttachmentType = (int)ContactAttachmentTypes.FloorPlan }, + new ContactAttachment { ContactAttachmentId = 2, ContactAttachmentType = (int)ContactAttachmentTypes.SitePhoto }, + new ContactAttachment { ContactAttachmentId = 3, ContactAttachmentType = (int)ContactAttachmentTypes.FloorPlan, IsDeleted = true } + }); + + (await _service.GetContactAttachmentsAsync(ContactId, DepartmentId)).Select(x => x.ContactAttachmentId).Should().Equal(1, 2); + (await _service.GetContactAttachmentsAsync(ContactId, DepartmentId, ContactAttachmentTypes.FloorPlan)).Select(x => x.ContactAttachmentId).Should().Equal(1); + } + + #endregion + + #region Call surfacing + + private void SetupCallWithBothContacts(int callId) + { + _callsRepo.Setup(x => x.GetByIdAsync(callId)).ReturnsAsync(new Call { CallId = callId, DepartmentId = DepartmentId }); + _callContactsRepo.Setup(x => x.GetCallContactsByCallIdAsync(callId)).ReturnsAsync(new List + { + new CallContact { CallId = callId, ContactId = OtherContactId, CallContactType = 1 }, + new CallContact { CallId = callId, ContactId = ContactId, CallContactType = 0 }, + new CallContact { CallId = callId, ContactId = ContactId, CallContactType = 1 } + }); + _preplanRepo.Setup(x => x.GetPreplansByContactIdsAsync(DepartmentId, It.IsAny>())).ReturnsAsync(new List + { + new ContactPreplan { ContactPreplanId = "plan-1", ContactId = ContactId, DepartmentId = DepartmentId, GateCode = "1234", HazmatOnSite = true } + }); + _hazardRepo.Setup(x => x.GetHazardsByContactIdsAsync(DepartmentId, It.IsAny>())).ReturnsAsync(new List + { + new ContactPreplanHazard { ContactPreplanHazardId = "h1", ContactPreplanId = "plan-1", ContactId = ContactId, Title = "Propane", ShouldAlert = true }, + new ContactPreplanHazard { ContactPreplanHazardId = "h2", ContactPreplanId = "plan-1", ContactId = ContactId, Title = "Dog" } + }); + _attachmentRepo.Setup(x => x.GetAttachmentMetaByContactIdsAsync(DepartmentId, It.IsAny>())).ReturnsAsync(new List + { + new ContactAttachment { ContactAttachmentId = 5, ContactId = ContactId, FileName = "plan.pdf" } + }); + _notesRepo.Setup(x => x.GetContactNotesByContactIdAsync(ContactId)).ReturnsAsync(new List + { + new ContactNote { ContactNoteId = "n1", DepartmentId = DepartmentId, ShouldAlert = true, Note = "Aggressive dog" }, + new ContactNote { ContactNoteId = "n2", DepartmentId = DepartmentId, ShouldAlert = true, ExpiresOn = DateTime.UtcNow.AddDays(-2) } + }); + _notesRepo.Setup(x => x.GetContactNotesByContactIdAsync(OtherContactId)).ReturnsAsync(new List()); + } + + [Test] + public async Task GetCallSiteInfo_composes_contacts_preplan_hazards_live_alert_notes_and_file_metadata_primary_first() + { + SetupCallWithBothContacts(100); + + var info = await _service.GetCallSiteInfoAsync(100, DepartmentId); + + info.CallId.Should().Be(100); + info.Contacts.Select(x => x.Contact.ContactId).Should().Equal(new[] { ContactId, OtherContactId }, "primary first, one entry per contact"); + info.Contacts[0].CallContactType.Should().Be(0); + + var site = info.Contacts[0]; + site.Preplan.Should().NotBeNull(); + site.Preplan.GateCode.Should().Be("1234", "stored values pass through; the controller runs the protected read"); + site.Preplan.Hazards.Select(x => x.ContactPreplanHazardId).Should().Equal("h1", "h2"); + site.Hazards.Should().HaveCount(2); + site.AlertNotes.Select(x => x.ContactNoteId).Should().Equal(new[] { "n1" }, "expired alert notes do not surface"); + site.Attachments.Select(x => x.ContactAttachmentId).Should().Equal(5); + + info.Contacts[1].Preplan.Should().BeNull(); + info.Contacts[1].Hazards.Should().BeEmpty(); + } + + [Test] + public async Task GetCallSiteInfo_is_null_for_a_missing_or_foreign_call_and_empty_for_a_call_without_contacts() + { + _callsRepo.Setup(x => x.GetByIdAsync(1)).ReturnsAsync((Call)null); + _callsRepo.Setup(x => x.GetByIdAsync(2)).ReturnsAsync(new Call { CallId = 2, DepartmentId = DepartmentId + 1 }); + _callsRepo.Setup(x => x.GetByIdAsync(3)).ReturnsAsync(new Call { CallId = 3, DepartmentId = DepartmentId }); + _callContactsRepo.Setup(x => x.GetCallContactsByCallIdAsync(3)).ReturnsAsync(new List()); + + (await _service.GetCallSiteInfoAsync(1, DepartmentId)).Should().BeNull(); + (await _service.GetCallSiteInfoAsync(2, DepartmentId)).Should().BeNull(); + + var empty = await _service.GetCallSiteInfoAsync(3, DepartmentId); + empty.Should().NotBeNull(); + empty.Contacts.Should().BeEmpty(); + } + + [Test] + public async Task GetCallContactSummaries_counts_preplan_hazards_and_live_alert_notes_per_call() + { + SetupCallWithBothContacts(100); + var call = new Call + { + CallId = 100, + DepartmentId = DepartmentId, + Contacts = new List + { + new CallContact { ContactId = OtherContactId, CallContactType = 1 }, + new CallContact { ContactId = ContactId, CallContactType = 0 } + } + }; + var callWithoutContacts = new Call { CallId = 101, DepartmentId = DepartmentId, Contacts = new List() }; + + var summaries = await _service.GetCallContactSummariesAsync(DepartmentId, new[] { call, callWithoutContacts }); + + summaries.Keys.Should().BeEquivalentTo(new[] { 100, 101 }); + summaries[101].Should().BeEmpty(); + + var primary = summaries[100][0]; + primary.Contact.ContactId.Should().Be(ContactId); + primary.CallContactType.Should().Be(0); + primary.HasPreplan.Should().BeTrue(); + primary.HazardCount.Should().Be(2); + primary.AlertNoteCount.Should().Be(1); + + var additional = summaries[100][1]; + additional.Contact.ContactId.Should().Be(OtherContactId); + additional.HasPreplan.Should().BeFalse(); + additional.HazardCount.Should().Be(0); + additional.AlertNoteCount.Should().Be(0); + } + + #endregion + + #region Signed contact-file links + + [Test] + public void Contact_file_link_validator_accepts_only_its_own_unexpired_kind() + { + var future = DateTime.UtcNow.AddMinutes(10).Ticks; + var past = DateTime.UtcNow.AddMinutes(-10).Ticks; + + ContactFilesController.TryValidateSignedContactFileQuery($"c|{DepartmentId}|42|{future}", out var departmentId, out var attachmentId).Should().BeTrue(); + departmentId.Should().Be(DepartmentId); + attachmentId.Should().Be(42); + + ContactFilesController.TryValidateSignedContactFileQuery($"c|{DepartmentId}|42|{past}", out _, out _).Should().BeFalse("expired"); + ContactFilesController.TryValidateSignedContactFileQuery($"{DepartmentId}|42|{future}", out _, out _).Should().BeFalse("a call-file link must not resolve a contact file"); + ContactFilesController.TryValidateSignedContactFileQuery($"x|{DepartmentId}|42|{future}", out _, out _).Should().BeFalse("wrong kind"); + ContactFilesController.TryValidateSignedContactFileQuery($"c|0|42|{future}", out _, out _).Should().BeFalse(); + ContactFilesController.TryValidateSignedContactFileQuery("", out _, out _).Should().BeFalse(); + ContactFilesController.TryValidateSignedContactFileQuery("c|a|b|c", out _, out _).Should().BeFalse(); + } + + [Test] + public void Call_file_link_validator_rejects_a_contact_file_link() + { + var future = DateTime.UtcNow.AddMinutes(10).Ticks; + + CallFilesController.TryValidateSignedFileQuery($"c|{DepartmentId}|42|{future}", out _, out _).Should().BeFalse("the kind marker keeps the two link spaces apart"); + CallFilesController.TryValidateSignedFileQuery($"{DepartmentId}|42|{future}", out var departmentId, out var attachmentId).Should().BeTrue(); + departmentId.Should().Be(DepartmentId); + attachmentId.Should().Be(42); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index d1b9a070a..dd1be10d4 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -239,6 +239,20 @@ public void Child_accessor_maps_cover_every_catalog_v1_child_column() ProtectedReadService.ContactNoteFieldAccessors.Keys .Should().BeEquivalentTo(contactNotesBinding.Columns.Select(c => c.FieldId)); + // Catalog v12: Contacts pre-plans, hazards and site attachments (Contacts plan Phase A). + var preplansBinding = AdpTableBindings.V1.Single(b => b.TableName == "ContactPreplans"); + ProtectedReadService.ContactPreplanFieldAccessors.Keys + .Should().BeEquivalentTo(preplansBinding.Columns.Select(c => c.FieldId)); + + var hazardsBinding = AdpTableBindings.V1.Single(b => b.TableName == "ContactPreplanHazards"); + ProtectedReadService.ContactPreplanHazardFieldAccessors.Keys + .Should().BeEquivalentTo(hazardsBinding.Columns.Select(c => c.FieldId)); + + var contactAttachmentsBinding = AdpTableBindings.V1.Single(b => b.TableName == "ContactAttachments"); + ProtectedReadService.ContactAttachmentFieldAccessors.Keys + .Concat(new[] { ProtectedReadService.ContactAttachmentDataFieldId }) + .Should().BeEquivalentTo(contactAttachmentsBinding.Columns.Select(c => c.FieldId)); + var unitStatesBinding = AdpTableBindings.V1.Single(b => b.TableName == "UnitStates"); ProtectedReadService.UnitStateFieldAccessors.Keys .Concat(ProtectedReadService.UnitStateCompanionAccessors.Keys) @@ -289,7 +303,13 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude "RmsSubmissions", "RmsSignatures", "RmsEvidenceArtifacts", "RmsDisclosureRequests", "RmsDisclosureProductions", "RmsRecordLegalHolds", "RmsRecordAttachments", "RmsExportRuns", // Typed values of department definitions, catalog v11: read through the same Records resolvers. - "RmsRecordValues" + "RmsRecordValues", + // Contacts pre-plans, catalog v12 (Contacts plan Phase A). + "ContactPreplans", "ContactPreplanHazards", "ContactAttachments", + // RMS-5 prevention and investigations plus the RMS-4 quality review, catalog v13: read through the generic Records resolvers. + "RmsOccupancies", "RmsOccupancyHazards", "RmsInspections", "RmsViolations", "RmsPermits", "RmsPlanReviews", + "RmsInvestigationCases", "RmsInvestigationNotes", "RmsInvestigationEvidence", "RmsInvestigationCustody", "RmsInvestigationReferrals", + "RmsQualityReviews", "RmsPreventionAttachments" }; AdpTableBindings.V1.Select(b => b.TableName) @@ -1226,6 +1246,66 @@ public async Task Write_preflight_blocks_attended_callers_without_a_grant_and_pa workload.IsProtected.Should().BeTrue(); } + [Test] + public async Task Contact_preplans_hazards_and_attachments_redact_without_a_grant_and_strip_binary_ciphertext() + { + var preplan = new ContactPreplan { ContactPreplanId = "pp-1", ContactId = "c-1", GateCode = "rgdp:1:1:gate==", KnoxBoxLocation = "North door", OccupantLoad = 40 }; + var hazard = new ContactPreplanHazard { ContactPreplanHazardId = "h-1", ContactId = "c-1", Title = "rgdp:1:1:title==", Severity = 2 }; + var attachment = new ContactAttachment { ContactAttachmentId = 9, ContactId = "c-1", FileName = "rgdp:1:1:file==", Data = new byte[] { (byte)'r', (byte)'g', (byte)'d', (byte)'p', (byte)'b', (byte)':', 1, 2 } }; + + var preplanRead = await _service.ResolveContactPreplansForReadAsync(DeptId, new[] { preplan }, null, UserId); + var hazardRead = await _service.ResolveContactPreplanHazardsForReadAsync(DeptId, new[] { hazard }, null, UserId); + var attachmentRead = await _service.ResolveContactAttachmentsForReadAsync(DeptId, new[] { attachment }, null, UserId, includeData: false); + + preplanRead.ProtectedReason.Should().Be("step_up_required"); + preplanRead.RedactedFields.Should().BeEquivalentTo("contactpreplans.gatecode"); + preplan.GateCode.Should().Be(ProtectedDataEnvelope.RedactionValue); + preplan.KnoxBoxLocation.Should().Be("North door", "a plaintext column (unprotected department, or pre-upgrade row) passes through"); + preplan.OccupantLoad.Should().Be(40, "structural columns are never cataloged"); + + hazardRead.RedactedFields.Should().BeEquivalentTo("contactpreplanhazards.title"); + hazard.Title.Should().Be(ProtectedDataEnvelope.RedactionValue); + hazard.Severity.Should().Be(2); + + attachmentRead.RedactedFields.Should().BeEquivalentTo("contactattachments.filename"); + attachment.FileName.Should().Be(ProtectedDataEnvelope.RedactionValue); + attachment.Data.Should().BeNull("a metadata-only resolution never carries ciphertext bytes out"); + } + + [Test] + public async Task Contact_preplan_write_envelopes_the_cataloged_text_and_stamps_the_row_marker() + { + SetupWriteEnforced(); + SetupCurrentCatalogVersion(); + SetupEncryptEcho(); + var preplan = new ContactPreplan { ContactPreplanId = "pp-guid-1", ContactId = "c-1", DepartmentId = DeptId, GateCode = "4411#", TacticalSummary = "Defensive only", OccupantLoad = 12 }; + + var result = await _service.PrepareContactPreplanWriteAsync(DeptId, preplan, null, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + preplan.GateCode.Should().Be("rgdp:1:1:contactpreplans.gatecode=="); + preplan.TacticalSummary.Should().Be("rgdp:1:1:contactpreplans.tacticalsummary=="); + preplan.OccupantLoad.Should().Be(12); + preplan.IsProtected.Should().BeTrue(); + } + + [Test] + public async Task Contact_preplan_write_restores_a_round_tripped_sentinel_from_the_stored_row() + { + SetupWriteEnforced(); + SetupCurrentCatalogVersion(); + SetupEncryptEcho(); + var stored = new ContactPreplan { ContactPreplanId = "pp-guid-1", ContactId = "c-1", DepartmentId = DeptId, GateCode = "rgdp:1:1:stored==" }; + var edited = new ContactPreplan { ContactPreplanId = "pp-guid-1", ContactId = "c-1", DepartmentId = DeptId, GateCode = ProtectedDataEnvelope.RedactionValue, AccessNotes = "Use side gate" }; + + var result = await _service.PrepareContactPreplanWriteAsync(DeptId, edited, stored, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + result.Changed.Should().BeTrue(); + edited.GateCode.Should().Be("rgdp:1:1:stored==", "an editor who never had the gate code revealed must not overwrite it with the placeholder"); + edited.AccessNotes.Should().Be("rgdp:1:1:contactpreplans.accessnotes=="); + } + [Test] public async Task Contact_notes_redact_without_a_grant() { diff --git a/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs b/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs index bff74b91d..b942a96f3 100644 --- a/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs +++ b/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs @@ -22,11 +22,30 @@ public class RemainingCandidateProtectionTests [Test] public void The_catalog_is_at_version_ten_and_the_last_candidates_are_what_moved_it() { - // v9 closed the remaining Protected Data candidates; v10 (2026-09-05) is the Records (RMS) family and - // v11 (2026-09-06) the typed values of department definitions, pinned in RmsProtectedFieldsCatalogTests. - _catalog.Version.Should().Be(11); - _catalog.GetAddedBetween(9, 11).Select(e => e.FieldId).Should().BeEquivalentTo(Resgrid.Model.RmsProtectedFields.AllFieldIds()); + // v9 closed the remaining Protected Data candidates; v10 (2026-09-05) is the Records (RMS) family, + // v11 (2026-09-06) the typed values of department definitions, pinned in RmsProtectedFieldsCatalogTests, + // and v12 (2026-09-07) the Contacts pre-plan family (Contacts plan Phase A). + // v13 (2026-09-07) is the RMS-5 prevention/investigation family plus the RMS-4 quality review. + _catalog.Version.Should().Be(ProtectedFieldCatalog.PreventionCatalogVersion); + _catalog.GetAddedBetween(9, 13).Where(e => e.Family == Resgrid.Model.RmsProtectedFields.Family).Select(e => e.FieldId).Should().BeEquivalentTo(Resgrid.Model.RmsProtectedFields.AllFieldIds()); + _catalog.GetAddedBetween(12, 13).Select(e => e.FieldId).Should().BeEquivalentTo( + Resgrid.Model.RmsProtectedFields.Occupancies.Keys.Concat(Resgrid.Model.RmsProtectedFields.OccupancyHazards.Keys) + .Concat(Resgrid.Model.RmsProtectedFields.Inspections.Keys).Concat(Resgrid.Model.RmsProtectedFields.Violations.Keys) + .Concat(Resgrid.Model.RmsProtectedFields.Permits.Keys).Concat(Resgrid.Model.RmsProtectedFields.PlanReviews.Keys) + .Concat(Resgrid.Model.RmsProtectedFields.InvestigationCases.Keys).Concat(Resgrid.Model.RmsProtectedFields.InvestigationNotes.Keys) + .Concat(Resgrid.Model.RmsProtectedFields.InvestigationEvidence.Keys).Concat(Resgrid.Model.RmsProtectedFields.InvestigationCustody.Keys) + .Concat(Resgrid.Model.RmsProtectedFields.InvestigationReferrals.Keys).Concat(Resgrid.Model.RmsProtectedFields.QualityReviews.Keys) + .Concat(Resgrid.Model.RmsProtectedFields.PreventionAttachments.Keys).Concat(new[] { Resgrid.Model.RmsProtectedFields.PreventionAttachmentDataFieldId }), + "catalog v13 is exactly the RMS-5 seam"); + _catalog.GetAddedBetween(12, 13).Should().OnlyContain(e => e.Family == Resgrid.Model.RmsProtectedFields.Family); _catalog.GetAddedBetween(10, 11).Select(e => e.FieldId).Should().BeEquivalentTo(new[] { Resgrid.Model.RmsProtectedFields.ValueFieldId }); + _catalog.GetAddedBetween(11, 12).Select(e => e.FieldId).Should().BeEquivalentTo( + ProtectedReadService.ContactPreplanFieldAccessors.Keys + .Concat(ProtectedReadService.ContactPreplanHazardFieldAccessors.Keys) + .Concat(ProtectedReadService.ContactAttachmentFieldAccessors.Keys) + .Concat(new[] { ProtectedReadService.ContactAttachmentDataFieldId }), + "catalog v12 is exactly the pre-plan, hazard and site-attachment seam"); + _catalog.GetAddedBetween(11, 12).Should().OnlyContain(e => e.Family == "Contacts" && e.ViewPermission == PermissionTypes.ViewProtectedContactData); _catalog.GetAddedBetween(8, 9).Select(e => e.FieldId) .Should().BeEquivalentTo(new[] diff --git a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs index 985040ba4..7e96d877d 100644 --- a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs @@ -96,7 +96,8 @@ public void SetUp() Mock.Of(), _dataProtectionService.Object, _protectedCallReadService.Object, - Mock.Of()) + Mock.Of(), + Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; diff --git a/Tests/Resgrid.Tests/Web/User/ProtectedRevealAuthorizationTests.cs b/Tests/Resgrid.Tests/Web/User/ProtectedRevealAuthorizationTests.cs index ad986795b..bab95fce8 100644 --- a/Tests/Resgrid.Tests/Web/User/ProtectedRevealAuthorizationTests.cs +++ b/Tests/Resgrid.Tests/Web/User/ProtectedRevealAuthorizationTests.cs @@ -116,6 +116,11 @@ private static DirectoryInfo RepositoryRoot() { "ContactsController.cs:RevealContact", + // The contact pre-plan (Contacts plan Phase A, ADP catalog v12) is the same department-level + // record as the contact: the editor and the call Site Info tab both authorize through the + // Contacts resource policy plus the department comparison, and the reveal repeats that check. + "ContactsController.cs:RevealContactPreplan", + // A document and a calendar entry belong to the department, not to a member. There is no // per-record ACL to consult beyond the resource policy and, for documents, the // admins-only flag - which is the same model the pages hosting these reveals use. diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 28f90cad8..5fd776958 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -22,6 +22,8 @@ using Resgrid.Model.Events; using Resgrid.Model.Queue; using Resgrid.Web.Services.Models.v4.CallProtocols; +using Resgrid.Web.Services.Models.v4.ContactFiles; +using Resgrid.Web.Services.Models.v4.Contacts; using Resgrid.Web.Helpers; using Resgrid.Web.ServicesCore.Helpers; @@ -61,6 +63,7 @@ public class CallsController : V4AuthenticatedApiControllerbaseSystemAuth private readonly IFeatureToggleService _featureToggleService; private readonly IDepartmentDataProtectionService _dataProtectionService; private readonly IProtectedReadService _protectedCallReadService; + private readonly IContactsService _contactsService; private readonly IProtectedWriteService _protectedWriteService; public CallsController( @@ -89,9 +92,11 @@ public CallsController( IFeatureToggleService featureToggleService, IDepartmentDataProtectionService dataProtectionService, IProtectedReadService protectedCallReadService, - IProtectedWriteService protectedWriteService + IProtectedWriteService protectedWriteService, + IContactsService contactsService ) { + _contactsService = contactsService; _dataProtectionService = dataProtectionService; _protectedCallReadService = protectedCallReadService; _protectedWriteService = protectedWriteService; @@ -172,6 +177,7 @@ private async Task ApplyBigBoardSafeShellAsync(IEnumerable call.Type = null; call.Latitude = null; call.Longitude = null; + call.Contacts = new List(); } return true; @@ -238,6 +244,7 @@ public async Task> GetActiveCalls() // Resolve BEFORE per-call processing so geocoding and templates see plaintext (or // REDACTED), never envelopes. var protectedReads = await ResolveProtectedReadsAsync(calls); + var contactPairs = new List>(); foreach (var c in calls) { @@ -258,9 +265,13 @@ public async Task> GetActiveCalls() var callData = ConvertCall(callWithData, null, address, TimeZone, destinationPoi); if (protectedReads.TryGetValue(callWithData.CallId, out var protectedRead)) ApplyProtectedReadMetadata(callData, protectedRead); + contactPairs.Add(new KeyValuePair(callWithData, callData)); result.Data.Add(callData); } + // Linked contacts with pre-plan / alert / hazard indicators (Contacts plan Phase A, 3a). + await ApplyCallContactsAsync(DepartmentId, contactPairs); + await ApplyBigBoardSafeShellAsync(result.Data); result.PageSize = result.Data.Count(); result.Status = ResponseHelper.Success; @@ -340,6 +351,9 @@ public async Task> GetCall(string callId, [FromQuery result.Data = ConvertCall(c, protocols, address, TimeZone, destinationPoi); ApplyProtectedReadMetadata(result.Data, protectedRead); + // Linked contacts with pre-plan / alert / hazard indicators (Contacts plan Phase A, 3a). + await ApplyCallContactsAsync(effectiveDepartmentId, new List> { new KeyValuePair(c, result.Data) }); + // BigBoard shells also suppress UDF submissions — user-authored free text defaults to // sensitive in a protected department (plan section 5.2). if (await ApplyBigBoardSafeShellAsync(new[] { result.Data })) @@ -373,6 +387,145 @@ public async Task> GetCall(string callId, [FromQuery return Ok(result); } + /// + /// Gets the site information for every contact linked to a call in one round trip: the contact, + /// its pre-incident plan, premise hazards, live alert notes and file metadata with signed download + /// links (Contacts plan Phase A, decision 3b). This is the RMS/NERIS authoring prefill contract for + /// calls linked to a Contact: additive changes only. Signed file links expire; do not persist them. + /// + /// Id of the call + [HttpGet("GetCallSiteInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> GetCallSiteInfo(string callId) + { + if (!int.TryParse(callId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedCallId)) + return BadRequest(); + + var result = new CallSiteInfoResult(); + var call = await _callsService.GetCallByIdAsync(parsedCallId); + + if (call == null) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + if (call.DepartmentId != DepartmentId) + return Unauthorized(); + + if (!IsSystemApiKeyRequest && !await _authorizationService.CanUserViewCallAsync(UserId, parsedCallId)) + return Unauthorized(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var siteInfo = await _contactsService.GetCallSiteInfoAsync(parsedCallId, DepartmentId); + + result.Data = new CallSiteInfoData { CallId = parsedCallId.ToString() }; + + if (siteInfo != null && siteInfo.Contacts.Any()) + { + // Attended protected read (plan 7.1): contact identity and note text decrypt with a valid + // grant or read as REDACTED. Pre-plan and hazard text is not cataloged and passes through. + var contacts = siteInfo.Contacts.Select(x => x.Contact).ToList(); + var protectedRead = await _protectedCallReadService.ResolveContactsForReadAsync(DepartmentId, contacts, ProtectedGrantToken, UserId); + result.Data.IsProtected = protectedRead.IsProtected; + result.Data.ProtectedReason = protectedRead.ProtectedReason; + + var alertNotes = siteInfo.Contacts.SelectMany(x => x.AlertNotes).ToList(); + if (alertNotes.Any()) + await _protectedCallReadService.ResolveContactNotesForReadAsync(DepartmentId, alertNotes, ProtectedGrantToken, UserId); + + // Catalog v12: pre-plan text, hazard text and attachment names resolve in the same request. + var preplans = siteInfo.Contacts.Where(x => x.Preplan != null).Select(x => x.Preplan).ToList(); + var preplanRead = preplans.Any() + ? await _protectedCallReadService.ResolveContactPreplansForReadAsync(DepartmentId, preplans, ProtectedGrantToken, UserId) + : new ProtectedReadResult(); + var hazards = siteInfo.Contacts.SelectMany(x => x.Hazards).ToList(); + var hazardRead = hazards.Any() + ? await _protectedCallReadService.ResolveContactPreplanHazardsForReadAsync(DepartmentId, hazards, ProtectedGrantToken, UserId) + : new ProtectedReadResult(); + var attachments = siteInfo.Contacts.SelectMany(x => x.Attachments).ToList(); + var attachmentRead = attachments.Any() + ? await _protectedCallReadService.ResolveContactAttachmentsForReadAsync(DepartmentId, attachments, ProtectedGrantToken, UserId, includeData: false) + : new ProtectedReadResult(); + + result.Data.IsProtected = result.Data.IsProtected || preplanRead.IsProtected || hazardRead.IsProtected || attachmentRead.IsProtected; + result.Data.ProtectedReason ??= preplanRead.ProtectedReason ?? hazardRead.ProtectedReason ?? attachmentRead.ProtectedReason; + + var noteTypes = await _contactsService.GetContactNoteTypesByDepartmentIdAsync(DepartmentId); + + foreach (var entry in siteInfo.Contacts) + { + var data = new CallSiteContactData + { + ContactId = entry.Contact.ContactId, + Name = SafeContactName(entry.Contact), + ContactType = entry.Contact.ContactType, + CallContactType = entry.CallContactType, + LocationGpsCoordinates = entry.Contact.LocationGpsCoordinates, + EntranceGpsCoordinates = entry.Contact.EntranceGpsCoordinates, + PhoneNumber = FirstNonEmpty(entry.Contact.CellPhoneNumber, entry.Contact.OfficePhoneNumber, entry.Contact.HomePhoneNumber) + }; + + if (entry.Preplan != null) + { + data.Preplan = ContactsController.ConvertPreplanData(entry.Preplan, department); + ContactsController.ApplyProtection(data.Preplan, preplanRead, hazardRead); + if (!string.IsNullOrWhiteSpace(entry.Preplan.ContactPreplanId) && entry.Preplan.ContactPreplanId.StartsWith("occ:", StringComparison.Ordinal)) + data.OccupancyId = entry.Preplan.ContactPreplanId.Substring(4); + } + + data.Hazards = entry.Hazards.Select(h => + { + var hazardData = ContactsController.ConvertHazardData(h, department); + hazardData.IsProtected = hazardRead.IsProtected; + hazardData.ProtectedReason = hazardRead.ProtectedReason; + return hazardData; + }).ToList(); + + foreach (var note in entry.AlertNotes) + { + var noteType = string.IsNullOrWhiteSpace(note.ContactNoteTypeId) ? null : noteTypes.FirstOrDefault(t => t.ContactNoteTypeId == note.ContactNoteTypeId); + var noteData = new ContactNoteResultData + { + ContactNoteId = note.ContactNoteId, + ContactId = note.ContactId, + ContactNoteTypeId = note.ContactNoteTypeId, + NoteType = noteType?.Name, + Note = note.Note, + ShouldAlert = note.ShouldAlert, + Visibility = note.Visibility, + ExpiresOnUtc = note.ExpiresOn, + AddedOnUtc = note.AddedOn, + AddedOn = note.AddedOn.FormatForDepartment(department), + AddedByUserId = note.AddedByUserId, + IsProtected = result.Data.IsProtected, + ProtectedReason = result.Data.ProtectedReason + }; + if (note.ExpiresOn.HasValue) + noteData.ExpiresOn = note.ExpiresOn.Value.FormatForDepartment(department); + data.AlertNotes.Add(noteData); + } + + data.Attachments = entry.Attachments.Select(a => + { + var fileData = ContactFilesController.ConvertContactFileData(a, department, false); + fileData.IsProtected = attachmentRead.IsProtected; + fileData.ProtectedReason = attachmentRead.ProtectedReason; + return fileData; + }).ToList(); + + result.Data.Contacts.Add(data); + } + } + + result.PageSize = result.Data.Contacts.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + /// /// Gets all the meta-data around a call, dispatched personnel, units, groups and responses /// @@ -980,6 +1133,13 @@ public async Task> SaveCall([FromBody] NewCallInput if (!writePreflight.Success) return ProtectedWriteProblem(writePreflight); + // Contacts plan Phase A (A5): API-created calls can link department contacts. + var contactLinks = await BuildCallContactLinksAsync(effectiveDepartmentId, newCallInput.ContactId, newCallInput.AdditionalContactIds); + if (contactLinks == null) + return BadRequest("One or more contacts do not belong to this department."); + if (contactLinks.Any()) + call.Contacts = contactLinks; + var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); if (recommendationResult != null && recommendationResult.MatchedRunCardId.HasValue && recommendationResult.AutoDispatch) @@ -1367,6 +1527,17 @@ public async Task> EditCall([FromBody] EditCallInpu // ADP protected write (plan 19.2): cataloged plaintext encrypts in place before // persistence; a blocked write persists nothing. + // Contacts plan Phase A (A5): replace the contact links only when the input carries them. + if (editCallInput.ContactId != null || editCallInput.AdditionalContactIds != null) + { + var contactLinks = await BuildCallContactLinksAsync(DepartmentId, editCallInput.ContactId, editCallInput.AdditionalContactIds); + if (contactLinks == null) + return BadRequest("One or more contacts do not belong to this department."); + + await _callsService.DeleteCallContactsAsync(call.CallId, cancellationToken); + call.Contacts = contactLinks; + } + var protectedWrite = await _protectedWriteService.PrepareCallWriteAsync(DepartmentId, call, storedCatalogedValues, ProtectedGrantToken, UserId, IsSystemApiKeyRequest, cancellationToken); if (!protectedWrite.Success) @@ -2235,6 +2406,88 @@ public static CallResultData ConvertCall(Call call, List proto return callResult; } + /// Validates the requested contact links belong to the department; null when any does not, empty when none were requested. + private async Task> BuildCallContactLinksAsync(int departmentId, string primaryContactId, List additionalContactIds) + { + var links = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + async Task AddAsync(string contactId, int callContactType) + { + if (string.IsNullOrWhiteSpace(contactId) || !seen.Add(contactId)) + return true; + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.IsDeleted || contact.DepartmentId != departmentId) + return false; + + links.Add(new CallContact { DepartmentId = departmentId, ContactId = contact.ContactId, CallContactType = callContactType }); + return true; + } + + if (!await AddAsync(primaryContactId, 0)) + return null; + + foreach (var contactId in additionalContactIds ?? new List()) + { + if (!await AddAsync(contactId, 1)) + return null; + } + + return links; + } + + /// + /// Fills CallResultData.Contacts for the given calls (Contacts plan Phase A, decision 3a). Contact + /// names honor the caller's protected-read grant: plaintext with a grant, REDACTED without. + /// + private async Task ApplyCallContactsAsync(int departmentId, IReadOnlyList> pairs) + { + var calls = pairs.Select(x => x.Key).Where(x => x?.Contacts != null && x.Contacts.Any()).ToList(); + if (!calls.Any()) + return; + + var summaries = await _contactsService.GetCallContactSummariesAsync(departmentId, calls) ?? new Dictionary>(); + var contacts = summaries.Values.SelectMany(x => x).Select(x => x.Contact).GroupBy(x => x.ContactId).Select(g => g.First()).ToList(); + if (contacts.Any()) + await _protectedCallReadService.ResolveContactsForReadAsync(departmentId, contacts, ProtectedGrantToken, UserId); + + foreach (var pair in pairs) + { + if (pair.Key == null || pair.Value == null || !summaries.TryGetValue(pair.Key.CallId, out var list)) + continue; + + pair.Value.Contacts = list.Select(x => new CallContactResultData + { + ContactId = x.Contact.ContactId, + Name = SafeContactName(x.Contact), + ContactType = x.Contact.ContactType, + CallContactType = x.CallContactType, + HasPreplan = x.HasPreplan, + AlertNoteCount = x.AlertNoteCount, + HazardCount = x.HazardCount + }).ToList(); + } + } + + /// A redacted name part collapses the whole display name to the REDACTED placeholder. + private static string SafeContactName(Contact contact) + { + if (contact == null) + return null; + + if (contact.FirstName == ProtectedDataEnvelope.RedactionValue || contact.LastName == ProtectedDataEnvelope.RedactionValue || + contact.CompanyName == ProtectedDataEnvelope.RedactionValue) + return ProtectedDataEnvelope.RedactionValue; + + return contact.GetName()?.Trim(); + } + + private static string FirstNonEmpty(params string[] values) + { + return values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); + } + private async Task GetValidatedDestinationPoiAsync(int? destinationPoiId, int? departmentIdOverride = null) { if (!destinationPoiId.HasValue || destinationPoiId.Value <= 0) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ContactFilesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ContactFilesController.cs new file mode 100644 index 000000000..fd1e76f66 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/ContactFilesController.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Helpers; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.ContactFiles; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Files attached to Contacts: site documents, pre-plans, floor plans, site photos and drawings + /// (Contacts plan Phase A). Mirrors CallFiles: metadata lists carry a signed, expiring anonymous + /// download URL; uploads are base64. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class ContactFilesController : V4AuthenticatedApiControllerbase + { + /// Signed-link kind marker; keeps a contact-file link from validating as a call-file link and vice versa. + public const string SignedLinkKind = "c"; + + #region Members and Constructors + private readonly IContactsService _contactsService; + private readonly IDepartmentsService _departmentsService; + private readonly IProtectedReadService _protectedReadService; + private readonly IProtectedWriteService _protectedWriteService; + + public ContactFilesController(IContactsService contactsService, IDepartmentsService departmentsService, + IProtectedReadService protectedReadService, IProtectedWriteService protectedWriteService) + { + _contactsService = contactsService; + _departmentsService = departmentsService; + _protectedReadService = protectedReadService; + _protectedWriteService = protectedWriteService; + } + + /// The caller's Protected Data Grant, when presented (plan section 3.1 step 6). + private string ProtectedGrantToken => Request.Headers[DataProtectionController.GrantHeader].ToString(); + #endregion Members and Constructors + + /// + /// Gets the files attached to a contact + /// + /// Contact to list files for + /// Include the base64 data in the result (metadata only otherwise) + /// Optional type filter (Document = 0, PrePlan = 1, FloorPlan = 2, SitePhoto = 3, SiteDrawing = 4, Other = 5); omit for all + [HttpGet("GetFilesForContact")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Contacts_View)] + public async Task> GetFilesForContact(string contactId, bool includeData = false, int? type = null) + { + var result = new ContactFilesResult(); + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.IsDeleted) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + if (contact.DepartmentId != DepartmentId) + return Unauthorized(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + ContactAttachmentTypes? typeFilter = type.HasValue && Enum.IsDefined(typeof(ContactAttachmentTypes), type.Value) + ? (ContactAttachmentTypes?)type.Value + : null; + + var attachments = await _contactsService.GetContactAttachmentsAsync(contactId, DepartmentId, typeFilter); + + var rows = new List(); + foreach (var meta in attachments) + { + var attachment = meta; + if (includeData) + attachment = await _contactsService.GetContactAttachmentByIdAsync(meta.ContactAttachmentId) ?? meta; + rows.Add(attachment); + } + + // Attended protected read (catalog v12): names decrypt with a valid grant or read as REDACTED; + // includeData additionally decrypts the rgdpb payload through the broker, and a concealed payload + // serializes as null — never ciphertext bytes. + var protectedRead = await _protectedReadService.ResolveContactAttachmentsForReadAsync(DepartmentId, rows, + ProtectedGrantToken, UserId, includeData: includeData); + + foreach (var attachment in rows) + { + var fileData = ConvertContactFileData(attachment, department, includeData); + fileData.IsProtected = protectedRead.IsProtected; + fileData.ProtectedReason = protectedRead.ProtectedReason; + result.Data.Add(fileData); + } + + result.PageSize = result.Data.Count; + result.Status = result.Data.Any() ? ResponseHelper.Success : ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Streams a contact file from a signed link (as issued by GetFilesForContact / GetCallSiteInfo) + /// + /// Signed link token + [HttpGet("GetFile")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetFile(string query) + { + var attachment = await ResolveSignedAttachmentAsync(query); + if (attachment == null) + return NotFound(); + + var contentType = attachment.FileType; + if (string.IsNullOrWhiteSpace(contentType)) + contentType = FileHelper.GetContentTypeByExtension(Path.GetExtension(attachment.FileName ?? string.Empty).ToLowerInvariant()); + if (string.IsNullOrWhiteSpace(contentType)) + contentType = MediaTypeNames.Application.Octet; + + return File(attachment.Data, contentType, attachment.FileName); + } + + /// + /// Existence check for a signed contact file link + /// + /// Signed link token + [HttpHead("GetFile")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetFileHead(string query) + { + var attachment = await ResolveSignedAttachmentAsync(query); + return attachment == null ? NotFound() : Ok(); + } + + /// + /// Attaches a file to a contact + /// + [HttpPost("UploadContactFile")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Contacts_Update)] + public async Task> UploadContactFile([FromBody] UploadContactFileInput input, CancellationToken cancellationToken) + { + var result = new UploadContactFileResult(); + + if (input == null || !ModelState.IsValid) + return BadRequest(); + + var contact = await _contactsService.GetContactByIdAsync(input.ContactId); + if (contact == null || contact.IsDeleted) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + if (contact.DepartmentId != DepartmentId) + return Unauthorized(); + + if (!Enum.IsDefined(typeof(ContactAttachmentTypes), input.Type)) + return BadRequest("Unknown contact file type."); + + byte[] data; + try + { + data = Convert.FromBase64String(input.Data); + } + catch (Exception) + { + return BadRequest("File data must be base64."); + } + + if (data.Length == 0) + return BadRequest("File data is empty."); + + if (data.Length > ContactAttachment.MaxSizeBytes) + return BadRequest($"Attachment is too large, must be smaller than {ContactAttachment.MaxSizeBytes / (1024 * 1024)}MB."); + + // ADP write preflight (plan 3.3): refuse BEFORE inserting the transient plaintext row. + var writePreflight = await _protectedWriteService.PreflightWriteAsync(DepartmentId, ProtectedGrantToken, UserId, false, cancellationToken); + if (!writePreflight.Success) + return Problem(type: writePreflight.Reason, + title: writePreflight.Reason == "broker_unavailable" + ? "Protected storage is temporarily unavailable; the file was not saved." + : "Recent multi-factor verification is required to modify protected data.", + statusCode: writePreflight.Reason == "broker_unavailable" ? StatusCodes.Status503ServiceUnavailable : StatusCodes.Status403Forbidden); + + var attachment = new ContactAttachment + { + ContactId = contact.ContactId, + DepartmentId = DepartmentId, + ContactAttachmentType = input.Type, + Name = string.IsNullOrWhiteSpace(input.Name) ? input.FileName : input.Name, + FileName = input.FileName, + FileType = FileHelper.GetContentTypeByExtension(Path.GetExtension(input.FileName ?? string.Empty).ToLowerInvariant()), + Data = data + }; + + var saved = await _contactsService.SaveContactAttachmentAsync(attachment, UserId, + IpAddressHelper.GetRequestIP(Request, true), $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + + result.Id = saved.ContactAttachmentId.ToString(); + result.PageSize = 0; + result.Status = ResponseHelper.Created; + ResponseHelper.PopulateV4ResponseData(result); + + return CreatedAtAction(nameof(GetFilesForContact), new { contactId = contact.ContactId }, result); + } + + /// + /// Removes a file from a contact + /// + /// Id of the contact file + [HttpDelete("DeleteContactFile")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Contacts_Delete)] + public async Task> DeleteContactFile(int contactFileId, CancellationToken cancellationToken) + { + var result = new DeleteContactFileResult(); + + var deleted = await _contactsService.DeleteContactAttachmentAsync(contactFileId, DepartmentId, UserId, + IpAddressHelper.GetRequestIP(Request, true), $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + + if (!deleted) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + result.PageSize = 0; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + #region Helpers + + private async Task ResolveSignedAttachmentAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return null; + + string decryptedQuery; + try + { + var decodedQuery = Encoding.UTF8.GetString(Convert.FromBase64String(query)); + decryptedQuery = SymmetricEncryption.Decrypt(decodedQuery, Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); + } + catch (Exception) + { + // Malformed/foreign query: value-free not-found, never a 500. + return null; + } + + if (!TryValidateSignedContactFileQuery(decryptedQuery, out var departmentId, out var attachmentId)) + return null; + + var attachment = await _contactsService.GetContactAttachmentByIdAsync(attachmentId); + if (attachment == null || attachment.DepartmentId != departmentId) + return null; + + if (attachment.Data == null || attachment.Data.Length == 0) + return null; + + // ADP: this is an ANONYMOUS signed-link route — it can never carry a grant, so a protected + // department's enveloped file is simply not available here (and ciphertext bytes are never + // served). Attended clients fetch through GetFilesForContact with includeData and their grant. + if (Resgrid.Services.ProtectedReadService.IsBinaryEnveloped(attachment.Data) || + ProtectedDataEnvelope.HasEnvelopePrefix(attachment.FileName) || attachment.IsProtected) + return null; + + return attachment; + } + + /// + /// Validates the decrypted signed-link payload "c|dept|attachmentId|expiresUtcTicks". The leading kind + /// marker means a call-file link never resolves here and a contact-file link never resolves on CallFiles. + /// Expired or malformed reads as not-found. + /// + public static bool TryValidateSignedContactFileQuery(string decryptedQuery, out int departmentId, out int attachmentId) + { + departmentId = 0; + attachmentId = 0; + + if (string.IsNullOrWhiteSpace(decryptedQuery)) + return false; + + var items = decryptedQuery.Split('|'); + if (items.Length != 4 || items[0] != SignedLinkKind) + return false; + + if (!int.TryParse(items[1].Trim(), out departmentId) || departmentId <= 0) + return false; + + if (!int.TryParse(items[2].Trim(), out attachmentId) || attachmentId <= 0) + return false; + + if (!long.TryParse(items[3].Trim(), out var expiresTicks)) + return false; + + return DateTime.UtcNow.Ticks <= expiresTicks; + } + + /// Builds the signed, expiring anonymous download URL for a contact attachment. + public static string BuildSignedUrl(int departmentId, int contactAttachmentId) + { + var expiresTicks = DateTime.UtcNow.AddMinutes(Math.Max(5, Config.SecurityConfig.SignedFileLinkTtlMinutes)).Ticks; + var query = SymmetricEncryption.Encrypt($"{SignedLinkKind}|{departmentId}|{contactAttachmentId}|{expiresTicks}", Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); + + return Config.SystemBehaviorConfig.ResgridApiBaseUrl + "/api/v4/ContactFiles/GetFile?query=" + Convert.ToBase64String(Encoding.UTF8.GetBytes(query)); + } + + public static ContactFileResultData ConvertContactFileData(ContactAttachment attachment, Department department, bool includeData) + { + var file = new ContactFileResultData(); + file.Id = attachment.ContactAttachmentId.ToString(); + file.ContactId = attachment.ContactId; + file.Type = attachment.ContactAttachmentType; + file.TypeName = Enum.IsDefined(typeof(ContactAttachmentTypes), attachment.ContactAttachmentType) + ? ((ContactAttachmentTypes)attachment.ContactAttachmentType).ToString() + : ContactAttachmentTypes.Other.ToString(); + file.Name = attachment.Name; + file.FileName = attachment.FileName; + file.Size = attachment.Size; + file.Mime = string.IsNullOrWhiteSpace(attachment.FileType) + ? FileHelper.GetContentTypeByExtension(Path.GetExtension(attachment.FileName ?? string.Empty)) + : attachment.FileType; + // An enveloped file has no anonymous link: the signed route cannot carry a grant. + file.Url = attachment.IsProtected ? null : BuildSignedUrl(attachment.DepartmentId, attachment.ContactAttachmentId); + file.UserId = attachment.AddedByUserId; + file.RedactedFields = Resgrid.Services.ProtectedReadService.GetRedactedFieldIds(attachment, Resgrid.Services.ProtectedReadService.ContactAttachmentFieldAccessors); + file.Timestamp = attachment.AddedOn.TimeConverterToString(department); + + if (includeData && attachment.Data != null) + file.Data = Convert.ToBase64String(attachment.Data); + + return file; + } + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs index 7177ebdf8..d1793d76c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Model.Providers; @@ -14,6 +14,10 @@ using System; using Resgrid.Model.Helpers; using Resgrid.Web.ServicesCore.Helpers; +using System.Collections.Generic; +using System.Threading; +using System.Net.Mime; +using Resgrid.Web.Helpers; namespace Resgrid.Web.Services.Controllers.v4 { @@ -33,6 +37,7 @@ public class ContactsController : V4AuthenticatedApiControllerbase private readonly IEventAggregator _eventAggregator; private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly IProtectedReadService _protectedReadService; + private readonly IProtectedWriteService _protectedWriteService; public ContactsController( IContactsService contactsService, @@ -41,10 +46,12 @@ public ContactsController( Model.Services.IAuthorizationService authorizationService, IEventAggregator eventAggregator, IUserDefinedFieldsService userDefinedFieldsService, - IProtectedReadService protectedReadService + IProtectedReadService protectedReadService, + IProtectedWriteService protectedWriteService ) { _protectedReadService = protectedReadService; + _protectedWriteService = protectedWriteService; _contactsService = contactsService; _departmentsService = departmentsService; _userProfileService = userProfileService; @@ -273,8 +280,379 @@ public async Task> GetContactNotesByContactId(s return result; } + // ── Pre-plans and hazards (Contacts plan Phase A, A5) ───────────────────── + + /// + /// Gets the pre-incident plan for a contact (with its hazards). Data is null when the contact has none. + /// + /// Id of the contact + [HttpGet("GetContactPreplan")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Contacts_View)] + public async Task> GetContactPreplan(string contactId) + { + var result = new ContactPreplanResult(); + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.IsDeleted) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + if (contact.DepartmentId != DepartmentId) + return Unauthorized(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var preplan = await _contactsService.GetPreplanByContactIdAsync(contactId, DepartmentId); + + if (preplan != null) + { + // Attended protected read (catalog v12): pre-plan and hazard text decrypt with a valid grant + // or read as REDACTED — never an envelope. + var preplanRead = await _protectedReadService.ResolveContactPreplansForReadAsync(DepartmentId, new[] { preplan }, ProtectedGrantToken, UserId); + var hazardRead = await _protectedReadService.ResolveContactPreplanHazardsForReadAsync(DepartmentId, preplan.Hazards, ProtectedGrantToken, UserId); + + result.Data = ConvertPreplanData(preplan, department); + ApplyProtection(result.Data, preplanRead, hazardRead); + result.PageSize = 1; + } + else + { + result.PageSize = 0; + } + + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Creates or replaces the pre-incident plan for a contact + /// + [HttpPost("SaveContactPreplan")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Contacts_Update)] + public async Task> SaveContactPreplan([FromBody] SaveContactPreplanInput input, CancellationToken cancellationToken) + { + var result = new SaveContactPreplanResult(); + + if (input == null || !ModelState.IsValid) + return BadRequest(); + + var contact = await _contactsService.GetContactByIdAsync(input.ContactId); + if (contact == null || contact.IsDeleted) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + if (contact.DepartmentId != DepartmentId) + return Unauthorized(); + + if (!Enum.IsDefined(typeof(ContactPreplanConstructionTypes), input.ConstructionType) || + !Enum.IsDefined(typeof(ContactPreplanRoofTypes), input.RoofType) || + !Enum.IsDefined(typeof(ContactPreplanOccupancyTypes), input.OccupancyType)) + return BadRequest("Unknown construction, roof or occupancy type."); + + // ADP write preflight (plan 3.3): an attended caller in a protected department needs a current grant + // BEFORE the transient plaintext row is inserted; the service's write net then envelopes it. + var writePreflight = await _protectedWriteService.PreflightWriteAsync(DepartmentId, ProtectedGrantToken, UserId, false, cancellationToken); + if (!writePreflight.Success) + return ProtectedWriteProblem(writePreflight); + + var existing = await _contactsService.GetPreplanByContactIdAsync(contact.ContactId, DepartmentId); + + var preplan = new ContactPreplan + { + ContactId = contact.ContactId, + DepartmentId = DepartmentId, + ConstructionType = input.ConstructionType, + RoofType = input.RoofType, + OccupancyType = input.OccupancyType, + OccupancyNotes = input.OccupancyNotes, + OccupancyHours = input.OccupancyHours, + OccupantLoad = input.OccupantLoad, + HasOccupantsNeedingAssistance = input.HasOccupantsNeedingAssistance, + OccupantsNeedingAssistanceNotes = input.OccupantsNeedingAssistanceNotes, + GasShutoffLocation = input.GasShutoffLocation, + ElectricShutoffLocation = input.ElectricShutoffLocation, + WaterShutoffLocation = input.WaterShutoffLocation, + UtilityNotes = input.UtilityNotes, + KnoxBoxLocation = input.KnoxBoxLocation, + GateCode = input.GateCode, + AlarmPanelLocation = input.AlarmPanelLocation, + AlarmCompany = input.AlarmCompany, + AlarmCompanyPhone = input.AlarmCompanyPhone, + AccessNotes = input.AccessNotes, + NearestHydrantLocation = input.NearestHydrantLocation, + RequiredFireFlowGpm = input.RequiredFireFlowGpm, + WaterSupplyNotes = input.WaterSupplyNotes, + EmergencyContactName = input.EmergencyContactName, + EmergencyContactPhone = input.EmergencyContactPhone, + SecondaryContactName = input.SecondaryContactName, + SecondaryContactPhone = input.SecondaryContactPhone, + HazmatOnSite = input.HazmatOnSite, + GeneralHazardNotes = input.GeneralHazardNotes, + TacticalSummary = input.TacticalSummary, + NextReviewDue = input.NextReviewDueUtc, + LastReviewedOn = existing?.LastReviewedOn, + ReviewedByUserId = existing?.ReviewedByUserId + }; + + if (input.MarkReviewed) + { + preplan.LastReviewedOn = DateTime.UtcNow; + preplan.ReviewedByUserId = UserId; + } + + ContactPreplan saved; + try + { + saved = await _contactsService.SavePreplanAsync(preplan, UserId, IpAddressHelper.GetRequestIP(Request, true), + $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + } + catch (InvalidOperationException ex) when (ex.Message == IContactPreplanOwnershipGate.RecordsOwnedReason) + { + // RMS-5 write cutover: the occupancy master owns this pre-plan now; edit it under Records. + return Problem(statusCode: StatusCodes.Status409Conflict, title: "Pre-plans for this department are owned by Records occupancies.", type: "contacts_preplan_records_owned"); + } + + result.Id = saved.ContactPreplanId; + result.PageSize = 0; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Removes the pre-incident plan (and its hazards) from a contact + /// + /// Id of the contact + [HttpDelete("DeleteContactPreplan")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Contacts_Delete)] + public async Task> DeleteContactPreplan(string contactId, CancellationToken cancellationToken) + { + var result = new DeleteContactPreplanResult(); + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.DepartmentId != DepartmentId) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + bool deleted; + try + { + deleted = await _contactsService.DeletePreplanAsync(contactId, DepartmentId, UserId, IpAddressHelper.GetRequestIP(Request, true), + $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + } + catch (InvalidOperationException ex) when (ex.Message == IContactPreplanOwnershipGate.RecordsOwnedReason) + { + // RMS-5 write cutover: the occupancy master owns this pre-plan now; edit it under Records. + return Problem(statusCode: StatusCodes.Status409Conflict, title: "Pre-plans for this department are owned by Records occupancies.", type: "contacts_preplan_records_owned"); + } + + if (!deleted) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + result.PageSize = 0; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Gets the premise hazards for a contact, most severe first + /// + /// Id of the contact + [HttpGet("GetContactHazards")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Contacts_View)] + public async Task> GetContactHazards(string contactId) + { + var result = new ContactHazardsResult(); + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.IsDeleted) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + if (contact.DepartmentId != DepartmentId) + return Unauthorized(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var hazards = await _contactsService.GetHazardsByContactIdAsync(contactId, DepartmentId); + + // Attended protected read (catalog v12). + var hazardRead = await _protectedReadService.ResolveContactPreplanHazardsForReadAsync(DepartmentId, hazards, ProtectedGrantToken, UserId); + + foreach (var hazard in hazards) + { + var data = ConvertHazardData(hazard, department); + data.IsProtected = hazardRead.IsProtected; + data.ProtectedReason = hazardRead.ProtectedReason; + result.Data.Add(data); + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Creates (no id) or updates (with id) a premise hazard on a contact + /// + [HttpPost("SaveContactHazard")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Contacts_Update)] + public async Task> SaveContactHazard([FromBody] SaveContactHazardInput input, CancellationToken cancellationToken) + { + var result = new SaveContactHazardResult(); + + if (input == null || !ModelState.IsValid) + return BadRequest(); + + var contact = await _contactsService.GetContactByIdAsync(input.ContactId); + if (contact == null || contact.IsDeleted) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + if (contact.DepartmentId != DepartmentId) + return Unauthorized(); + + if (!Enum.IsDefined(typeof(ContactPreplanHazardTypes), input.HazardType) || + !Enum.IsDefined(typeof(ContactPreplanHazardSeverities), input.Severity)) + return BadRequest("Unknown hazard type or severity."); + + // ADP write preflight (plan 3.3); see SaveContactPreplan. + var writePreflight = await _protectedWriteService.PreflightWriteAsync(DepartmentId, ProtectedGrantToken, UserId, false, cancellationToken); + if (!writePreflight.Success) + return ProtectedWriteProblem(writePreflight); + + var hazard = new ContactPreplanHazard + { + ContactPreplanHazardId = string.IsNullOrWhiteSpace(input.ContactPreplanHazardId) ? null : input.ContactPreplanHazardId, + ContactId = contact.ContactId, + DepartmentId = DepartmentId, + HazardType = input.HazardType, + Severity = input.Severity, + Title = input.Title, + Description = input.Description, + LocationDescription = input.LocationDescription, + GpsCoordinates = input.GpsCoordinates, + ShouldAlert = input.ShouldAlert + }; + + ContactPreplanHazard saved; + try + { + saved = await _contactsService.SaveHazardAsync(hazard, UserId, IpAddressHelper.GetRequestIP(Request, true), + $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + } + catch (InvalidOperationException ex) when (ex.Message == IContactPreplanOwnershipGate.RecordsOwnedReason) + { + // RMS-5 write cutover: the occupancy master owns this pre-plan now; edit it under Records. + return Problem(statusCode: StatusCodes.Status409Conflict, title: "Pre-plans for this department are owned by Records occupancies.", type: "contacts_preplan_records_owned"); + } + catch (InvalidOperationException) + { + // The hazard id belongs to another contact/department: value-free not found. + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + result.Id = saved.ContactPreplanHazardId; + result.PageSize = 0; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Removes a premise hazard from a contact + /// + /// Id of the hazard + [HttpDelete("DeleteContactHazard")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Contacts_Update)] + public async Task> DeleteContactHazard(string contactPreplanHazardId, CancellationToken cancellationToken) + { + var result = new DeleteContactHazardResult(); + + bool deleted; + try + { + deleted = await _contactsService.DeleteHazardAsync(contactPreplanHazardId, DepartmentId, UserId, IpAddressHelper.GetRequestIP(Request, true), + $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + } + catch (InvalidOperationException ex) when (ex.Message == IContactPreplanOwnershipGate.RecordsOwnedReason) + { + // RMS-5 write cutover: the occupancy master owns this pre-plan now; edit it under Records. + return Problem(statusCode: StatusCodes.Status409Conflict, title: "Pre-plans for this department are owned by Records occupancies.", type: "contacts_preplan_records_owned"); + } + + if (!deleted) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + result.PageSize = 0; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + // ── Private helpers ────────────────────────────────────────────────────── + /// The caller's Protected Data Grant, when presented (plan section 3.1 step 6). + private string ProtectedGrantToken => Request.Headers[DataProtectionController.GrantHeader].ToString(); + + /// Maps a blocked protected write to its value-free problem response (plan 3.3/19.2). + private ObjectResult ProtectedWriteProblem(ProtectedWriteResult write) => + Problem(type: write.Reason, + title: write.Reason == "broker_unavailable" + ? "Protected storage is temporarily unavailable; the change was not saved." + : "Recent multi-factor verification is required to modify protected data.", + statusCode: write.Reason == "broker_unavailable" ? StatusCodes.Status503ServiceUnavailable : StatusCodes.Status403Forbidden); + + /// Stamps the batch-level protection metadata onto a pre-plan DTO and its hazards (per-row redaction ids come from the converters). + public static void ApplyProtection(ContactPreplanData data, ProtectedReadResult preplanRead, ProtectedReadResult hazardRead) + { + if (data == null) + return; + + data.IsProtected = (preplanRead?.IsProtected ?? false) || (hazardRead?.IsProtected ?? false); + data.ProtectedReason = preplanRead?.ProtectedReason ?? hazardRead?.ProtectedReason; + + foreach (var hazard in data.Hazards) + { + hazard.IsProtected = hazardRead?.IsProtected ?? false; + hazard.ProtectedReason = hazardRead?.ProtectedReason; + } + } + /// /// Returns true if the current caller holds a group-admin claim for any group. /// @@ -404,5 +782,108 @@ public static ContactNoteResultData ConvertContactNoteData(ContactNote contactNo return conNote; } + + public static ContactPreplanData ConvertPreplanData(ContactPreplan preplan, Department department) + { + var data = new ContactPreplanData(); + data.ContactPreplanId = preplan.ContactPreplanId; + data.ContactId = preplan.ContactId; + data.ConstructionType = preplan.ConstructionType; + data.ConstructionTypeName = EnumName(preplan.ConstructionType); + data.RoofType = preplan.RoofType; + data.RoofTypeName = EnumName(preplan.RoofType); + data.OccupancyType = preplan.OccupancyType; + data.OccupancyTypeName = EnumName(preplan.OccupancyType); + data.OccupancyNotes = preplan.OccupancyNotes; + data.OccupancyHours = preplan.OccupancyHours; + data.OccupantLoad = preplan.OccupantLoad; + data.HasOccupantsNeedingAssistance = preplan.HasOccupantsNeedingAssistance; + data.OccupantsNeedingAssistanceNotes = preplan.OccupantsNeedingAssistanceNotes; + data.GasShutoffLocation = preplan.GasShutoffLocation; + data.ElectricShutoffLocation = preplan.ElectricShutoffLocation; + data.WaterShutoffLocation = preplan.WaterShutoffLocation; + data.UtilityNotes = preplan.UtilityNotes; + data.KnoxBoxLocation = preplan.KnoxBoxLocation; + data.GateCode = preplan.GateCode; + data.AlarmPanelLocation = preplan.AlarmPanelLocation; + data.AlarmCompany = preplan.AlarmCompany; + data.AlarmCompanyPhone = preplan.AlarmCompanyPhone; + data.AccessNotes = preplan.AccessNotes; + data.NearestHydrantLocation = preplan.NearestHydrantLocation; + data.RequiredFireFlowGpm = preplan.RequiredFireFlowGpm; + data.WaterSupplyNotes = preplan.WaterSupplyNotes; + data.EmergencyContactName = preplan.EmergencyContactName; + data.EmergencyContactPhone = preplan.EmergencyContactPhone; + data.SecondaryContactName = preplan.SecondaryContactName; + data.SecondaryContactPhone = preplan.SecondaryContactPhone; + data.HazmatOnSite = preplan.HazmatOnSite; + data.GeneralHazardNotes = preplan.GeneralHazardNotes; + data.TacticalSummary = preplan.TacticalSummary; + + data.LastReviewedOnUtc = preplan.LastReviewedOn; + if (preplan.LastReviewedOn.HasValue) + data.LastReviewedOn = preplan.LastReviewedOn.Value.FormatForDepartment(department); + data.ReviewedByUserId = preplan.ReviewedByUserId; + + data.NextReviewDueUtc = preplan.NextReviewDue; + if (preplan.NextReviewDue.HasValue) + data.NextReviewDue = preplan.NextReviewDue.Value.FormatForDepartment(department); + data.IsReviewOverdue = preplan.IsReviewOverdue(DateTime.UtcNow); + + data.AddedOnUtc = preplan.AddedOn; + data.AddedOn = preplan.AddedOn.FormatForDepartment(department); + data.AddedByUserId = preplan.AddedByUserId; + + data.EditedOnUtc = preplan.EditedOn; + if (preplan.EditedOn.HasValue) + data.EditedOn = preplan.EditedOn.Value.FormatForDepartment(department); + data.EditedByUserId = preplan.EditedByUserId; + + if (preplan.Hazards != null) + { + foreach (var hazard in preplan.Hazards) + data.Hazards.Add(ConvertHazardData(hazard, department)); + } + + // Per ROW, not the batch union (ProtectedRedactedFieldIdsTests): only the fields this plan actually had withheld. + data.RedactedFields = Resgrid.Services.ProtectedReadService.GetRedactedFieldIds(preplan, Resgrid.Services.ProtectedReadService.ContactPreplanFieldAccessors); + + return data; + } + + public static ContactHazardData ConvertHazardData(ContactPreplanHazard hazard, Department department) + { + var data = new ContactHazardData(); + data.ContactPreplanHazardId = hazard.ContactPreplanHazardId; + data.ContactPreplanId = hazard.ContactPreplanId; + data.ContactId = hazard.ContactId; + data.HazardType = hazard.HazardType; + data.HazardTypeName = EnumName(hazard.HazardType); + data.Severity = hazard.Severity; + data.SeverityName = EnumName(hazard.Severity); + data.Title = hazard.Title; + data.Description = hazard.Description; + data.LocationDescription = hazard.LocationDescription; + data.GpsCoordinates = hazard.GpsCoordinates; + data.ShouldAlert = hazard.ShouldAlert; + + data.AddedOnUtc = hazard.AddedOn; + data.AddedOn = hazard.AddedOn.FormatForDepartment(department); + data.AddedByUserId = hazard.AddedByUserId; + + data.EditedOnUtc = hazard.EditedOn; + if (hazard.EditedOn.HasValue) + data.EditedOn = hazard.EditedOn.Value.FormatForDepartment(department); + data.EditedByUserId = hazard.EditedByUserId; + + data.RedactedFields = Resgrid.Services.ProtectedReadService.GetRedactedFieldIds(hazard, Resgrid.Services.ProtectedReadService.ContactPreplanHazardFieldAccessors); + + return data; + } + + private static string EnumName(int value) where TEnum : struct, Enum + { + return Enum.IsDefined(typeof(TEnum), value) ? Enum.GetName(typeof(TEnum), value) : value.ToString(); + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs index 49d26619b..e277e558c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs @@ -253,7 +253,7 @@ public async Task> Telemetry([FromBody] OriginClient = Origin(input.OriginClient), AppVersion = AppVersion(input.AppVersion), ClientCapability = input.ClientCapability, - Events = input.Events.Select(e => new RecordFieldRolloutInput + Events = input.Events.Where(e => e != null).Select(e => new RecordFieldRolloutInput { EventType = e.EventType, Outcome = e.Outcome, DefinitionKey = e.DefinitionKey, DefinitionVersion = e.DefinitionVersion, RecordId = e.RecordId, DurationMs = e.DurationMs, ItemCount = e.ItemCount, OccurredOn = e.OccurredOn diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentConnectorsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentConnectorsController.cs index f8cd73041..8f0f1fca6 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentConnectorsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentConnectorsController.cs @@ -239,7 +239,9 @@ public async Task> Reconcilia [AllowAnonymous] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] public async Task> Inbound(string connectorId, CancellationToken cancellationToken) { @@ -267,7 +269,10 @@ public async Task> Inbound(s ResponseHelper.PopulateV4ResponseData(result); return Ok(result); } + // A wrong token is 401 here rather than the 403 an authenticated caller gets; a malformed feed, a spent + // hourly limit or a connector that is not ready map exactly as they do on every other action. catch (UnauthorizedAccessException) { return Unauthorized(); } + catch (Exception ex) { return Fail(ex); } } #region Helpers diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordHydrantsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordHydrantsController.cs new file mode 100644 index 000000000..15b9999ce --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordHydrantsController.cs @@ -0,0 +1,142 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.Services.Models.v4; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// RMS-5 hydrants and water sources (RMS plan section 4.3): flow tests, maintenance, service state, CSV import and the response-map layer. + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class RecordHydrantsController : RecordsPreventionApiControllerBase + { + private readonly IRecordsHydrantsService _hydrants; + + public RecordHydrantsController(IRecordsHydrantsService hydrants, IRecordsCutoverService cutover) : base(cutover) + { + _hydrants = hydrants; + } + + [HttpGet("List")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> List() + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new HydrantsResult { Data = (await _hydrants.ListAsync(DepartmentId, UserId)).Select(RecordsRms5ApiMapper.ToHydrant).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Get")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Get(string id) + { + if (!await FlagOnAsync()) return NotFound(); + try { var a = await _hydrants.GetAsync(DepartmentId, UserId, id); if (a == null) return NotFound(); return Ok(Done(new HydrantResult { Data = RecordsRms5ApiMapper.ToHydrantAggregate(a), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Save")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Save([FromBody] HydrantInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try + { + var hydrant = await _hydrants.SaveAsync(DepartmentId, UserId, new RmsHydrant { RmsHydrantId = input.HydrantId, HydrantNumber = input.HydrantNumber, Type = input.Type, Latitude = input.Latitude, Longitude = input.Longitude, AddressText = input.AddressText, OwnerKind = input.OwnerKind, OwnerName = input.OwnerName, MainSizeInches = input.MainSizeInches, FlowGpm = input.FlowGpm, StaticPressurePsi = input.StaticPressurePsi, ResidualPressurePsi = input.ResidualPressurePsi, Notes = input.Notes, PoiId = input.PoiId }, cancellationToken); + return Ok(Done(new HydrantSavedResult { Data = RecordsRms5ApiMapper.ToHydrant(hydrant), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("Delete")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Delete(string id, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { await _hydrants.DeleteAsync(DepartmentId, UserId, id, cancellationToken); return Ok(Done(new StandardApiResponseV4Base())); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("SetServiceState")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> SetServiceState([FromBody] HydrantServiceStateInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new HydrantSavedResult { Data = RecordsRms5ApiMapper.ToHydrant(await _hydrants.SetServiceStateAsync(DepartmentId, UserId, input.HydrantId, input.InService, input.Reason, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("RecordFlowTest")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> RecordFlowTest([FromBody] HydrantFlowTestInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try + { + var test = await _hydrants.RecordFlowTestAsync(DepartmentId, UserId, new RmsHydrantFlowTest { RmsHydrantId = input.HydrantId, TestedOn = input.TestedOn ?? default, StaticPressurePsi = input.StaticPressurePsi, ResidualPressurePsi = input.ResidualPressurePsi, PitotPressurePsi = input.PitotPressurePsi, OutletDiameterInches = input.OutletDiameterInches, Coefficient = input.Coefficient, Notes = input.Notes }, cancellationToken); + return Ok(Done(new HydrantFlowTestResult { Data = RecordsRms5ApiMapper.ToFlowTest(test), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("RecordMaintenance")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> RecordMaintenance([FromBody] HydrantMaintenanceInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try + { + var row = await _hydrants.RecordMaintenanceAsync(DepartmentId, UserId, new RmsHydrantMaintenance { RmsHydrantId = input.HydrantId, PerformedOn = input.PerformedOn ?? default, Kind = input.Kind, Notes = input.Notes, ReturnedToService = input.ReturnedToService }, cancellationToken); + return Ok(Done(new HydrantMaintenanceResult { Data = RecordsRms5ApiMapper.ToMaintenance(row), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + /// CSV import (number, latitude, longitude, type, address, main_size, flow_gpm, owner). + [HttpPost("Import")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Import([FromBody] HydrantImportInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new HydrantImportResultData { Data = await _hydrants.ImportCsvAsync(DepartmentId, UserId, input?.Csv, cancellationToken), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + /// Map layer for the response map and the apps; bounded to a viewport when bounds are given. + [HttpGet("MapLayer")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> MapLayer(decimal? minLat = null, decimal? maxLat = null, decimal? minLon = null, decimal? maxLon = null) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new HydrantMapLayerResult { Data = await _hydrants.GetMapLayerAsync(DepartmentId, UserId, minLat, maxLat, minLon, maxLon) }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Nearest")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Nearest(decimal latitude, decimal longitude, int take = 5, double maxMeters = 1600) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + if (!await _hydrants.IsModuleEnabledAsync(DepartmentId)) return NotFound(); + var r = new HydrantsResult { Data = (await _hydrants.GetNearestAsync(DepartmentId, latitude, longitude, take, maxMeters)).Select(RecordsRms5ApiMapper.ToHydrant).ToList() }; r.PageSize = r.Data.Count; + return Ok(Done(r)); + } + catch (Exception ex) { return Fail(ex); } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordInspectionsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordInspectionsController.cs new file mode 100644 index 000000000..970f72354 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordInspectionsController.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.Services.Models.v4; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// RMS-5 inspection programs, adopted code sets, inspections, violations and re-inspection (RMS plan section 4.3). + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class RecordInspectionsController : RecordsPreventionApiControllerBase + { + private readonly IRecordsInspectionsService _inspections; + + public RecordInspectionsController(IRecordsInspectionsService inspections, IRecordsCutoverService cutover) : base(cutover) + { + _inspections = inspections; + } + + #region Code sets and programs + + [HttpGet("CodeSets")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> CodeSets(bool includeInactive = false) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new CodeSetsResult { Data = (await _inspections.GetCodeSetsAsync(DepartmentId, UserId, includeInactive)).Select(RecordsRms5ApiMapper.ToCodeSet).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("SaveCodeSet")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> SaveCodeSet([FromBody] CodeSetData input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new CodeSetResult { Data = RecordsRms5ApiMapper.ToCodeSet(await _inspections.SaveCodeSetAsync(DepartmentId, UserId, new RmsCodeSet { RmsCodeSetId = input.CodeSetId, Name = input.Name, Edition = input.Edition, Jurisdiction = input.Jurisdiction, IsActive = input.IsActive }, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("CodeSections")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> CodeSections(string codeSetId) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new CodeSectionsResult { Data = (await _inspections.GetCodeSectionsAsync(DepartmentId, UserId, codeSetId)).Select(RecordsRms5ApiMapper.ToCodeSection).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("SaveCodeSection")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> SaveCodeSection([FromBody] CodeSectionData input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new CodeSectionResult { Data = RecordsRms5ApiMapper.ToCodeSection(await _inspections.SaveCodeSectionAsync(DepartmentId, UserId, new RmsCodeSection { RmsCodeSectionId = input.CodeSectionId, RmsCodeSetId = input.CodeSetId, SectionNumber = input.SectionNumber, Title = input.Title, Text = input.Text, DefaultSeverity = input.DefaultSeverity, DefaultCorrectionDays = input.DefaultCorrectionDays }, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + /// CSV import of code sections: section,title,text,severity,days. + [HttpPost("ImportCodeSections")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> ImportCodeSections(string codeSetId, [FromBody] HydrantImportInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { var created = await _inspections.ImportCodeSectionsAsync(DepartmentId, UserId, codeSetId, input?.Csv, cancellationToken); return Ok(Done(new StandardApiResponseV4Base { PageSize = created })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Programs")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Programs(bool includeInactive = false) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new InspectionProgramsResult { Data = (await _inspections.GetProgramsAsync(DepartmentId, UserId, includeInactive)).Select(RecordsRms5ApiMapper.ToProgram).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("SaveProgram")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> SaveProgram([FromBody] InspectionProgramInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try + { + var program = await _inspections.SaveProgramAsync(DepartmentId, UserId, new RmsInspectionProgram { RmsInspectionProgramId = input.ProgramId, Name = input.Name, Description = input.Description, OccupancyTypesCsv = input.OccupancyTypesCsv, FrequencyMonths = input.FrequencyMonths, RmsCodeSetId = input.CodeSetId, IsActive = input.IsActive }, input.Checklist, cancellationToken); + return Ok(Done(new InspectionProgramResult { Data = RecordsRms5ApiMapper.ToProgram(program), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + #endregion + + #region Inspections + + [HttpGet("List")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> List(string occupancyId = null, string programId = null, [FromQuery] List states = null, string inspectorUserId = null, DateTime? scheduledBefore = null, int skip = 0, int take = 50) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var query = new RmsInspectionQuery { OccupancyId = occupancyId, ProgramId = programId, States = states, InspectorUserId = inspectorUserId, ScheduledBefore = scheduledBefore, Skip = skip, Take = take }; + var r = new InspectionsResult { Data = (await _inspections.ListAsync(DepartmentId, UserId, query)).Select(RecordsRms5ApiMapper.ToInspection).ToList(), TotalCount = await _inspections.CountAsync(DepartmentId, UserId, query) }; + r.PageSize = r.Data.Count; + return Ok(Done(r)); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Get")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Get(string id) + { + if (!await FlagOnAsync()) return NotFound(); + try { var a = await _inspections.GetAsync(DepartmentId, UserId, id); if (a == null) return NotFound(); return Ok(Done(new InspectionResult { Data = RecordsRms5ApiMapper.ToInspectionAggregate(a), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Schedule")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Schedule([FromBody] ScheduleInspectionInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new InspectionSavedResult { Data = RecordsRms5ApiMapper.ToInspection(await _inspections.ScheduleAsync(DepartmentId, UserId, input.OccupancyId, input.ProgramId, input.ScheduledOn ?? DateTime.UtcNow, input.InspectorUserId, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Start")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Start(string id, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new InspectionSavedResult { Data = RecordsRms5ApiMapper.ToInspection(await _inspections.StartAsync(DepartmentId, UserId, id, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Complete")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Complete([FromBody] CompleteInspectionInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new InspectionResult { Data = RecordsRms5ApiMapper.ToInspectionAggregate(await _inspections.CompleteAsync(DepartmentId, UserId, input.InspectionId, input.Items, input.Notes, input.SignatureName, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("ScheduleReinspection")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> ScheduleReinspection(string id, DateTime? scheduledOn = null, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new InspectionSavedResult { Data = RecordsRms5ApiMapper.ToInspection(await _inspections.ScheduleReinspectionAsync(DepartmentId, UserId, id, scheduledOn ?? DateTime.UtcNow.AddDays(30), cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Close")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Close(string id, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new InspectionSavedResult { Data = RecordsRms5ApiMapper.ToInspection(await _inspections.CloseAsync(DepartmentId, UserId, id, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Cancel")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Cancel(string id, string reason = null, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new InspectionSavedResult { Data = RecordsRms5ApiMapper.ToInspection(await _inspections.CancelAsync(DepartmentId, UserId, id, reason, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("IssueNotice")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> IssueNotice(string id, string noticeReference = null, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new InspectionSavedResult { Data = RecordsRms5ApiMapper.ToInspection(await _inspections.IssueNoticeAsync(DepartmentId, UserId, id, noticeReference, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + #endregion + + #region Violations + + [HttpGet("Violations")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Violations(string occupancyId = null, bool openOnly = true, int take = 200) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var rows = string.IsNullOrWhiteSpace(occupancyId) ? await _inspections.GetOpenViolationsAsync(DepartmentId, UserId, take) : await _inspections.GetViolationsForOccupancyAsync(DepartmentId, UserId, occupancyId, openOnly); + var r = new ViolationsResult { Data = rows.Select(RecordsRms5ApiMapper.ToViolation).ToList() }; r.PageSize = r.Data.Count; + return Ok(Done(r)); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("SaveViolation")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> SaveViolation([FromBody] ViolationInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new ViolationResult { Data = RecordsRms5ApiMapper.ToViolation(await _inspections.SaveViolationAsync(DepartmentId, UserId, new RmsViolation { RmsViolationId = input.ViolationId, RmsInspectionId = input.InspectionId, RmsCodeSetId = input.CodeSetId, RmsCodeSectionId = input.CodeSectionId, Description = input.Description, Severity = input.Severity, CorrectiveAction = input.CorrectiveAction, DueOn = input.DueOn }, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("TransitionViolation")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> TransitionViolation([FromBody] ViolationTransitionInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new ViolationResult { Data = RecordsRms5ApiMapper.ToViolation(await _inspections.TransitionViolationAsync(DepartmentId, UserId, input.ViolationId, (RmsViolationState)input.TargetState, input.Note, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordInvestigationsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordInvestigationsController.cs new file mode 100644 index 000000000..868abb3da --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordInvestigationsController.cs @@ -0,0 +1,231 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.Services.Models.v4; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// RMS-5 investigation cases (RMS plan section 4.4). RecordRestricted_View is the policy; the service enforces + /// case membership on top of it and audits every read, so a department administrator without a membership sees + /// nothing here. Findings never touch the linked incident revision. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Authorize(Policy = ResgridResources.RecordRestricted_View)] + public class RecordInvestigationsController : RecordsPreventionApiControllerBase + { + private readonly IRecordsInvestigationsService _investigations; + + public RecordInvestigationsController(IRecordsInvestigationsService investigations, IRecordsCutoverService cutover) : base(cutover) + { + _investigations = investigations; + } + + private InvestigationCaseSavedResult Wrap(RmsInvestigationCase c) => Done(new InvestigationCaseSavedResult { Data = RecordsRms5ApiMapper.ToCase(c), PageSize = 1 }); + + [HttpGet("MyCases")] + public async Task> MyCases(bool includeClosed = false) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new InvestigationCasesResult { Data = (await _investigations.ListMyCasesAsync(DepartmentId, UserId, includeClosed)).Select(RecordsRms5ApiMapper.ToCase).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Get")] + public async Task> Get(string id) + { + if (!await FlagOnAsync()) return NotFound(); + try { var a = await _investigations.GetAsync(DepartmentId, UserId, id, IpAddressHelper.GetRequestIP(Request, true)); if (a == null) return NotFound(); return Ok(Done(new InvestigationCaseResult { Data = RecordsRms5ApiMapper.ToCaseAggregate(a), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Open")] + public async Task> Open([FromBody] OpenCaseInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Wrap(await _investigations.OpenAsync(DepartmentId, UserId, input.Title, input.OccupancyId, input.CallId, input.IncidentSummary, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Update")] + public async Task> Update([FromBody] UpdateCaseInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Wrap(await _investigations.UpdateAsync(DepartmentId, UserId, new RmsInvestigationCase { RmsInvestigationCaseId = input.CaseId, RowVersion = input.RowVersion, Title = input.Title, IncidentSummary = input.IncidentSummary, RmsOccupancyId = input.OccupancyId, CallId = input.CallId, LeadInvestigatorUserId = input.LeadInvestigatorUserId }, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("AddMember")] + public async Task> AddMember([FromBody] CaseMemberInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new CaseMemberResult { Data = RecordsRms5ApiMapper.ToMember(await _investigations.AddMemberAsync(DepartmentId, UserId, input.CaseId, input.UserId, (RmsInvestigationRole)input.Role, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("RemoveMember")] + public async Task> RemoveMember(string caseId, string memberId, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { await _investigations.RemoveMemberAsync(DepartmentId, UserId, caseId, memberId, cancellationToken); return Ok(Done(new StandardApiResponseV4Base())); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("LinkIncident")] + public async Task> LinkIncident(string caseId, string recordId, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new CaseIncidentResult { Data = RecordsRms5ApiMapper.ToIncident(await _investigations.LinkIncidentAsync(DepartmentId, UserId, caseId, recordId, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("UnlinkIncident")] + public async Task> UnlinkIncident(string caseId, string linkId, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { await _investigations.UnlinkIncidentAsync(DepartmentId, UserId, caseId, linkId, cancellationToken); return Ok(Done(new StandardApiResponseV4Base())); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("SaveNote")] + public async Task> SaveNote([FromBody] CaseNoteInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try + { + var note = string.IsNullOrWhiteSpace(input.NoteId) + ? await _investigations.AddNoteAsync(DepartmentId, UserId, input.CaseId, (RmsInvestigationNoteKind)input.Kind, input.OccurredOn ?? DateTime.UtcNow, input.Subject, input.Body, cancellationToken) + : await _investigations.UpdateNoteAsync(DepartmentId, UserId, input.NoteId, input.Subject, input.Body, cancellationToken); + return Ok(Done(new InvestigationNoteResult { Data = RecordsRms5ApiMapper.ToNote(note), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("AddEvidence")] + public async Task> AddEvidence([FromBody] CaseEvidenceInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try + { + var evidence = await _investigations.AddEvidenceAsync(DepartmentId, UserId, input.CaseId, new RmsInvestigationEvidence { Kind = input.Kind, Description = input.Description, CollectedOn = input.CollectedOn ?? default, CollectedByUserId = input.CollectedByUserId, CollectedFrom = input.CollectedFrom, StorageLocation = input.StorageLocation }, cancellationToken); + return Ok(Done(new InvestigationEvidenceResult { Data = RecordsRms5ApiMapper.ToEvidence(evidence), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("TransferCustody")] + public async Task> TransferCustody([FromBody] CustodyTransferInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new CustodyResult { Data = RecordsRms5ApiMapper.ToCustody(await _investigations.TransferCustodyAsync(DepartmentId, UserId, input.EvidenceId, input.ToUserId, input.ToExternal, input.Reason, (RmsEvidenceState)input.ResultingState, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("CustodyChain")] + public async Task> CustodyChain(string evidenceId) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new CustodyChainResult { Data = (await _investigations.GetCustodyChainAsync(DepartmentId, UserId, evidenceId)).Select(RecordsRms5ApiMapper.ToCustody).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("AddReferral")] + public async Task> AddReferral([FromBody] ReferralInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new ReferralResult { Data = RecordsRms5ApiMapper.ToReferral(await _investigations.AddReferralAsync(DepartmentId, UserId, input.CaseId, input.Agency, input.Reason, input.ReferenceNumber, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("UpdateReferralState")] + public async Task> UpdateReferralState(string referralId, int state, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new ReferralResult { Data = RecordsRms5ApiMapper.ToReferral(await _investigations.UpdateReferralStateAsync(DepartmentId, UserId, referralId, (RmsReferralState)state, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("RecordFindings")] + public async Task> RecordFindings([FromBody] FindingsInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Wrap(await _investigations.RecordFindingsAsync(DepartmentId, UserId, input.CaseId, (RmsFireCauseClassification)input.Classification, input.CauseDetail, input.OriginDescription, input.Findings, input.RecommendsIncidentAmendment, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("ApproveFindings")] + public async Task> ApproveFindings(string caseId, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Wrap(await _investigations.ApproveFindingsAsync(DepartmentId, UserId, caseId, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("ReturnFindings")] + public async Task> ReturnFindings([FromBody] CaseReasonInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Wrap(await _investigations.ReturnFindingsAsync(DepartmentId, UserId, input.CaseId, input.Reason, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Close")] + public async Task> Close([FromBody] CaseReasonInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Wrap(await _investigations.CloseAsync(DepartmentId, UserId, input.CaseId, input.Reason, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Reopen")] + public async Task> Reopen([FromBody] CaseReasonInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Wrap(await _investigations.ReopenAsync(DepartmentId, UserId, input.CaseId, input.Reason, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + /// The case packet (JSON); audited as an export and refused while any content is concealed. + [HttpGet("Export")] + public async Task Export(string id, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var bytes = await _investigations.ExportAsync(DepartmentId, UserId, id, IpAddressHelper.GetRequestIP(Request, true), cancellationToken); + return File(bytes, "application/json", $"investigation-{id}.json"); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("AccessAudit")] + public async Task> AccessAudit(string id, int take = 100) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new CaseAuditResult { Data = (await _investigations.GetAccessAuditAsync(DepartmentId, UserId, id, take)).Select(a => new CaseAuditEntryData { Action = a.Action, ActorUserId = a.ActorUserId, Purpose = a.Purpose, Successful = a.Successful, OccurredOn = a.OccurredOn, DetailJson = a.DetailJson }).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordOccupanciesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordOccupanciesController.cs new file mode 100644 index 000000000..c40df3352 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordOccupanciesController.cs @@ -0,0 +1,241 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.Services.Models.v4; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// RMS-5 occupancy/property master (RMS plan section 4.3): the structure the department pre-plans, inspects and + /// permits, the ContactPreplan/Contact/POI crosswalk, the structure-write ownership switch and + /// OccupancyDispatchProjectionV1. Reads need Record_View; changes need Record_PreventionAdmin. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class RecordOccupanciesController : RecordsPreventionApiControllerBase + { + private readonly IRecordsOccupancyService _occupancies; + + public RecordOccupanciesController(IRecordsOccupancyService occupancies, IRecordsCutoverService cutover) : base(cutover) + { + _occupancies = occupancies; + } + + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> List(string search = null, int? status = null, int? occupancyType = null, bool? reviewOverdue = null, bool? hazmat = null, int skip = 0, int take = 50) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var query = new RmsOccupancyQuery { Search = search, Status = status, OccupancyType = occupancyType, ReviewOverdue = reviewOverdue, HazmatOnSite = hazmat, Skip = skip, Take = take }; + var rows = await _occupancies.ListAsync(DepartmentId, UserId, query); + var result = new OccupanciesResult { Data = rows.Select(RecordsRms5ApiMapper.ToOccupancy).ToList(), TotalCount = await _occupancies.CountAsync(DepartmentId, UserId, query) }; + result.PageSize = result.Data.Count; + return Ok(Done(result)); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Get")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Get(string id) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var aggregate = await _occupancies.GetAsync(DepartmentId, UserId, id); + if (aggregate == null) return NotFound(); + return Ok(Done(new OccupancyResult { Data = RecordsRms5ApiMapper.ToOccupancyAggregate(aggregate), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Save")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Save([FromBody] OccupancyInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try + { + var saved = await _occupancies.SaveAsync(DepartmentId, UserId, RecordsRms5ApiMapper.FromOccupancy(input), cancellationToken); + return Ok(Done(new OccupancySavedResult { Data = RecordsRms5ApiMapper.ToOccupancy(saved), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("Delete")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Delete(string id, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { await _occupancies.DeleteAsync(DepartmentId, UserId, id, cancellationToken); return Ok(Done(new StandardApiResponseV4Base())); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("MarkReviewed")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> MarkReviewed(string id, int nextReviewMonths = 12, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new OccupancySavedResult { Data = RecordsRms5ApiMapper.ToOccupancy(await _occupancies.MarkReviewedAsync(DepartmentId, UserId, id, nextReviewMonths, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("SaveHazard")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> SaveHazard([FromBody] OccupancyHazardInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try + { + var hazard = await _occupancies.SaveHazardAsync(DepartmentId, UserId, new RmsOccupancyHazard { RmsOccupancyHazardId = input.HazardId, RmsOccupancyId = input.OccupancyId, HazardType = input.HazardType, Severity = input.Severity, Title = input.Title, Description = input.Description, LocationDescription = input.LocationDescription, GpsCoordinates = input.GpsCoordinates, ShouldAlert = input.ShouldAlert }, cancellationToken); + return Ok(Done(new OccupancyHazardResult { Data = RecordsRms5ApiMapper.ToHazard(hazard), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("DeleteHazard")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> DeleteHazard(string id, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { await _occupancies.DeleteHazardAsync(DepartmentId, UserId, id, cancellationToken); return Ok(Done(new StandardApiResponseV4Base())); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("LinkContact")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> LinkContact([FromBody] OccupancyContactLinkInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try + { + var link = await _occupancies.LinkContactAsync(DepartmentId, UserId, input.OccupancyId, input.ContactId, (RmsOccupancyContactRole)input.Role, input.IsPrimary, cancellationToken); + return Ok(Done(new OccupancyContactLinkResult { Data = RecordsRms5ApiMapper.ToLink(link), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("UnlinkContact")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> UnlinkContact(string linkId, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { await _occupancies.UnlinkContactAsync(DepartmentId, UserId, linkId, cancellationToken); return Ok(Done(new StandardApiResponseV4Base())); } + catch (Exception ex) { return Fail(ex); } + } + + /// Runs the ContactPreplan/Contact/POI inventory into crosswalk candidates. + [HttpPost("Inventory")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Inventory(CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new OccupancyInventoryResult { Data = await _occupancies.InventoryCandidatesAsync(DepartmentId, UserId, cancellationToken), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Candidates")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Candidates(int state = (int)RmsOccupancyCrosswalkState.Candidate, int skip = 0, int take = 100) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var rows = await _occupancies.GetCandidatesAsync(DepartmentId, UserId, (RmsOccupancyCrosswalkState)state, skip, take); + var result = new OccupancyCrosswalksResult { Data = rows.Select(RecordsRms5ApiMapper.ToCrosswalk).ToList() }; + result.PageSize = result.Data.Count; + return Ok(Done(result)); + } + catch (Exception ex) { return Fail(ex); } + } + + /// Binds a candidate to an occupancy, or creates one from the source when occupancyId is empty. + [HttpPost("LinkCandidate")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> LinkCandidate(string crosswalkId, string occupancyId = null, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new OccupancySavedResult { Data = RecordsRms5ApiMapper.ToOccupancy(await _occupancies.LinkCandidateAsync(DepartmentId, UserId, crosswalkId, occupancyId, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("RejectCandidate")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> RejectCandidate(string crosswalkId, string reason = null, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { await _occupancies.RejectCandidateAsync(DepartmentId, UserId, crosswalkId, reason, cancellationToken); return Ok(Done(new StandardApiResponseV4Base())); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Merge")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Merge(string sourceOccupancyId, string targetOccupancyId, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new OccupancySavedResult { Data = RecordsRms5ApiMapper.ToOccupancy(await _occupancies.MergeAsync(DepartmentId, UserId, sourceOccupancyId, targetOccupancyId, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Reconciliation")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Reconciliation() + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new OccupancyReconciliationResult { Data = RecordsRms5ApiMapper.ToReconciliation(await _occupancies.GetReconciliationStatusAsync(DepartmentId)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + /// Switches structure writes from Contacts pre-plans to RMS. Refused until every candidate and pre-plan is decided. + [HttpPost("SwitchOwnership")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> SwitchOwnership(string reason, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + await _occupancies.SwitchWriteOwnershipAsync(DepartmentId, UserId, reason, cancellationToken); + return Ok(Done(new OccupancyReconciliationResult { Data = RecordsRms5ApiMapper.ToReconciliation(await _occupancies.GetReconciliationStatusAsync(DepartmentId)), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + /// OccupancyDispatchProjectionV1 for an occupancy, or for the occupancy a contact is linked to. + [HttpGet("DispatchProjection")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> DispatchProjection(string occupancyId = null, string contactId = null, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + if (!await _occupancies.IsModuleEnabledAsync(DepartmentId)) return NotFound(); + var projection = !string.IsNullOrWhiteSpace(occupancyId) + ? await _occupancies.GetDispatchProjectionAsync(DepartmentId, occupancyId, cancellationToken) + : await _occupancies.GetDispatchProjectionForContactAsync(DepartmentId, contactId, cancellationToken); + if (projection == null) return NotFound(); + return Ok(Done(new OccupancyProjectionResult { Data = projection, PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordPermitsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordPermitsController.cs new file mode 100644 index 000000000..e0dae5f5a --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordPermitsController.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.Services.Models.v4; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// RMS-5 permits, plan review, conditions, expiration and the optional fee reference (RMS plan section 4.3). + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class RecordPermitsController : RecordsPreventionApiControllerBase + { + private readonly IRecordsPermitsService _permits; + + public RecordPermitsController(IRecordsPermitsService permits, IRecordsCutoverService cutover) : base(cutover) + { + _permits = permits; + } + + [HttpGet("Types")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Types(bool includeInactive = false) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new PermitTypesResult { Data = (await _permits.GetTypesAsync(DepartmentId, UserId, includeInactive)).Select(RecordsRms5ApiMapper.ToPermitType).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("SaveType")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> SaveType([FromBody] PermitTypeData input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new PermitTypeResult { Data = RecordsRms5ApiMapper.ToPermitType(await _permits.SaveTypeAsync(DepartmentId, UserId, new RmsPermitType { RmsPermitTypeId = input.PermitTypeId, Name = input.Name, Code = input.Code, Description = input.Description, DefaultValidityDays = input.DefaultValidityDays, RequiresPlanReview = input.RequiresPlanReview, FeeAmount = input.FeeAmount, ConditionsTemplate = input.ConditionsTemplate, IsActive = input.IsActive }, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("List")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> List(string occupancyId = null, string permitTypeId = null, [FromQuery] List states = null, DateTime? expiresBefore = null, int skip = 0, int take = 50) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var query = new RmsPermitQuery { OccupancyId = occupancyId, PermitTypeId = permitTypeId, States = states, ExpiresBefore = expiresBefore, Skip = skip, Take = take }; + var r = new PermitsResult { Data = (await _permits.ListAsync(DepartmentId, UserId, query)).Select(RecordsRms5ApiMapper.ToPermit).ToList(), TotalCount = await _permits.CountAsync(DepartmentId, UserId, query) }; + r.PageSize = r.Data.Count; + return Ok(Done(r)); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Get")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Get(string id) + { + if (!await FlagOnAsync()) return NotFound(); + try { var a = await _permits.GetAsync(DepartmentId, UserId, id); if (a == null) return NotFound(); return Ok(Done(new PermitResult { Data = RecordsRms5ApiMapper.ToPermitAggregate(a), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Apply")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Apply([FromBody] PermitInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new PermitSavedResult { Data = RecordsRms5ApiMapper.ToPermit(await _permits.ApplyAsync(DepartmentId, UserId, RecordsRms5ApiMapper.FromPermit(input), cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Update")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Update([FromBody] PermitInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new PermitSavedResult { Data = RecordsRms5ApiMapper.ToPermit(await _permits.UpdateAsync(DepartmentId, UserId, RecordsRms5ApiMapper.FromPermit(input), cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Transition")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Transition([FromBody] PermitTransitionInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new PermitSavedResult { Data = RecordsRms5ApiMapper.ToPermit(await _permits.TransitionAsync(DepartmentId, UserId, input.PermitId, (RmsPermitState)input.TargetState, input.Reason, input.EffectiveOn, input.ExpiresOn, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("PlanReview")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> PlanReview([FromBody] PlanReviewInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new PlanReviewResult { Data = RecordsRms5ApiMapper.ToPlanReview(await _permits.RecordPlanReviewAsync(DepartmentId, UserId, input.PermitId, (RmsPlanReviewOutcome)input.Outcome, input.Comments, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("RecordFee")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> RecordFee([FromBody] PermitFeeInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new PermitSavedResult { Data = RecordsRms5ApiMapper.ToPermit(await _permits.RecordFeePaidAsync(DepartmentId, UserId, input.PermitId, input.Amount, input.InvoiceReference, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + } + + /// RMS-5 community risk reduction activities (RMS plan section 4.3). + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class RecordCrrController : RecordsPreventionApiControllerBase + { + private readonly IRecordsCrrService _crr; + + public RecordCrrController(IRecordsCrrService crr, IRecordsCutoverService cutover) : base(cutover) + { + _crr = crr; + } + + [HttpGet("List")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> List(DateTime? start = null, DateTime? end = null, int take = 200) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new CrrActivitiesResult { Data = (await _crr.ListAsync(DepartmentId, UserId, start ?? DateTime.UtcNow.AddDays(-90), end ?? DateTime.UtcNow.AddDays(1), take)).Select(RecordsRms5ApiMapper.ToCrr).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Get")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Get(string id) + { + if (!await FlagOnAsync()) return NotFound(); + try { var a = await _crr.GetAsync(DepartmentId, UserId, id); if (a == null) return NotFound(); return Ok(Done(new CrrActivityResult { Data = RecordsRms5ApiMapper.ToCrr(a), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Save")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Save([FromBody] CrrActivityInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new CrrActivityResult { Data = RecordsRms5ApiMapper.ToCrr(await _crr.SaveAsync(DepartmentId, UserId, RecordsRms5ApiMapper.FromCrr(input), cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("Delete")] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task> Delete(string id, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { await _crr.DeleteAsync(DepartmentId, UserId, id, cancellationToken); return Ok(Done(new StandardApiResponseV4Base())); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Summary")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Summary(DateTime? start = null, DateTime? end = null) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new CrrSummaryResult { Data = await _crr.GetSummaryAsync(DepartmentId, UserId, start ?? DateTime.UtcNow.AddDays(-90), end ?? DateTime.UtcNow.AddDays(1)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordQualityController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordQualityController.cs new file mode 100644 index 000000000..f62d758e8 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordQualityController.cs @@ -0,0 +1,205 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.Services.Models.v4; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// RMS-4 optional post-finalization quality review (RMS plan section 4.7): rubrics, sampling, scoring and trends. Rubrics are department-administrator work; sampling and scoring need Record_Review. + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class RecordQualityController : RecordsPreventionApiControllerBase + { + private readonly IRecordsQualityReviewService _quality; + + public RecordQualityController(IRecordsQualityReviewService quality, IRecordsCutoverService cutover) : base(cutover) + { + _quality = quality; + } + + [HttpGet("Rubrics")] + [Authorize(Policy = ResgridResources.Record_Review)] + public async Task> Rubrics(bool includeInactive = false) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new QualityRubricsResult { Data = (await _quality.GetRubricsAsync(DepartmentId, UserId, includeInactive)).Select(RecordsRms5ApiMapper.ToRubric).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("SaveRubric")] + [Authorize(Policy = ResgridResources.Record_Review)] + public async Task> SaveRubric([FromBody] QualityRubricInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new QualityRubricResult { Data = RecordsRms5ApiMapper.ToRubric(await _quality.SaveRubricAsync(DepartmentId, UserId, new RmsQualityRubric { RmsQualityRubricId = input.RubricId, Name = input.Name, DefinitionKey = input.DefinitionKey, SampleSize = input.SampleSize, IsActive = input.IsActive }, input.Criteria, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Sample")] + [Authorize(Policy = ResgridResources.Record_Review)] + public async Task> Sample([FromBody] QualitySampleInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { var r = new QualityReviewsResult { Data = (await _quality.SampleAsync(DepartmentId, UserId, input.RubricId, input.Since ?? DateTime.UtcNow.AddDays(-30), cancellationToken)).Select(RecordsRms5ApiMapper.ToReview).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Pending")] + [Authorize(Policy = ResgridResources.Record_Review)] + public async Task> Pending(int take = 100) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new QualityReviewsResult { Data = (await _quality.GetPendingAsync(DepartmentId, UserId, take)).Select(RecordsRms5ApiMapper.ToReview).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Get")] + [Authorize(Policy = ResgridResources.Record_Review)] + public async Task> Get(string id) + { + if (!await FlagOnAsync()) return NotFound(); + try { var review = await _quality.GetReviewAsync(DepartmentId, UserId, id); if (review == null) return NotFound(); return Ok(Done(new QualityReviewResult { Data = RecordsRms5ApiMapper.ToReview(review), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Score")] + [Authorize(Policy = ResgridResources.Record_Review)] + public async Task> Score([FromBody] QualityScoreInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null) return BadRequest(); + try { return Ok(Done(new QualityReviewResult { Data = RecordsRms5ApiMapper.ToReview(await _quality.ScoreAsync(DepartmentId, UserId, input.ReviewId, input.Findings, input.Note, input.AmendmentRecommended, cancellationToken)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + /// QA notes attached to a record, for the record detail view. Needs only visibility of the record. + [HttpGet("ForRecord")] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> ForRecord(string recordId) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new QualityReviewsResult { Data = (await _quality.GetForRecordAsync(DepartmentId, UserId, recordId)).Select(RecordsRms5ApiMapper.ToReview).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Trends")] + [Authorize(Policy = ResgridResources.Record_Review)] + public async Task> Trends(DateTime? since = null) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new QualityTrendsResult { Data = await _quality.GetTrendsAsync(DepartmentId, UserId, since ?? DateTime.UtcNow.AddDays(-90)), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + } + + /// Files on prevention and investigation aggregates. Authorization follows the parent (see the service). + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordPreventionAttachmentsController : RecordsPreventionApiControllerBase + { + private readonly IRecordsPreventionAttachmentsService _attachments; + + public RecordPreventionAttachmentsController(IRecordsPreventionAttachmentsService attachments, IRecordsCutoverService cutover) : base(cutover) + { + _attachments = attachments; + } + + [HttpGet("List")] + public async Task> List(int parentKind, string parentId) + { + if (!await FlagOnAsync()) return NotFound(); + try { var r = new PreventionAttachmentsResult { Data = (await _attachments.GetMetadataAsync(DepartmentId, UserId, (RmsPreventionParentKind)parentKind, parentId)).Select(a => RecordsRms5ApiMapper.ToAttachment(a, false)).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + catch (Exception ex) { return Fail(ex); } + } + + /// Uploads a base64 file through the record-attachment hygiene and scanner pipeline. + [HttpPost("Add")] + public async Task> Add([FromBody] PreventionAttachmentInput input, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + if (input == null || string.IsNullOrWhiteSpace(input.Data)) return BadRequest(); + try + { + byte[] bytes; + try { bytes = Convert.FromBase64String(input.Data); } + catch (FormatException) { return Problem(statusCode: 400, title: "Data must be base64.", type: "record_prevention_validation"); } + var attachment = await _attachments.AddAsync(DepartmentId, UserId, (RmsPreventionParentKind)input.ParentKind, input.ParentId, input.FileName, input.ContentType, bytes, input.Description, false, cancellationToken); + return Ok(Done(new PreventionAttachmentResult { Data = RecordsRms5ApiMapper.ToAttachment(attachment, false), PageSize = 1 })); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Get")] + public async Task> Get(string id) + { + if (!await FlagOnAsync()) return NotFound(); + try { var a = await _attachments.GetWithDataAsync(DepartmentId, UserId, id); if (a == null) return NotFound(); return Ok(Done(new PreventionAttachmentResult { Data = RecordsRms5ApiMapper.ToAttachment(a, true), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Download")] + public async Task Download(string id) + { + if (!await FlagOnAsync()) return NotFound(); + try { var a = await _attachments.GetWithDataAsync(DepartmentId, UserId, id); if (a?.Data == null) return NotFound(); return File(a.Data, a.ContentType ?? "application/octet-stream", a.FileName ?? "attachment"); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("Remove")] + public async Task> Remove(string id, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try { await _attachments.RemoveAsync(DepartmentId, UserId, id, cancellationToken); return Ok(Done(new StandardApiResponseV4Base())); } + catch (Exception ex) { return Fail(ex); } + } + } + + /// RMS-4 release telemetry and the prevention summary (plan section 6, RMS-4 "baseline telemetry"). + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordsHealthController : RecordsPreventionApiControllerBase + { + private readonly IRecordsReleaseTelemetryService _telemetry; + private readonly IRecordsPreventionSweepService _prevention; + + public RecordsHealthController(IRecordsReleaseTelemetryService telemetry, IRecordsPreventionSweepService prevention, IRecordsCutoverService cutover) : base(cutover) + { + _telemetry = telemetry; + _prevention = prevention; + } + + /// Department-administrator only: legacy-write attempts, outbox lag, Workflow run outcomes, scan states and the prevention counters. + [HttpGet("Telemetry")] + public async Task> Telemetry(int windowHours = 24) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new RecordsReleaseTelemetryResult { Data = await _telemetry.GetAsync(DepartmentId, UserId, windowHours), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("PreventionSummary")] + public async Task> PreventionSummary() + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new PreventionSummaryResult { Data = await _prevention.GetSummaryAsync(DepartmentId, UserId), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordsPreventionApiControllerBase.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordsPreventionApiControllerBase.cs new file mode 100644 index 000000000..8b7656d5a --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordsPreventionApiControllerBase.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Shared gate for the RMS-5 prevention/investigation and RMS-4 quality/health surfaces: the Records flag first, member + /// principals only (a system principal's department-scoped Record_View grant covers Records, never prevention data or + /// investigation cases), and one exception-to-status mapping so every module answers the same way. + /// + public abstract class RecordsPreventionApiControllerBase : V4AuthenticatedApiControllerbase, IActionFilter + { + protected readonly IRecordsCutoverService Cutover; + + protected RecordsPreventionApiControllerBase(IRecordsCutoverService cutover) + { + Cutover = cutover; + } + + public void OnActionExecuting(ActionExecutingContext context) + { + if (RecordsSystemPrincipal.IsSystemPrincipal(User)) + context.Result = Problem(statusCode: StatusCodes.Status403Forbidden, title: "Prevention and investigation endpoints accept member principals only.", type: "record_prevention_member_only"); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + + protected async Task FlagOnAsync() => (await Cutover.GetModuleStateAsync(DepartmentId)).FlagEnabled; + + protected string ClientIp => HttpContext?.Connection?.RemoteIpAddress?.ToString(); + + protected T Done(T result) where T : StandardApiResponseV4Base + { + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + protected ActionResult Fail(Exception ex) + { + switch (ex) + { + case UnauthorizedAccessException _: return Forbid(); + case RecordsModuleDisabledException _: return NotFound(); + case RecordProtectedContentException protectedContent: return Problem(statusCode: StatusCodes.Status403Forbidden, title: protectedContent.Message, type: "record_protected_content", detail: protectedContent.Reason); + case RecordAttachmentRejectedException rejected: return Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: rejected.Message, type: "record_attachment_rejected"); + case ArgumentException argument: return Problem(statusCode: StatusCodes.Status400BadRequest, title: argument.Message, type: "record_prevention_validation"); + case InvalidOperationException invalid: return Problem(statusCode: StatusCodes.Status409Conflict, title: invalid.Message, type: "record_prevention_state"); + default: throw ex; + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs index 2cec85f7b..8254ab4ed 100644 --- a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs @@ -268,6 +268,11 @@ public static bool CanManageRecordLegalHold() return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.RecordLegalHold, ResgridClaimTypes.Actions.Update); } + public static bool CanAdministerRecordsPrevention() + { + return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.PreventionAdmin); + } + public static bool CanCreateShift() { return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Shift, ResgridClaimTypes.Actions.Create); diff --git a/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs b/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs index ae9d18d0c..8367c2c41 100644 --- a/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs +++ b/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs @@ -62,7 +62,8 @@ public static RecordDefinitionVersionData ToVersion(RmsRecordDefinitionVersion v return new RecordDefinitionVersionData { VersionId = v.RmsRecordDefinitionVersionId, DefinitionKey = v.DefinitionKey, Version = v.Version, State = ((RmsDefinitionVersionState)v.State).ToString(), - LifecyclePreset = ((RmsLifecyclePreset)v.LifecyclePreset).ToString(), ReviewerRoleIds = RecordDefinitionsService.ParseIds(v.ReviewerRoleIds), ApproverRoleIds = RecordDefinitionsService.ParseIds(v.ApproverRoleIds), + LifecyclePreset = ((RmsLifecyclePreset)v.LifecyclePreset).ToString(), Cardinality = ((RmsRecordCardinality)v.Cardinality).ToString(), + ReviewerRoleIds = RecordDefinitionsService.ParseIds(v.ReviewerRoleIds), ApproverRoleIds = RecordDefinitionsService.ParseIds(v.ApproverRoleIds), ReviewDueHours = v.ReviewDueHours, ApproveDueHours = v.ApproveDueHours, RequireAuthorAttestation = v.RequireAuthorAttestation, Numbering = v.Numbering, RetentionYears = v.RetentionYears, Classification = ((RmsFieldClassification)v.Classification).ToString(), Schema = v.Schema, SchemaChecksum = v.SchemaChecksum, MinimumClientCapability = v.MinimumClientCapability, ClientSurface = v.ClientSurface, MigrationMap = RecordDefinitionsService.ToDraftInput(v).MigrationMap, ChangeNotes = v.ChangeNotes, PublishedOn = v.PublishedOn, PublishedByUserId = v.PublishedByUserId, RetiredOn = v.RetiredOn, @@ -89,7 +90,7 @@ public static RecordDefinitionData ToDefinitionData(RmsRecordDefinitionVersion v return new RecordDefinitionData { Key = version.DefinitionKey, Version = version.Version, Name = definition?.Name ?? version.DefinitionKey, RecordType = null, RecordKind = RmsRecordKind.Operational.ToString(), - LifecyclePreset = version.LifecyclePreset, LifecyclePresetName = ((RmsLifecyclePreset)version.LifecyclePreset).ToString(), Cardinality = RmsRecordCardinality.MultiplePerCall.ToString(), + LifecyclePreset = version.LifecyclePreset, LifecyclePresetName = ((RmsLifecyclePreset)version.LifecyclePreset).ToString(), Cardinality = ((RmsRecordCardinality)version.Cardinality).ToString(), Restricted = version.Classification != (int)RmsFieldClassification.Standard || schema.AllFields().Any(f => f.Classification != RmsFieldClassification.Standard), NumberPrefix = version.Numbering.Prefix, RequiresCall = false, SupportsParticipants = true, SupportsUnits = true, SupportsAttachments = version.ClientSurface.AllowAttachments, MinimumClientCapability = version.MinimumClientCapability ?? RecordsClientCapabilities.Derive(schema), Locked = false, @@ -102,7 +103,7 @@ public static RecordTemplateRenderingData ToRendering(RecordTemplateRendering re var t = rendering.Template; return new RecordTemplateRenderingData { - TemplateKey = t.Key, Name = t.Name, Category = t.Category, Description = t.Description, PackKey = t.PackKey, LifecyclePreset = t.LifecyclePreset.ToString(), NumberPrefix = t.NumberPrefix, PermittedSubjectTypes = t.PermittedSubjectTypes, + TemplateKey = t.Key, Name = t.Name, Category = t.Category, Description = t.Description, PackKey = t.PackKey, LifecyclePreset = t.LifecyclePreset.ToString(), Cardinality = t.Cardinality.ToString(), NumberPrefix = t.NumberPrefix, PermittedSubjectTypes = t.PermittedSubjectTypes, ProfileKey = rendering.ProfileKey, Locale = rendering.Locale, MeasurementSystem = rendering.MeasurementSystem, CurrencyCode = rendering.CurrencyCode, ArtifactStatus = rendering.ArtifactStatus.ToString(), ProvenanceStatement = rendering.ProvenanceStatement, Sources = rendering.Sources, MinimumClientCapability = RecordsClientCapabilities.Derive(rendering.Schema), Schema = rendering.Schema }; diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs index 7f5bae0f8..635485a54 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs @@ -247,5 +247,12 @@ public class CallResultData /// Whether check-in timers are enabled for this call /// public bool CheckInTimersEnabled { get; set; } + + /// + /// Contacts linked to this call (primary first) with pre-plan / alert / hazard indicators. + /// Populated by GetCall and GetActiveCalls; empty on other list endpoints. Use + /// GetCallSiteInfo for the full site knowledge. Contacts plan Phase A, decision 3a. + /// + public List Contacts { get; set; } = new List(); } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/CallSiteInfoResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/CallSiteInfoResult.cs new file mode 100644 index 000000000..ac15a4b74 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/CallSiteInfoResult.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using Resgrid.Web.Services.Models.v4.ContactFiles; +using Resgrid.Web.Services.Models.v4.Contacts; + +namespace Resgrid.Web.Services.Models.v4.Calls +{ + /// + /// A contact linked to a call, as carried on the call payload (Contacts plan Phase A, decision 3a). + /// + public class CallContactResultData + { + /// Id of the contact + public string ContactId { get; set; } + + /// Display name (person or company). REDACTED in a protected department without a grant. + public string Name { get; set; } + + /// 0 = Person, 1 = Company + public int ContactType { get; set; } + + /// 0 = Primary, 1 = Additional + public int CallContactType { get; set; } + + /// True when the contact has a pre-incident plan + public bool HasPreplan { get; set; } + + /// Count of live ShouldAlert notes + public int AlertNoteCount { get; set; } + + /// Count of live premise hazards + public int HazardCount { get; set; } + } + + /// + /// Site information for every contact linked to a call, composed in one round trip + /// (Contacts plan Phase A, decision 3b). Also the RMS NERIS authoring prefill source (A8): additive changes only. + /// + public class CallSiteInfoResult : StandardApiResponseV4Base + { + /// + /// Response Data + /// + public CallSiteInfoData Data { get; set; } + } + + public class CallSiteInfoData + { + /// Id of the call + public string CallId { get; set; } + + /// + /// ADP: true when the department enforces protection; contact identity fields are then decrypted + /// (valid grant) or the exact "REDACTED" placeholder. Pre-plan and hazard text is not cataloged. + /// + public bool IsProtected { get; set; } + + /// ADP: machine-readable reason when contact fields are redacted; null when nothing is redacted. + public string ProtectedReason { get; set; } + + /// Linked contacts, primary first + public List Contacts { get; set; } = new List(); + } + + public class CallSiteContactData + { + /// Id of the contact + public string ContactId { get; set; } + + /// Display name (person or company) + public string Name { get; set; } + + /// 0 = Person, 1 = Company + public int ContactType { get; set; } + + /// 0 = Primary, 1 = Additional + public int CallContactType { get; set; } + + /// Physical location coordinates ("lat,lng") when known + public string LocationGpsCoordinates { get; set; } + + /// Entrance coordinates ("lat,lng") when known + public string EntranceGpsCoordinates { get; set; } + + /// Best phone number on file (cell, then office, then home) + public string PhoneNumber { get; set; } + + /// The pre-incident plan, null when none + public ContactPreplanData Preplan { get; set; } + + /// RMS-5: the Records occupancy the pre-plan is projected from, when the department has switched pre-plan ownership to Records. Null otherwise (additive, contract unchanged). + public string OccupancyId { get; set; } + + /// Live premise hazards, most severe first + public List Hazards { get; set; } = new List(); + + /// Live ShouldAlert notes (not expired) + public List AlertNotes { get; set; } = new List(); + + /// Attachment metadata with signed download URLs (no Data) + public List Attachments { get; set; } = new List(); + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs index 0bdfce2da..4b66798bc 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs @@ -79,6 +79,18 @@ public class EditCallInput /// public string ContactInfo { get; set; } + /// + /// Id of the primary Contact (the premises/customer record) to link to the call. Optional; + /// the contact must belong to the department. Contacts plan Phase A (A5). + /// + public string ContactId { get; set; } + + /// + /// Ids of additional Contacts to link to the call. Optional. On EditCall, supplying ContactId or + /// AdditionalContactIds (even empty) replaces the call's existing contact links; omitting both leaves them unchanged. + /// + public List AdditionalContactIds { get; set; } + /// /// External Call Id /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs index a46cabe9e..f8500aa00 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs @@ -78,6 +78,18 @@ public class NewCallInput /// public string ContactInfo { get; set; } + /// + /// Id of the primary Contact (the premises/customer record) to link to the call. Optional; + /// the contact must belong to the department. Contacts plan Phase A (A5). + /// + public string ContactId { get; set; } + + /// + /// Ids of additional Contacts to link to the call. Optional. On EditCall, supplying ContactId or + /// AdditionalContactIds (even empty) replaces the call's existing contact links; omitting both leaves them unchanged. + /// + public List AdditionalContactIds { get; set; } + /// /// External Call Id /// diff --git a/Web/Resgrid.Web.Services/Models/v4/ContactFiles/ContactFileResult.cs b/Web/Resgrid.Web.Services/Models/v4/ContactFiles/ContactFileResult.cs new file mode 100644 index 000000000..a4ae5b87e --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/ContactFiles/ContactFileResult.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Web.Services.Models.v4.ContactFiles +{ + /// + /// Files attached to a contact (site documents, pre-plans, floor plans, photos, drawings). + /// + public class ContactFilesResult : StandardApiResponseV4Base + { + /// + /// Data payload + /// + public List Data { get; set; } = new List(); + } + + /// + /// One file attached to a contact. Metadata always; Data only when requested. + /// + public class ContactFileResultData + { + /// + /// ADP: true when this row belongs to a protection-enforced department (shield indicator). + /// Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + /// — never ciphertext. + /// + public bool IsProtected { get; set; } + + /// ADP: machine-readable reason when values are redacted (step_up_required, + /// grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + /// nothing is redacted. + public string ProtectedReason { get; set; } + + /// ADP: stable catalog field ids (catalog v12) whose values are REDACTED on this row. + public List RedactedFields { get; set; } = new List(); + + /// Id of the contact file + public string Id { get; set; } + + /// Id of the Contact + public string ContactId { get; set; } + + /// Type of the file (Document = 0, PrePlan = 1, FloorPlan = 2, SitePhoto = 3, SiteDrawing = 4, Other = 5) + public int Type { get; set; } + + /// Type display name + public string TypeName { get; set; } + + /// User friendly title of the file + public string Name { get; set; } + + /// Original file name + public string FileName { get; set; } + + /// Mime type + public string Mime { get; set; } + + /// Size in bytes + public int Size { get; set; } + + /// Base64 file data (only when includeData was requested) + public string Data { get; set; } + + /// + /// Signed, expiring anonymous URL to download the file instead of using Data. Regenerated on every + /// authenticated list call; do not persist it (RMS plan: an authorized capture copies the evidence). + /// Null for a protected department's enveloped file: the anonymous route can never carry a grant, so + /// attended clients fetch the bytes through GetFilesForContact with includeData and their grant instead. + /// + public string Url { get; set; } + + /// User Id of the person who uploaded the file + public string UserId { get; set; } + + /// When the file was added (department time) + public string Timestamp { get; set; } + } + + /// + /// Input to attach a file to a contact + /// + public class UploadContactFileInput + { + [Required] + public string ContactId { get; set; } + + /// Type of the file (Document = 0, PrePlan = 1, FloorPlan = 2, SitePhoto = 3, SiteDrawing = 4, Other = 5) + public int Type { get; set; } + + /// User friendly title (defaults to the file name) + public string Name { get; set; } + + /// Original file name, with extension + [Required] + public string FileName { get; set; } + + /// Base64 encoded file contents + [Required] + public string Data { get; set; } + } + + public class UploadContactFileResult : StandardApiResponseV4Base + { + /// Id of the new contact file + public string Id { get; set; } + } + + public class DeleteContactFileResult : StandardApiResponseV4Base + { + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactHazardsResult.cs b/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactHazardsResult.cs new file mode 100644 index 000000000..73478809c --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactHazardsResult.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Resgrid.Web.Services.Helpers; + +namespace Resgrid.Web.Services.Models.v4.Contacts +{ + /// + /// Premise hazards on a contact's pre-plan (Contacts plan Phase A). + /// + public class ContactHazardsResult : StandardApiResponseV4Base + { + /// + /// Response Data + /// + public List Data { get; set; } = new List(); + } + + /// + /// A single premise hazard. + /// + public class ContactHazardData + { + /// + /// ADP: true when this row belongs to a protection-enforced department (shield indicator). + /// Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + /// — never ciphertext. + /// + public bool IsProtected { get; set; } + + /// ADP: machine-readable reason when values are redacted (step_up_required, + /// grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + /// nothing is redacted. + public string ProtectedReason { get; set; } + + /// ADP: stable catalog field ids (catalog v12) whose values are REDACTED on this row. + public List RedactedFields { get; set; } = new List(); + + public string ContactPreplanHazardId { get; set; } + public string ContactPreplanId { get; set; } + public string ContactId { get; set; } + + /// ContactPreplanHazardTypes value (General = 0, Hazmat = 1, Structural = 2, Electrical = 3, Biological = 4, Animal = 5, Occupant = 6, Other = 7) + public int HazardType { get; set; } + public string HazardTypeName { get; set; } + + /// ContactPreplanHazardSeverities value (Info = 0, Caution = 1, Danger = 2) + public int Severity { get; set; } + public string SeverityName { get; set; } + + public string Title { get; set; } + public string Description { get; set; } + public string LocationDescription { get; set; } + + /// "lat,lng" + public string GpsCoordinates { get; set; } + + /// Surfaces in the dispatch alert banner when true. + public bool ShouldAlert { get; set; } + + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime AddedOnUtc { get; set; } + public string AddedOn { get; set; } + public string AddedByUserId { get; set; } + + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime? EditedOnUtc { get; set; } + public string EditedOn { get; set; } + public string EditedByUserId { get; set; } + } + + /// + /// Input to create (no id) or update (with id) a hazard. A contact without a pre-plan gets an empty one created. + /// + public class SaveContactHazardInput + { + /// Omit to create a new hazard. + public string ContactPreplanHazardId { get; set; } + + [Required] + public string ContactId { get; set; } + + public int HazardType { get; set; } + public int Severity { get; set; } + + [Required] + public string Title { get; set; } + public string Description { get; set; } + public string LocationDescription { get; set; } + public string GpsCoordinates { get; set; } + public bool ShouldAlert { get; set; } + } + + public class SaveContactHazardResult : StandardApiResponseV4Base + { + /// Id of the hazard row + public string Id { get; set; } + } + + public class DeleteContactHazardResult : StandardApiResponseV4Base + { + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactPreplanResult.cs b/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactPreplanResult.cs new file mode 100644 index 000000000..c86125813 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactPreplanResult.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Resgrid.Web.Services.Helpers; + +namespace Resgrid.Web.Services.Models.v4.Contacts +{ + /// + /// A contact's pre-incident plan (Contacts plan Phase A). + /// + public class ContactPreplanResult : StandardApiResponseV4Base + { + /// + /// Response Data (null when the contact has no pre-plan) + /// + public ContactPreplanData Data { get; set; } + } + + /// + /// Structured NFPA 1620 pre-incident plan for a contact. Enum-valued fields carry the integer value; + /// see ContactPreplanConstructionTypes / ContactPreplanRoofTypes / ContactPreplanOccupancyTypes. + /// + public class ContactPreplanData + { + /// + /// ADP: true when this row belongs to a protection-enforced department (shield indicator). + /// Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + /// — never ciphertext. + /// + public bool IsProtected { get; set; } + + /// ADP: machine-readable reason when values are redacted (step_up_required, + /// grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + /// nothing is redacted. + public string ProtectedReason { get; set; } + + /// ADP: stable catalog field ids (catalog v12) whose values are REDACTED on this row. + public List RedactedFields { get; set; } = new List(); + + public string ContactPreplanId { get; set; } + public string ContactId { get; set; } + + /// ContactPreplanConstructionTypes value + public int ConstructionType { get; set; } + public string ConstructionTypeName { get; set; } + + /// ContactPreplanRoofTypes value + public int RoofType { get; set; } + public string RoofTypeName { get; set; } + + /// ContactPreplanOccupancyTypes value + public int OccupancyType { get; set; } + public string OccupancyTypeName { get; set; } + public string OccupancyNotes { get; set; } + public string OccupancyHours { get; set; } + public int? OccupantLoad { get; set; } + public bool HasOccupantsNeedingAssistance { get; set; } + public string OccupantsNeedingAssistanceNotes { get; set; } + + public string GasShutoffLocation { get; set; } + public string ElectricShutoffLocation { get; set; } + public string WaterShutoffLocation { get; set; } + public string UtilityNotes { get; set; } + + public string KnoxBoxLocation { get; set; } + /// ADP catalog v12 field: decrypted with a valid grant, otherwise the REDACTED placeholder. + public string GateCode { get; set; } + public string AlarmPanelLocation { get; set; } + public string AlarmCompany { get; set; } + public string AlarmCompanyPhone { get; set; } + public string AccessNotes { get; set; } + + public string NearestHydrantLocation { get; set; } + public int? RequiredFireFlowGpm { get; set; } + public string WaterSupplyNotes { get; set; } + + public string EmergencyContactName { get; set; } + public string EmergencyContactPhone { get; set; } + public string SecondaryContactName { get; set; } + public string SecondaryContactPhone { get; set; } + + public bool HazmatOnSite { get; set; } + public string GeneralHazardNotes { get; set; } + public string TacticalSummary { get; set; } + + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime? LastReviewedOnUtc { get; set; } + public string LastReviewedOn { get; set; } + public string ReviewedByUserId { get; set; } + + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime? NextReviewDueUtc { get; set; } + public string NextReviewDue { get; set; } + + /// True when NextReviewDue has passed. + public bool IsReviewOverdue { get; set; } + + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime AddedOnUtc { get; set; } + public string AddedOn { get; set; } + public string AddedByUserId { get; set; } + + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime? EditedOnUtc { get; set; } + public string EditedOn { get; set; } + public string EditedByUserId { get; set; } + + /// Live hazards on this pre-plan. + public List Hazards { get; set; } = new List(); + } + + /// + /// Input to create or replace a contact's pre-plan. The whole plan is written; omitted text fields clear. + /// + public class SaveContactPreplanInput + { + [Required] + public string ContactId { get; set; } + + public int ConstructionType { get; set; } + public int RoofType { get; set; } + public int OccupancyType { get; set; } + public string OccupancyNotes { get; set; } + public string OccupancyHours { get; set; } + public int? OccupantLoad { get; set; } + public bool HasOccupantsNeedingAssistance { get; set; } + public string OccupantsNeedingAssistanceNotes { get; set; } + + public string GasShutoffLocation { get; set; } + public string ElectricShutoffLocation { get; set; } + public string WaterShutoffLocation { get; set; } + public string UtilityNotes { get; set; } + + public string KnoxBoxLocation { get; set; } + public string GateCode { get; set; } + public string AlarmPanelLocation { get; set; } + public string AlarmCompany { get; set; } + public string AlarmCompanyPhone { get; set; } + public string AccessNotes { get; set; } + + public string NearestHydrantLocation { get; set; } + public int? RequiredFireFlowGpm { get; set; } + public string WaterSupplyNotes { get; set; } + + public string EmergencyContactName { get; set; } + public string EmergencyContactPhone { get; set; } + public string SecondaryContactName { get; set; } + public string SecondaryContactPhone { get; set; } + + public bool HazmatOnSite { get; set; } + public string GeneralHazardNotes { get; set; } + public string TacticalSummary { get; set; } + + /// When true, LastReviewedOn is stamped now and ReviewedByUserId is the caller. + public bool MarkReviewed { get; set; } + + /// Next scheduled review (UTC). Null leaves no review scheduled. + public DateTime? NextReviewDueUtc { get; set; } + } + + public class SaveContactPreplanResult : StandardApiResponseV4Base + { + /// Id of the pre-plan row + public string Id { get; set; } + } + + public class DeleteContactPreplanResult : StandardApiResponseV4Base + { + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs index 2b338c7f8..d86fe3402 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs @@ -97,6 +97,8 @@ public class RecordDefinitionVersionData public int Version { get; set; } public string State { get; set; } public string LifecyclePreset { get; set; } + /// How many Records of this definition may exist on one Call (plan section 5.2.1). + public string Cardinality { get; set; } public List ReviewerRoleIds { get; set; } = new List(); public List ApproverRoleIds { get; set; } = new List(); public int? ReviewDueHours { get; set; } @@ -286,6 +288,7 @@ public class RecordTemplateRenderingData public string Description { get; set; } public string PackKey { get; set; } public string LifecyclePreset { get; set; } + public string Cardinality { get; set; } public string NumberPrefix { get; set; } public string PermittedSubjectTypes { get; set; } public string ProfileKey { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms5ApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms5ApiModels.cs new file mode 100644 index 000000000..8171c1fc7 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms5ApiModels.cs @@ -0,0 +1,593 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Services.Models.v4.Records +{ + // RMS-5 prevention and investigations plus the RMS-4 quality review and release telemetry (plan sections 4.3, 4.4, + // 4.7 and 6). Same conventions as the RMS-1B surfaces: envelopes here, contracts in the Model. Cataloged text + // arrives already revealed or as the REDACTED sentinel; RedactedFields on the aggregate names what was withheld. + + #region Occupancies + + public class OccupancyData + { + public string OccupancyId { get; set; } + public string OccupancyNumber { get; set; } + public string Name { get; set; } + public int Status { get; set; } + public string MergedIntoOccupancyId { get; set; } + public string AddressText { get; set; } + public string City { get; set; } + public string StateProvince { get; set; } + public string PostalCode { get; set; } + public string Country { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public string ParcelId { get; set; } + public int ConstructionType { get; set; } + public int RoofType { get; set; } + public int OccupancyType { get; set; } + public int? Stories { get; set; } + public int? YearBuilt { get; set; } + public int? SquareFeet { get; set; } + public int? OccupantLoad { get; set; } + public string OccupancyHours { get; set; } + public bool HasOccupantsNeedingAssistance { get; set; } + public string OccupantsNeedingAssistanceNotes { get; set; } + public int SprinklerType { get; set; } + public bool HasStandpipe { get; set; } + public bool HasFireAlarm { get; set; } + public string FdcLocation { get; set; } + public string GasShutoffLocation { get; set; } + public string ElectricShutoffLocation { get; set; } + public string WaterShutoffLocation { get; set; } + public string UtilityNotes { get; set; } + public string KnoxBoxLocation { get; set; } + public string GateCode { get; set; } + public string AlarmPanelLocation { get; set; } + public string AlarmCompany { get; set; } + public string AlarmCompanyPhone { get; set; } + public string AccessNotes { get; set; } + public string NearestHydrantId { get; set; } + public int? RequiredFireFlowGpm { get; set; } + public string WaterSupplyNotes { get; set; } + public string EmergencyContactName { get; set; } + public string EmergencyContactPhone { get; set; } + public bool HazmatOnSite { get; set; } + public string GeneralHazardNotes { get; set; } + public string TacticalSummary { get; set; } + public int? PoiId { get; set; } + public DateTime? LastReviewedOn { get; set; } + public string ReviewedByUserId { get; set; } + public DateTime? NextReviewDue { get; set; } + public bool IsReviewOverdue { get; set; } + public DateTime? LastInspectedOn { get; set; } + public bool IsProtected { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + } + + public class OccupancyHazardData + { + public string HazardId { get; set; } + public string OccupancyId { get; set; } + public int HazardType { get; set; } + public int Severity { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string LocationDescription { get; set; } + public string GpsCoordinates { get; set; } + public bool ShouldAlert { get; set; } + public string SourceContactPreplanHazardId { get; set; } + public bool IsProtected { get; set; } + } + + public class OccupancyContactLinkData + { + public string LinkId { get; set; } + public string OccupancyId { get; set; } + public string ContactId { get; set; } + public int Role { get; set; } + public bool IsPrimary { get; set; } + } + + public class OccupancyProvenanceData + { + public string FieldKey { get; set; } + public int SourceKind { get; set; } + public string SourceId { get; set; } + public DateTime CapturedOn { get; set; } + public DateTime? ReviewedOn { get; set; } + } + + public class OccupancyCrosswalkData + { + public string CrosswalkId { get; set; } + public string OccupancyId { get; set; } + public int SourceKind { get; set; } + public string SourceId { get; set; } + public string ContactId { get; set; } + public string SourceDisplayName { get; set; } + public string NormalizedAddress { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public int MatchConfidence { get; set; } + public string MatchReason { get; set; } + public string SuggestedOccupancyId { get; set; } + public string GroupKey { get; set; } + public int State { get; set; } + public DateTime? DecidedOn { get; set; } + public DateTime InventoriedOn { get; set; } + } + + public class OccupancyAggregateData + { + public OccupancyData Occupancy { get; set; } + public List Hazards { get; set; } = new List(); + public List ContactLinks { get; set; } = new List(); + public List Provenance { get; set; } = new List(); + public List Crosswalks { get; set; } = new List(); + public int OpenViolationCount { get; set; } + public bool IsProtected { get; set; } + public string ProtectedReason { get; set; } + public List RedactedFields { get; set; } = new List(); + } + + public class OccupancyInput + { + public string OccupancyId { get; set; } + public long RowVersion { get; set; } + public string Name { get; set; } + public int Status { get; set; } + public string AddressText { get; set; } + public string City { get; set; } + public string StateProvince { get; set; } + public string PostalCode { get; set; } + public string Country { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public string ParcelId { get; set; } + public int ConstructionType { get; set; } + public int RoofType { get; set; } + public int OccupancyType { get; set; } + public int? Stories { get; set; } + public int? YearBuilt { get; set; } + public int? SquareFeet { get; set; } + public int? OccupantLoad { get; set; } + public string OccupancyHours { get; set; } + public bool HasOccupantsNeedingAssistance { get; set; } + public string OccupantsNeedingAssistanceNotes { get; set; } + public int SprinklerType { get; set; } + public bool HasStandpipe { get; set; } + public bool HasFireAlarm { get; set; } + public string FdcLocation { get; set; } + public string GasShutoffLocation { get; set; } + public string ElectricShutoffLocation { get; set; } + public string WaterShutoffLocation { get; set; } + public string UtilityNotes { get; set; } + public string KnoxBoxLocation { get; set; } + public string GateCode { get; set; } + public string AlarmPanelLocation { get; set; } + public string AlarmCompany { get; set; } + public string AlarmCompanyPhone { get; set; } + public string AccessNotes { get; set; } + public string NearestHydrantId { get; set; } + public int? RequiredFireFlowGpm { get; set; } + public string WaterSupplyNotes { get; set; } + public string EmergencyContactName { get; set; } + public string EmergencyContactPhone { get; set; } + public bool HazmatOnSite { get; set; } + public string GeneralHazardNotes { get; set; } + public string TacticalSummary { get; set; } + public int? PoiId { get; set; } + public DateTime? NextReviewDue { get; set; } + } + + public class OccupancyHazardInput + { + public string HazardId { get; set; } + public string OccupancyId { get; set; } + public int HazardType { get; set; } + public int Severity { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string LocationDescription { get; set; } + public string GpsCoordinates { get; set; } + public bool ShouldAlert { get; set; } + } + + public class OccupancyContactLinkInput + { + public string OccupancyId { get; set; } + public string ContactId { get; set; } + public int Role { get; set; } + public bool IsPrimary { get; set; } + } + + public class OccupancyReconciliationData + { + public string State { get; set; } + public DateTime? InventoriedOn { get; set; } + public DateTime? SwitchedOn { get; set; } + public int Candidates { get; set; } + public int Linked { get; set; } + public int Rejected { get; set; } + public int Occupancies { get; set; } + public int UnreconciledPreplans { get; set; } + public bool CanSwitchToRecords { get; set; } + } + + public class OccupanciesResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); public int TotalCount { get; set; } } + public class OccupancyResult : StandardApiResponseV4Base { public OccupancyAggregateData Data { get; set; } } + public class OccupancySavedResult : StandardApiResponseV4Base { public OccupancyData Data { get; set; } } + public class OccupancyHazardResult : StandardApiResponseV4Base { public OccupancyHazardData Data { get; set; } } + public class OccupancyContactLinkResult : StandardApiResponseV4Base { public OccupancyContactLinkData Data { get; set; } } + public class OccupancyCrosswalksResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class OccupancyInventoryResult : StandardApiResponseV4Base { public OccupancyCrosswalkInventoryResult Data { get; set; } } + public class OccupancyReconciliationResult : StandardApiResponseV4Base { public OccupancyReconciliationData Data { get; set; } } + public class OccupancyProjectionResult : StandardApiResponseV4Base { public OccupancyDispatchProjectionV1 Data { get; set; } } + + #endregion + + #region Inspections + + public class CodeSetData { public string CodeSetId { get; set; } public string Name { get; set; } public string Edition { get; set; } public string Jurisdiction { get; set; } public bool IsActive { get; set; } } + public class CodeSectionData { public string CodeSectionId { get; set; } public string CodeSetId { get; set; } public string SectionNumber { get; set; } public string Title { get; set; } public string Text { get; set; } public int DefaultSeverity { get; set; } public int DefaultCorrectionDays { get; set; } } + public class InspectionProgramData { public string ProgramId { get; set; } public string Name { get; set; } public string Description { get; set; } public string OccupancyTypesCsv { get; set; } public int FrequencyMonths { get; set; } public string CodeSetId { get; set; } public bool IsActive { get; set; } public List Checklist { get; set; } = new List(); } + public class InspectionProgramInput { public string ProgramId { get; set; } public string Name { get; set; } public string Description { get; set; } public string OccupancyTypesCsv { get; set; } public int FrequencyMonths { get; set; } public string CodeSetId { get; set; } public bool IsActive { get; set; } = true; public List Checklist { get; set; } = new List(); } + + public class InspectionData + { + public string InspectionId { get; set; } + public string OccupancyId { get; set; } + public string ProgramId { get; set; } + public string InspectionNumber { get; set; } + public int State { get; set; } + public int Result { get; set; } + public DateTime? ScheduledOn { get; set; } + public DateTime? StartedOn { get; set; } + public DateTime? CompletedOn { get; set; } + public string InspectorUserId { get; set; } + public string Notes { get; set; } + public string SignatureName { get; set; } + public DateTime? SignedOn { get; set; } + public string ParentInspectionId { get; set; } + public DateTime? NoticeIssuedOn { get; set; } + public string NoticeReference { get; set; } + public bool IsProtected { get; set; } + public long RowVersion { get; set; } + } + + public class ViolationData + { + public string ViolationId { get; set; } + public string InspectionId { get; set; } + public string OccupancyId { get; set; } + public string CodeSetId { get; set; } + public string CodeSectionId { get; set; } + public string ChecklistItemKey { get; set; } + public string Description { get; set; } + public int Severity { get; set; } + public string CorrectiveAction { get; set; } + public DateTime? DueOn { get; set; } + public int State { get; set; } + public DateTime? CorrectedOn { get; set; } + public DateTime? VerifiedOn { get; set; } + public string ReinspectionId { get; set; } + public bool IsProtected { get; set; } + } + + public class InspectionAggregateData + { + public InspectionData Inspection { get; set; } + public InspectionProgramData Program { get; set; } + public OccupancyData Occupancy { get; set; } + public List Items { get; set; } = new List(); + public List Violations { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + public bool IsProtected { get; set; } + public List RedactedFields { get; set; } = new List(); + } + + public class ScheduleInspectionInput { public string OccupancyId { get; set; } public string ProgramId { get; set; } public DateTime? ScheduledOn { get; set; } public string InspectorUserId { get; set; } } + public class CompleteInspectionInput { public string InspectionId { get; set; } public List Items { get; set; } = new List(); public string Notes { get; set; } public string SignatureName { get; set; } } + public class ViolationInput { public string ViolationId { get; set; } public string InspectionId { get; set; } public string CodeSetId { get; set; } public string CodeSectionId { get; set; } public string Description { get; set; } public int Severity { get; set; } public string CorrectiveAction { get; set; } public DateTime? DueOn { get; set; } } + public class ViolationTransitionInput { public string ViolationId { get; set; } public int TargetState { get; set; } public string Note { get; set; } } + + public class CodeSetsResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class CodeSetResult : StandardApiResponseV4Base { public CodeSetData Data { get; set; } } + public class CodeSectionsResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class CodeSectionResult : StandardApiResponseV4Base { public CodeSectionData Data { get; set; } } + public class InspectionProgramsResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class InspectionProgramResult : StandardApiResponseV4Base { public InspectionProgramData Data { get; set; } } + public class InspectionsResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); public int TotalCount { get; set; } } + public class InspectionResult : StandardApiResponseV4Base { public InspectionAggregateData Data { get; set; } } + public class InspectionSavedResult : StandardApiResponseV4Base { public InspectionData Data { get; set; } } + public class ViolationsResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class ViolationResult : StandardApiResponseV4Base { public ViolationData Data { get; set; } } + + #endregion + + #region Hydrants + + public class HydrantData + { + public string HydrantId { get; set; } + public string HydrantNumber { get; set; } + public int Type { get; set; } + public decimal Latitude { get; set; } + public decimal Longitude { get; set; } + public string AddressText { get; set; } + public int OwnerKind { get; set; } + public string OwnerName { get; set; } + public decimal? MainSizeInches { get; set; } + public int? StaticPressurePsi { get; set; } + public int? ResidualPressurePsi { get; set; } + public int? FlowGpm { get; set; } + public int FlowClass { get; set; } + public bool InService { get; set; } + public string OutOfServiceReason { get; set; } + public DateTime? OutOfServiceSince { get; set; } + public DateTime? LastTestedOn { get; set; } + public DateTime? LastMaintainedOn { get; set; } + public string Notes { get; set; } + public int? PoiId { get; set; } + public string Source { get; set; } + public long RowVersion { get; set; } + } + + public class HydrantFlowTestData { public string FlowTestId { get; set; } public string HydrantId { get; set; } public DateTime TestedOn { get; set; } public string TestedByUserId { get; set; } public int StaticPressurePsi { get; set; } public int ResidualPressurePsi { get; set; } public int PitotPressurePsi { get; set; } public decimal OutletDiameterInches { get; set; } public decimal Coefficient { get; set; } public int FlowGpm { get; set; } public int FlowClass { get; set; } public string Notes { get; set; } } + public class HydrantMaintenanceData { public string MaintenanceId { get; set; } public string HydrantId { get; set; } public DateTime PerformedOn { get; set; } public string PerformedByUserId { get; set; } public int Kind { get; set; } public string Notes { get; set; } public bool ReturnedToService { get; set; } } + public class HydrantAggregateData { public HydrantData Hydrant { get; set; } public List FlowTests { get; set; } = new List(); public List Maintenance { get; set; } = new List(); public List Attachments { get; set; } = new List(); } + public class HydrantInput { public string HydrantId { get; set; } public string HydrantNumber { get; set; } public int Type { get; set; } public decimal Latitude { get; set; } public decimal Longitude { get; set; } public string AddressText { get; set; } public int OwnerKind { get; set; } public string OwnerName { get; set; } public decimal? MainSizeInches { get; set; } public int? FlowGpm { get; set; } public int? StaticPressurePsi { get; set; } public int? ResidualPressurePsi { get; set; } public string Notes { get; set; } public int? PoiId { get; set; } } + public class HydrantServiceStateInput { public string HydrantId { get; set; } public bool InService { get; set; } public string Reason { get; set; } } + public class HydrantFlowTestInput { public string HydrantId { get; set; } public DateTime? TestedOn { get; set; } public int StaticPressurePsi { get; set; } public int ResidualPressurePsi { get; set; } public int PitotPressurePsi { get; set; } public decimal OutletDiameterInches { get; set; } public decimal Coefficient { get; set; } public string Notes { get; set; } } + public class HydrantMaintenanceInput { public string HydrantId { get; set; } public DateTime? PerformedOn { get; set; } public int Kind { get; set; } public string Notes { get; set; } public bool ReturnedToService { get; set; } } + public class HydrantImportInput { public string Csv { get; set; } } + + public class HydrantsResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class HydrantResult : StandardApiResponseV4Base { public HydrantAggregateData Data { get; set; } } + public class HydrantSavedResult : StandardApiResponseV4Base { public HydrantData Data { get; set; } } + public class HydrantFlowTestResult : StandardApiResponseV4Base { public HydrantFlowTestData Data { get; set; } } + public class HydrantMaintenanceResult : StandardApiResponseV4Base { public HydrantMaintenanceData Data { get; set; } } + public class HydrantImportResultData : StandardApiResponseV4Base { public HydrantImportResult Data { get; set; } } + public class HydrantMapLayerResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + + #endregion + + #region Permits + + public class PermitTypeData { public string PermitTypeId { get; set; } public string Name { get; set; } public string Code { get; set; } public string Description { get; set; } public int DefaultValidityDays { get; set; } public bool RequiresPlanReview { get; set; } public decimal? FeeAmount { get; set; } public string ConditionsTemplate { get; set; } public bool IsActive { get; set; } } + public class PermitData + { + public string PermitId { get; set; } + public string PermitTypeId { get; set; } + public string OccupancyId { get; set; } + public string PermitNumber { get; set; } + public string ApplicantContactId { get; set; } + public string ApplicantName { get; set; } + public string ApplicantPhone { get; set; } + public string ApplicantEmail { get; set; } + public string Description { get; set; } + public int State { get; set; } + public DateTime AppliedOn { get; set; } + public DateTime? ReviewedOn { get; set; } + public DateTime? IssuedOn { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public string Conditions { get; set; } + public string ReviewNotes { get; set; } + public decimal? FeeAmount { get; set; } + public DateTime? FeePaidOn { get; set; } + public string InvoiceReference { get; set; } + public string DecisionReason { get; set; } + public bool IsProtected { get; set; } + public long RowVersion { get; set; } + } + public class PlanReviewData { public string PlanReviewId { get; set; } public string PermitId { get; set; } public int CycleNumber { get; set; } public DateTime SubmittedOn { get; set; } public string ReviewerUserId { get; set; } public DateTime? ReviewedOn { get; set; } public int Outcome { get; set; } public string Comments { get; set; } } + public class PermitAggregateData { public PermitData Permit { get; set; } public PermitTypeData Type { get; set; } public OccupancyData Occupancy { get; set; } public List PlanReviews { get; set; } = new List(); public List Attachments { get; set; } = new List(); public bool IsProtected { get; set; } public List RedactedFields { get; set; } = new List(); } + public class PermitInput { public string PermitId { get; set; } public long RowVersion { get; set; } public string PermitTypeId { get; set; } public string OccupancyId { get; set; } public string ApplicantContactId { get; set; } public string ApplicantName { get; set; } public string ApplicantPhone { get; set; } public string ApplicantEmail { get; set; } public string Description { get; set; } public string Conditions { get; set; } public string ReviewNotes { get; set; } public decimal? FeeAmount { get; set; } public DateTime? EffectiveOn { get; set; } public DateTime? ExpiresOn { get; set; } } + public class PermitTransitionInput { public string PermitId { get; set; } public int TargetState { get; set; } public string Reason { get; set; } public DateTime? EffectiveOn { get; set; } public DateTime? ExpiresOn { get; set; } } + public class PlanReviewInput { public string PermitId { get; set; } public int Outcome { get; set; } public string Comments { get; set; } } + public class PermitFeeInput { public string PermitId { get; set; } public decimal Amount { get; set; } public string InvoiceReference { get; set; } } + + public class PermitTypesResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class PermitTypeResult : StandardApiResponseV4Base { public PermitTypeData Data { get; set; } } + public class PermitsResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); public int TotalCount { get; set; } } + public class PermitResult : StandardApiResponseV4Base { public PermitAggregateData Data { get; set; } } + public class PermitSavedResult : StandardApiResponseV4Base { public PermitData Data { get; set; } } + public class PlanReviewResult : StandardApiResponseV4Base { public PlanReviewData Data { get; set; } } + + #endregion + + #region CRR + + public class CrrActivityData { public string ActivityId { get; set; } public int Kind { get; set; } public DateTime OccurredOn { get; set; } public string Title { get; set; } public string Description { get; set; } public string OccupancyId { get; set; } public string LocationText { get; set; } public decimal? Latitude { get; set; } public decimal? Longitude { get; set; } public int AudienceCount { get; set; } public int SmokeAlarmsInstalled { get; set; } public decimal HoursSpent { get; set; } public string StaffUserIdsCsv { get; set; } public string Outcome { get; set; } public long RowVersion { get; set; } } + public class CrrActivityInput { public string ActivityId { get; set; } public int Kind { get; set; } public DateTime? OccurredOn { get; set; } public string Title { get; set; } public string Description { get; set; } public string OccupancyId { get; set; } public string LocationText { get; set; } public decimal? Latitude { get; set; } public decimal? Longitude { get; set; } public int AudienceCount { get; set; } public int SmokeAlarmsInstalled { get; set; } public decimal HoursSpent { get; set; } public string StaffUserIdsCsv { get; set; } public string Outcome { get; set; } } + public class CrrActivitiesResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class CrrActivityResult : StandardApiResponseV4Base { public CrrActivityData Data { get; set; } } + public class CrrSummaryResult : StandardApiResponseV4Base { public CrrSummary Data { get; set; } } + + #endregion + + #region Attachments + + public class PreventionAttachmentData { public string AttachmentId { get; set; } public int ParentKind { get; set; } public string ParentId { get; set; } public string FileName { get; set; } public string ContentType { get; set; } public long ByteSize { get; set; } public string Checksum { get; set; } public string Description { get; set; } public string UploadedByUserId { get; set; } public DateTime UploadedOn { get; set; } public int ScanState { get; set; } public int Classification { get; set; } public bool IsProtected { get; set; } public string Data { get; set; } } + public class PreventionAttachmentInput { public int ParentKind { get; set; } public string ParentId { get; set; } public string FileName { get; set; } public string ContentType { get; set; } public string Data { get; set; } public string Description { get; set; } } + public class PreventionAttachmentsResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class PreventionAttachmentResult : StandardApiResponseV4Base { public PreventionAttachmentData Data { get; set; } } + + #endregion + + #region Investigations + + public class InvestigationCaseData + { + public string CaseId { get; set; } + public string CaseNumber { get; set; } + public string Title { get; set; } + public int State { get; set; } + public DateTime OpenedOn { get; set; } + public string OpenedByUserId { get; set; } + public string LeadInvestigatorUserId { get; set; } + public string OccupancyId { get; set; } + public int? CallId { get; set; } + public string IncidentSummary { get; set; } + public int CauseClassification { get; set; } + public string CauseDetail { get; set; } + public string OriginDescription { get; set; } + public string Findings { get; set; } + public string FindingsAuthorUserId { get; set; } + public DateTime? FindingsRecordedOn { get; set; } + public DateTime? FindingsApprovedOn { get; set; } + public string FindingsApprovedByUserId { get; set; } + public bool RecommendsIncidentAmendment { get; set; } + public DateTime? ClosedOn { get; set; } + public string ClosedByUserId { get; set; } + public string ClosureReason { get; set; } + public bool IsProtected { get; set; } + public long RowVersion { get; set; } + } + public class CaseMemberData { public string MemberId { get; set; } public string UserId { get; set; } public int Role { get; set; } public DateTime AddedOn { get; set; } public DateTime? RemovedOn { get; set; } } + public class CaseIncidentData { public string LinkId { get; set; } public string RecordId { get; set; } public string PinnedRevisionId { get; set; } public string RecordNumber { get; set; } public DateTime LinkedOn { get; set; } } + public class InvestigationNoteData { public string NoteId { get; set; } public int Kind { get; set; } public DateTime OccurredOn { get; set; } public string AuthorUserId { get; set; } public string Subject { get; set; } public string Body { get; set; } public bool IsLocked { get; set; } public bool IsProtected { get; set; } } + public class InvestigationEvidenceData { public string EvidenceId { get; set; } public string EvidenceNumber { get; set; } public int Kind { get; set; } public string Description { get; set; } public DateTime CollectedOn { get; set; } public string CollectedByUserId { get; set; } public string CollectedFrom { get; set; } public int State { get; set; } public string CurrentCustodianUserId { get; set; } public string CurrentCustodianExternal { get; set; } public string StorageLocation { get; set; } public bool IsProtected { get; set; } } + public class CustodyData { public string CustodyId { get; set; } public string EvidenceId { get; set; } public int Sequence { get; set; } public DateTime TransferredOn { get; set; } public string FromUserId { get; set; } public string FromExternal { get; set; } public string ToUserId { get; set; } public string ToExternal { get; set; } public string Reason { get; set; } public int ResultingState { get; set; } public string RecordedByUserId { get; set; } } + public class ReferralData { public string ReferralId { get; set; } public string Agency { get; set; } public DateTime ReferredOn { get; set; } public string ReferredByUserId { get; set; } public string Reason { get; set; } public string ReferenceNumber { get; set; } public int State { get; set; } } + public class InvestigationCaseAggregateData + { + public InvestigationCaseData Case { get; set; } + public int? CallerRole { get; set; } + public List Members { get; set; } = new List(); + public List Incidents { get; set; } = new List(); + public List Notes { get; set; } = new List(); + public List Evidence { get; set; } = new List(); + public List Referrals { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + public bool IsProtected { get; set; } + public string ProtectedReason { get; set; } + public List RedactedFields { get; set; } = new List(); + } + public class OpenCaseInput { public string Title { get; set; } public string OccupancyId { get; set; } public int? CallId { get; set; } public string IncidentSummary { get; set; } } + public class UpdateCaseInput { public string CaseId { get; set; } public long RowVersion { get; set; } public string Title { get; set; } public string IncidentSummary { get; set; } public string OccupancyId { get; set; } public int? CallId { get; set; } public string LeadInvestigatorUserId { get; set; } } + public class CaseMemberInput { public string CaseId { get; set; } public string UserId { get; set; } public int Role { get; set; } } + public class CaseNoteInput { public string CaseId { get; set; } public string NoteId { get; set; } public int Kind { get; set; } public DateTime? OccurredOn { get; set; } public string Subject { get; set; } public string Body { get; set; } } + public class CaseEvidenceInput { public string CaseId { get; set; } public int Kind { get; set; } public string Description { get; set; } public DateTime? CollectedOn { get; set; } public string CollectedByUserId { get; set; } public string CollectedFrom { get; set; } public string StorageLocation { get; set; } } + public class CustodyTransferInput { public string EvidenceId { get; set; } public string ToUserId { get; set; } public string ToExternal { get; set; } public string Reason { get; set; } public int ResultingState { get; set; } } + public class ReferralInput { public string CaseId { get; set; } public string Agency { get; set; } public string Reason { get; set; } public string ReferenceNumber { get; set; } } + public class FindingsInput { public string CaseId { get; set; } public int Classification { get; set; } public string CauseDetail { get; set; } public string OriginDescription { get; set; } public string Findings { get; set; } public bool RecommendsIncidentAmendment { get; set; } } + public class CaseReasonInput { public string CaseId { get; set; } public string Reason { get; set; } } + + public class InvestigationCasesResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class InvestigationCaseResult : StandardApiResponseV4Base { public InvestigationCaseAggregateData Data { get; set; } } + public class InvestigationCaseSavedResult : StandardApiResponseV4Base { public InvestigationCaseData Data { get; set; } } + public class CaseMemberResult : StandardApiResponseV4Base { public CaseMemberData Data { get; set; } } + public class CaseIncidentResult : StandardApiResponseV4Base { public CaseIncidentData Data { get; set; } } + public class InvestigationNoteResult : StandardApiResponseV4Base { public InvestigationNoteData Data { get; set; } } + public class InvestigationEvidenceResult : StandardApiResponseV4Base { public InvestigationEvidenceData Data { get; set; } } + public class CustodyResult : StandardApiResponseV4Base { public CustodyData Data { get; set; } } + public class CustodyChainResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class ReferralResult : StandardApiResponseV4Base { public ReferralData Data { get; set; } } + public class CaseAuditResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class CaseAuditEntryData { public int Action { get; set; } public string ActorUserId { get; set; } public string Purpose { get; set; } public bool Successful { get; set; } public DateTime OccurredOn { get; set; } public string DetailJson { get; set; } } + + #endregion + + #region Quality review and health + + public class QualityRubricData { public string RubricId { get; set; } public string Name { get; set; } public string DefinitionKey { get; set; } public int SampleSize { get; set; } public bool IsActive { get; set; } public List Criteria { get; set; } = new List(); } + public class QualityRubricInput { public string RubricId { get; set; } public string Name { get; set; } public string DefinitionKey { get; set; } public int SampleSize { get; set; } public bool IsActive { get; set; } = true; public List Criteria { get; set; } = new List(); } + public class QualityReviewData { public string ReviewId { get; set; } public string RubricId { get; set; } public string RecordId { get; set; } public int RecordKind { get; set; } public string RevisionId { get; set; } public string DefinitionKey { get; set; } public string RecordNumber { get; set; } public string AuthorUserId { get; set; } public int? UnitId { get; set; } public string ReviewerUserId { get; set; } public DateTime SampledOn { get; set; } public DateTime? ScoredOn { get; set; } public int? Score { get; set; } public List Criteria { get; set; } = new List(); public List Findings { get; set; } = new List(); public string Note { get; set; } public bool AmendmentRecommended { get; set; } public bool IsProtected { get; set; } } + public class QualitySampleInput { public string RubricId { get; set; } public DateTime? Since { get; set; } } + public class QualityScoreInput { public string ReviewId { get; set; } public List Findings { get; set; } = new List(); public string Note { get; set; } public bool AmendmentRecommended { get; set; } } + public class QualityRubricsResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class QualityRubricResult : StandardApiResponseV4Base { public QualityRubricData Data { get; set; } } + public class QualityReviewsResult : StandardApiResponseV4Base { public List Data { get; set; } = new List(); } + public class QualityReviewResult : StandardApiResponseV4Base { public QualityReviewData Data { get; set; } } + public class QualityTrendsResult : StandardApiResponseV4Base { public RecordsQualityTrends Data { get; set; } } + public class RecordsReleaseTelemetryResult : StandardApiResponseV4Base { public RecordsReleaseTelemetry Data { get; set; } } + public class PreventionSummaryResult : StandardApiResponseV4Base { public PreventionSummary Data { get; set; } } + + #endregion + + /// Model → v4 mapping. Entities never cross the wire (IEntity metadata, ProtectionId and attachment bytes stay server-side). + public static class RecordsRms5ApiMapper + { + public static OccupancyData ToOccupancy(RmsOccupancy o) => o == null ? null : new OccupancyData + { + OccupancyId = o.RmsOccupancyId, OccupancyNumber = o.OccupancyNumber, Name = o.Name, Status = o.Status, MergedIntoOccupancyId = o.MergedIntoOccupancyId, AddressText = o.AddressText, City = o.City, StateProvince = o.StateProvince, PostalCode = o.PostalCode, Country = o.Country, + Latitude = o.Latitude, Longitude = o.Longitude, ParcelId = o.ParcelId, ConstructionType = o.ConstructionType, RoofType = o.RoofType, OccupancyType = o.OccupancyType, Stories = o.Stories, YearBuilt = o.YearBuilt, SquareFeet = o.SquareFeet, OccupantLoad = o.OccupantLoad, + OccupancyHours = o.OccupancyHours, HasOccupantsNeedingAssistance = o.HasOccupantsNeedingAssistance, OccupantsNeedingAssistanceNotes = o.OccupantsNeedingAssistanceNotes, SprinklerType = o.SprinklerType, HasStandpipe = o.HasStandpipe, HasFireAlarm = o.HasFireAlarm, FdcLocation = o.FdcLocation, + GasShutoffLocation = o.GasShutoffLocation, ElectricShutoffLocation = o.ElectricShutoffLocation, WaterShutoffLocation = o.WaterShutoffLocation, UtilityNotes = o.UtilityNotes, KnoxBoxLocation = o.KnoxBoxLocation, GateCode = o.GateCode, AlarmPanelLocation = o.AlarmPanelLocation, + AlarmCompany = o.AlarmCompany, AlarmCompanyPhone = o.AlarmCompanyPhone, AccessNotes = o.AccessNotes, NearestHydrantId = o.NearestHydrantId, RequiredFireFlowGpm = o.RequiredFireFlowGpm, WaterSupplyNotes = o.WaterSupplyNotes, EmergencyContactName = o.EmergencyContactName, EmergencyContactPhone = o.EmergencyContactPhone, + HazmatOnSite = o.HazmatOnSite, GeneralHazardNotes = o.GeneralHazardNotes, TacticalSummary = o.TacticalSummary, PoiId = o.PoiId, LastReviewedOn = o.LastReviewedOn, ReviewedByUserId = o.ReviewedByUserId, NextReviewDue = o.NextReviewDue, IsReviewOverdue = o.IsReviewOverdue(DateTime.UtcNow), LastInspectedOn = o.LastInspectedOn, + IsProtected = o.IsProtected, CreatedOn = o.CreatedOn, ModifiedOn = o.ModifiedOn, RowVersion = o.RowVersion + }; + + public static RmsOccupancy FromOccupancy(OccupancyInput i) => new RmsOccupancy + { + RmsOccupancyId = i.OccupancyId, RowVersion = i.RowVersion, Name = i.Name, Status = i.Status, AddressText = i.AddressText, City = i.City, StateProvince = i.StateProvince, PostalCode = i.PostalCode, Country = i.Country, Latitude = i.Latitude, Longitude = i.Longitude, ParcelId = i.ParcelId, + ConstructionType = i.ConstructionType, RoofType = i.RoofType, OccupancyType = i.OccupancyType, Stories = i.Stories, YearBuilt = i.YearBuilt, SquareFeet = i.SquareFeet, OccupantLoad = i.OccupantLoad, OccupancyHours = i.OccupancyHours, HasOccupantsNeedingAssistance = i.HasOccupantsNeedingAssistance, + OccupantsNeedingAssistanceNotes = i.OccupantsNeedingAssistanceNotes, SprinklerType = i.SprinklerType, HasStandpipe = i.HasStandpipe, HasFireAlarm = i.HasFireAlarm, FdcLocation = i.FdcLocation, GasShutoffLocation = i.GasShutoffLocation, ElectricShutoffLocation = i.ElectricShutoffLocation, WaterShutoffLocation = i.WaterShutoffLocation, + UtilityNotes = i.UtilityNotes, KnoxBoxLocation = i.KnoxBoxLocation, GateCode = i.GateCode, AlarmPanelLocation = i.AlarmPanelLocation, AlarmCompany = i.AlarmCompany, AlarmCompanyPhone = i.AlarmCompanyPhone, AccessNotes = i.AccessNotes, NearestHydrantId = i.NearestHydrantId, RequiredFireFlowGpm = i.RequiredFireFlowGpm, + WaterSupplyNotes = i.WaterSupplyNotes, EmergencyContactName = i.EmergencyContactName, EmergencyContactPhone = i.EmergencyContactPhone, HazmatOnSite = i.HazmatOnSite, GeneralHazardNotes = i.GeneralHazardNotes, TacticalSummary = i.TacticalSummary, PoiId = i.PoiId, NextReviewDue = i.NextReviewDue + }; + + public static OccupancyHazardData ToHazard(RmsOccupancyHazard h) => h == null ? null : new OccupancyHazardData { HazardId = h.RmsOccupancyHazardId, OccupancyId = h.RmsOccupancyId, HazardType = h.HazardType, Severity = h.Severity, Title = h.Title, Description = h.Description, LocationDescription = h.LocationDescription, GpsCoordinates = h.GpsCoordinates, ShouldAlert = h.ShouldAlert, SourceContactPreplanHazardId = h.SourceContactPreplanHazardId, IsProtected = h.IsProtected }; + public static OccupancyContactLinkData ToLink(RmsOccupancyContactLink l) => l == null ? null : new OccupancyContactLinkData { LinkId = l.RmsOccupancyContactLinkId, OccupancyId = l.RmsOccupancyId, ContactId = l.ContactId, Role = l.Role, IsPrimary = l.IsPrimary }; + public static OccupancyCrosswalkData ToCrosswalk(RmsOccupancyCrosswalk c) => c == null ? null : new OccupancyCrosswalkData { CrosswalkId = c.RmsOccupancyCrosswalkId, OccupancyId = c.RmsOccupancyId, SourceKind = c.SourceKind, SourceId = c.SourceId, ContactId = c.ContactId, SourceDisplayName = c.SourceDisplayName, NormalizedAddress = c.NormalizedAddress, Latitude = c.Latitude, Longitude = c.Longitude, MatchConfidence = c.MatchConfidence, MatchReason = c.MatchReason, SuggestedOccupancyId = c.SuggestedOccupancyId, GroupKey = c.GroupKey, State = c.State, DecidedOn = c.DecidedOn, InventoriedOn = c.InventoriedOn }; + + public static OccupancyAggregateData ToOccupancyAggregate(OccupancyAggregate a) => a == null ? null : new OccupancyAggregateData + { + Occupancy = ToOccupancy(a.Occupancy), Hazards = a.Hazards.Select(ToHazard).ToList(), ContactLinks = a.ContactLinks.Select(ToLink).ToList(), + Provenance = a.Provenance.Select(p => new OccupancyProvenanceData { FieldKey = p.FieldKey, SourceKind = p.SourceKind, SourceId = p.SourceId, CapturedOn = p.CapturedOn, ReviewedOn = p.ReviewedOn }).ToList(), + Crosswalks = a.Crosswalks.Select(ToCrosswalk).ToList(), OpenViolationCount = a.OpenViolationCount, IsProtected = a.Protection.IsProtected, ProtectedReason = a.Protection.ProtectedReason, RedactedFields = a.Protection.RedactedFields.Distinct().ToList() + }; + + public static OccupancyReconciliationData ToReconciliation(OccupancyReconciliationStatus s) => new OccupancyReconciliationData { State = s.State.ToString(), InventoriedOn = s.InventoriedOn, SwitchedOn = s.SwitchedOn, Candidates = s.Candidates, Linked = s.Linked, Rejected = s.Rejected, Occupancies = s.Occupancies, UnreconciledPreplans = s.UnreconciledPreplans, CanSwitchToRecords = s.CanSwitchToRecords }; + + public static CodeSetData ToCodeSet(RmsCodeSet c) => c == null ? null : new CodeSetData { CodeSetId = c.RmsCodeSetId, Name = c.Name, Edition = c.Edition, Jurisdiction = c.Jurisdiction, IsActive = c.IsActive }; + public static CodeSectionData ToCodeSection(RmsCodeSection s) => s == null ? null : new CodeSectionData { CodeSectionId = s.RmsCodeSectionId, CodeSetId = s.RmsCodeSetId, SectionNumber = s.SectionNumber, Title = s.Title, Text = s.Text, DefaultSeverity = s.DefaultSeverity, DefaultCorrectionDays = s.DefaultCorrectionDays }; + public static InspectionProgramData ToProgram(RmsInspectionProgram p) => p == null ? null : new InspectionProgramData { ProgramId = p.RmsInspectionProgramId, Name = p.Name, Description = p.Description, OccupancyTypesCsv = p.OccupancyTypesCsv, FrequencyMonths = p.FrequencyMonths, CodeSetId = p.RmsCodeSetId, IsActive = p.IsActive, Checklist = Resgrid.Services.Records.RecordsInspectionsService.ParseChecklist(p.ChecklistJson) }; + public static InspectionData ToInspection(RmsInspection i) => i == null ? null : new InspectionData { InspectionId = i.RmsInspectionId, OccupancyId = i.RmsOccupancyId, ProgramId = i.RmsInspectionProgramId, InspectionNumber = i.InspectionNumber, State = i.State, Result = i.Result, ScheduledOn = i.ScheduledOn, StartedOn = i.StartedOn, CompletedOn = i.CompletedOn, InspectorUserId = i.InspectorUserId, Notes = i.Notes, SignatureName = i.SignatureName, SignedOn = i.SignedOn, ParentInspectionId = i.ParentInspectionId, NoticeIssuedOn = i.NoticeIssuedOn, NoticeReference = i.NoticeReference, IsProtected = i.IsProtected, RowVersion = i.RowVersion }; + public static ViolationData ToViolation(RmsViolation v) => v == null ? null : new ViolationData { ViolationId = v.RmsViolationId, InspectionId = v.RmsInspectionId, OccupancyId = v.RmsOccupancyId, CodeSetId = v.RmsCodeSetId, CodeSectionId = v.RmsCodeSectionId, ChecklistItemKey = v.ChecklistItemKey, Description = v.Description, Severity = v.Severity, CorrectiveAction = v.CorrectiveAction, DueOn = v.DueOn, State = v.State, CorrectedOn = v.CorrectedOn, VerifiedOn = v.VerifiedOn, ReinspectionId = v.ReinspectionId, IsProtected = v.IsProtected }; + public static InspectionAggregateData ToInspectionAggregate(InspectionAggregate a) => a == null ? null : new InspectionAggregateData { Inspection = ToInspection(a.Inspection), Program = ToProgram(a.Program), Occupancy = ToOccupancy(a.Occupancy), Items = a.Items, Violations = a.Violations.Select(ToViolation).ToList(), Attachments = a.Attachments.Select(x => ToAttachment(x, false)).ToList(), IsProtected = a.Protection.IsProtected, RedactedFields = a.Protection.RedactedFields.Distinct().ToList() }; + + public static HydrantData ToHydrant(RmsHydrant h) => h == null ? null : new HydrantData { HydrantId = h.RmsHydrantId, HydrantNumber = h.HydrantNumber, Type = h.Type, Latitude = h.Latitude, Longitude = h.Longitude, AddressText = h.AddressText, OwnerKind = h.OwnerKind, OwnerName = h.OwnerName, MainSizeInches = h.MainSizeInches, StaticPressurePsi = h.StaticPressurePsi, ResidualPressurePsi = h.ResidualPressurePsi, FlowGpm = h.FlowGpm, FlowClass = h.FlowClass, InService = h.InService, OutOfServiceReason = h.OutOfServiceReason, OutOfServiceSince = h.OutOfServiceSince, LastTestedOn = h.LastTestedOn, LastMaintainedOn = h.LastMaintainedOn, Notes = h.Notes, PoiId = h.PoiId, Source = h.Source, RowVersion = h.RowVersion }; + public static HydrantFlowTestData ToFlowTest(RmsHydrantFlowTest t) => t == null ? null : new HydrantFlowTestData { FlowTestId = t.RmsHydrantFlowTestId, HydrantId = t.RmsHydrantId, TestedOn = t.TestedOn, TestedByUserId = t.TestedByUserId, StaticPressurePsi = t.StaticPressurePsi, ResidualPressurePsi = t.ResidualPressurePsi, PitotPressurePsi = t.PitotPressurePsi, OutletDiameterInches = t.OutletDiameterInches, Coefficient = t.Coefficient, FlowGpm = t.FlowGpm, FlowClass = t.FlowClass, Notes = t.Notes }; + public static HydrantMaintenanceData ToMaintenance(RmsHydrantMaintenance m) => m == null ? null : new HydrantMaintenanceData { MaintenanceId = m.RmsHydrantMaintenanceId, HydrantId = m.RmsHydrantId, PerformedOn = m.PerformedOn, PerformedByUserId = m.PerformedByUserId, Kind = m.Kind, Notes = m.Notes, ReturnedToService = m.ReturnedToService }; + public static HydrantAggregateData ToHydrantAggregate(HydrantAggregate a) => a == null ? null : new HydrantAggregateData { Hydrant = ToHydrant(a.Hydrant), FlowTests = a.FlowTests.Select(ToFlowTest).ToList(), Maintenance = a.Maintenance.Select(ToMaintenance).ToList(), Attachments = a.Attachments.Select(x => ToAttachment(x, false)).ToList() }; + + public static PermitTypeData ToPermitType(RmsPermitType t) => t == null ? null : new PermitTypeData { PermitTypeId = t.RmsPermitTypeId, Name = t.Name, Code = t.Code, Description = t.Description, DefaultValidityDays = t.DefaultValidityDays, RequiresPlanReview = t.RequiresPlanReview, FeeAmount = t.FeeAmount, ConditionsTemplate = t.ConditionsTemplate, IsActive = t.IsActive }; + public static PermitData ToPermit(RmsPermit p) => p == null ? null : new PermitData { PermitId = p.RmsPermitId, PermitTypeId = p.RmsPermitTypeId, OccupancyId = p.RmsOccupancyId, PermitNumber = p.PermitNumber, ApplicantContactId = p.ApplicantContactId, ApplicantName = p.ApplicantName, ApplicantPhone = p.ApplicantPhone, ApplicantEmail = p.ApplicantEmail, Description = p.Description, State = p.State, AppliedOn = p.AppliedOn, ReviewedOn = p.ReviewedOn, IssuedOn = p.IssuedOn, EffectiveOn = p.EffectiveOn, ExpiresOn = p.ExpiresOn, Conditions = p.Conditions, ReviewNotes = p.ReviewNotes, FeeAmount = p.FeeAmount, FeePaidOn = p.FeePaidOn, InvoiceReference = p.InvoiceReference, DecisionReason = p.DecisionReason, IsProtected = p.IsProtected, RowVersion = p.RowVersion }; + public static PlanReviewData ToPlanReview(RmsPlanReview r) => r == null ? null : new PlanReviewData { PlanReviewId = r.RmsPlanReviewId, PermitId = r.RmsPermitId, CycleNumber = r.CycleNumber, SubmittedOn = r.SubmittedOn, ReviewerUserId = r.ReviewerUserId, ReviewedOn = r.ReviewedOn, Outcome = r.Outcome, Comments = r.Comments }; + public static PermitAggregateData ToPermitAggregate(PermitAggregate a) => a == null ? null : new PermitAggregateData { Permit = ToPermit(a.Permit), Type = ToPermitType(a.Type), Occupancy = ToOccupancy(a.Occupancy), PlanReviews = a.PlanReviews.Select(ToPlanReview).ToList(), Attachments = a.Attachments.Select(x => ToAttachment(x, false)).ToList(), IsProtected = a.Protection.IsProtected, RedactedFields = a.Protection.RedactedFields.Distinct().ToList() }; + public static RmsPermit FromPermit(PermitInput i) => new RmsPermit { RmsPermitId = i.PermitId, RowVersion = i.RowVersion, RmsPermitTypeId = i.PermitTypeId, RmsOccupancyId = i.OccupancyId, ApplicantContactId = i.ApplicantContactId, ApplicantName = i.ApplicantName, ApplicantPhone = i.ApplicantPhone, ApplicantEmail = i.ApplicantEmail, Description = i.Description, Conditions = i.Conditions, ReviewNotes = i.ReviewNotes, FeeAmount = i.FeeAmount, EffectiveOn = i.EffectiveOn, ExpiresOn = i.ExpiresOn }; + + public static CrrActivityData ToCrr(RmsCrrActivity a) => a == null ? null : new CrrActivityData { ActivityId = a.RmsCrrActivityId, Kind = a.Kind, OccurredOn = a.OccurredOn, Title = a.Title, Description = a.Description, OccupancyId = a.RmsOccupancyId, LocationText = a.LocationText, Latitude = a.Latitude, Longitude = a.Longitude, AudienceCount = a.AudienceCount, SmokeAlarmsInstalled = a.SmokeAlarmsInstalled, HoursSpent = a.HoursSpent, StaffUserIdsCsv = a.StaffUserIdsCsv, Outcome = a.Outcome, RowVersion = a.RowVersion }; + public static RmsCrrActivity FromCrr(CrrActivityInput i) => new RmsCrrActivity { RmsCrrActivityId = i.ActivityId, Kind = i.Kind, OccurredOn = i.OccurredOn ?? default, Title = i.Title, Description = i.Description, RmsOccupancyId = i.OccupancyId, LocationText = i.LocationText, Latitude = i.Latitude, Longitude = i.Longitude, AudienceCount = i.AudienceCount, SmokeAlarmsInstalled = i.SmokeAlarmsInstalled, HoursSpent = i.HoursSpent, StaffUserIdsCsv = i.StaffUserIdsCsv, Outcome = i.Outcome }; + + public static PreventionAttachmentData ToAttachment(RmsPreventionAttachment a, bool includeData) => a == null ? null : new PreventionAttachmentData { AttachmentId = a.RmsPreventionAttachmentId, ParentKind = a.ParentKind, ParentId = a.ParentId, FileName = a.FileName, ContentType = a.ContentType, ByteSize = a.ByteSize, Checksum = a.Checksum, Description = a.Description, UploadedByUserId = a.UploadedByUserId, UploadedOn = a.UploadedOn, ScanState = a.ScanState, Classification = a.Classification, IsProtected = a.IsProtected, Data = includeData && a.Data != null ? Convert.ToBase64String(a.Data) : null }; + + public static InvestigationCaseData ToCase(RmsInvestigationCase c) => c == null ? null : new InvestigationCaseData { CaseId = c.RmsInvestigationCaseId, CaseNumber = c.CaseNumber, Title = c.Title, State = c.State, OpenedOn = c.OpenedOn, OpenedByUserId = c.OpenedByUserId, LeadInvestigatorUserId = c.LeadInvestigatorUserId, OccupancyId = c.RmsOccupancyId, CallId = c.CallId, IncidentSummary = c.IncidentSummary, CauseClassification = c.CauseClassification, CauseDetail = c.CauseDetail, OriginDescription = c.OriginDescription, Findings = c.Findings, FindingsAuthorUserId = c.FindingsAuthorUserId, FindingsRecordedOn = c.FindingsRecordedOn, FindingsApprovedOn = c.FindingsApprovedOn, FindingsApprovedByUserId = c.FindingsApprovedByUserId, RecommendsIncidentAmendment = c.RecommendsIncidentAmendment, ClosedOn = c.ClosedOn, ClosedByUserId = c.ClosedByUserId, ClosureReason = c.ClosureReason, IsProtected = c.IsProtected, RowVersion = c.RowVersion }; + public static CaseMemberData ToMember(RmsInvestigationCaseMember m) => m == null ? null : new CaseMemberData { MemberId = m.RmsInvestigationCaseMemberId, UserId = m.UserId, Role = m.Role, AddedOn = m.AddedOn, RemovedOn = m.RemovedOn }; + public static CaseIncidentData ToIncident(RmsInvestigationCaseIncident i) => i == null ? null : new CaseIncidentData { LinkId = i.RmsInvestigationCaseIncidentId, RecordId = i.RecordId, PinnedRevisionId = i.PinnedRevisionId, RecordNumber = i.RecordNumber, LinkedOn = i.LinkedOn }; + public static InvestigationNoteData ToNote(RmsInvestigationNote n) => n == null ? null : new InvestigationNoteData { NoteId = n.RmsInvestigationNoteId, Kind = n.Kind, OccurredOn = n.OccurredOn, AuthorUserId = n.AuthorUserId, Subject = n.Subject, Body = n.Body, IsLocked = n.IsLocked, IsProtected = n.IsProtected }; + public static InvestigationEvidenceData ToEvidence(RmsInvestigationEvidence e) => e == null ? null : new InvestigationEvidenceData { EvidenceId = e.RmsInvestigationEvidenceId, EvidenceNumber = e.EvidenceNumber, Kind = e.Kind, Description = e.Description, CollectedOn = e.CollectedOn, CollectedByUserId = e.CollectedByUserId, CollectedFrom = e.CollectedFrom, State = e.State, CurrentCustodianUserId = e.CurrentCustodianUserId, CurrentCustodianExternal = e.CurrentCustodianExternal, StorageLocation = e.StorageLocation, IsProtected = e.IsProtected }; + public static CustodyData ToCustody(RmsInvestigationCustody c) => c == null ? null : new CustodyData { CustodyId = c.RmsInvestigationCustodyId, EvidenceId = c.RmsInvestigationEvidenceId, Sequence = c.Sequence, TransferredOn = c.TransferredOn, FromUserId = c.FromUserId, FromExternal = c.FromExternal, ToUserId = c.ToUserId, ToExternal = c.ToExternal, Reason = c.Reason, ResultingState = c.ResultingState, RecordedByUserId = c.RecordedByUserId }; + public static ReferralData ToReferral(RmsInvestigationReferral r) => r == null ? null : new ReferralData { ReferralId = r.RmsInvestigationReferralId, Agency = r.Agency, ReferredOn = r.ReferredOn, ReferredByUserId = r.ReferredByUserId, Reason = r.Reason, ReferenceNumber = r.ReferenceNumber, State = r.State }; + public static InvestigationCaseAggregateData ToCaseAggregate(InvestigationCaseAggregate a) => a == null ? null : new InvestigationCaseAggregateData + { + Case = ToCase(a.Case), CallerRole = a.CallerRole.HasValue ? (int)a.CallerRole.Value : (int?)null, Members = a.Members.Select(ToMember).ToList(), Incidents = a.Incidents.Select(ToIncident).ToList(), Notes = a.Notes.Select(ToNote).ToList(), + Evidence = a.Evidence.Select(ToEvidence).ToList(), Referrals = a.Referrals.Select(ToReferral).ToList(), Attachments = a.Attachments.Select(x => ToAttachment(x, false)).ToList(), + IsProtected = a.Protection.IsProtected, ProtectedReason = a.Protection.ProtectedReason, RedactedFields = a.Protection.RedactedFields.Distinct().ToList() + }; + + public static QualityRubricData ToRubric(RmsQualityRubric r) => r == null ? null : new QualityRubricData { RubricId = r.RmsQualityRubricId, Name = r.Name, DefinitionKey = r.DefinitionKey, SampleSize = r.SampleSize, IsActive = r.IsActive, Criteria = Resgrid.Services.Records.RecordsQualityReviewService.ParseCriteria(r.CriteriaJson) }; + public static QualityReviewData ToReview(RmsQualityReview r) => r == null ? null : new QualityReviewData { ReviewId = r.RmsQualityReviewId, RubricId = r.RmsQualityRubricId, RecordId = r.RecordId, RecordKind = r.RecordKind, RevisionId = r.RevisionId, DefinitionKey = r.DefinitionKey, RecordNumber = r.RecordNumber, AuthorUserId = r.AuthorUserId, UnitId = r.UnitId, ReviewerUserId = r.ReviewerUserId, SampledOn = r.SampledOn, ScoredOn = r.ScoredOn, Score = r.Score, Criteria = Resgrid.Services.Records.RecordsQualityReviewService.ParseCriteria(r.CriteriaJson), Findings = Resgrid.Services.Records.RecordsQualityReviewService.ParseFindings(r.FindingsJson), Note = r.Note, AmendmentRecommended = r.AmendmentRecommended, IsProtected = r.IsProtected }; + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index dc165a99d..36bb96841 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -357,6 +357,15 @@ Id of the call trying to be retrived CallResult of the call in the Resgrid system + + + Gets the site information for every contact linked to a call in one round trip: the contact, + its pre-incident plan, premise hazards, live alert notes and file metadata with signed download + links (Contacts plan Phase A, decision 3b). This is the RMS/NERIS authoring prefill contract for + calls linked to a Contact: additive changes only. Signed file links expire; do not persist them. + + Id of the call + Gets all the meta-data around a call, dispatched personnel, units, groups and responses @@ -440,6 +449,18 @@ End date as UTC to get calls for Array of CallResult objects for each call in the department within the range + + Validates the requested contact links belong to the department; null when any does not, empty when none were requested. + + + + Fills CallResultData.Contacts for the given calls (Contacts plan Phase A, decision 3a). Contact + names honor the caller's protected-read grant: plaintext with a grant, REDACTED without. + + + + A redacted name part collapses the whole display name to the REDACTED placeholder. + Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department @@ -1326,6 +1347,60 @@ Performs a timing-safe comparison of two secret strings to prevent timing attacks. + + + Files attached to Contacts: site documents, pre-plans, floor plans, site photos and drawings + (Contacts plan Phase A). Mirrors CallFiles: metadata lists carry a signed, expiring anonymous + download URL; uploads are base64. + + + + Signed-link kind marker; keeps a contact-file link from validating as a call-file link and vice versa. + + + The caller's Protected Data Grant, when presented (plan section 3.1 step 6). + + + + Gets the files attached to a contact + + Contact to list files for + Include the base64 data in the result (metadata only otherwise) + Optional type filter (Document = 0, PrePlan = 1, FloorPlan = 2, SitePhoto = 3, SiteDrawing = 4, Other = 5); omit for all + + + + Streams a contact file from a signed link (as issued by GetFilesForContact / GetCallSiteInfo) + + Signed link token + + + + Existence check for a signed contact file link + + Signed link token + + + + Attaches a file to a contact + + + + + Removes a file from a contact + + Id of the contact file + + + + Validates the decrypted signed-link payload "c|dept|attachmentId|expiresUtcTicks". The leading kind + marker means a call-file link never resolves here and a contact-file link never resolves on CallFiles. + Expired or malformed reads as not-found. + + + + Builds the signed, expiring anonymous download URL for a contact attachment. + Contacts, which are people, entities, and things that can be contacted (i.e. people, departments, groups, etc.) to dispatch a call to. @@ -1355,6 +1430,49 @@ + + + Gets the pre-incident plan for a contact (with its hazards). Data is null when the contact has none. + + Id of the contact + + + + Creates or replaces the pre-incident plan for a contact + + + + + Removes the pre-incident plan (and its hazards) from a contact + + Id of the contact + + + + Gets the premise hazards for a contact, most severe first + + Id of the contact + + + + Creates (no id) or updates (with id) a premise hazard on a contact + + + + + Removes a premise hazard from a contact + + Id of the hazard + + + The caller's Protected Data Grant, when presented (plan section 3.1 step 6). + + + Maps a blocked protected write to its value-free problem response (plan 3.3/19.2). + + + Stamps the batch-level protection metadata onto a pre-plan DTO and its hazards (per-row redaction ids come from the converters). + Returns true if the current caller holds a group-admin claim for any group. @@ -2750,6 +2868,74 @@ The stored file of a run; under ADP the bytes are opened through the seam for this caller. + + RMS-5 hydrants and water sources (RMS plan section 4.3): flow tests, maintenance, service state, CSV import and the response-map layer. + + + CSV import (number, latitude, longitude, type, address, main_size, flow_gpm, owner). + + + Map layer for the response map and the apps; bounded to a viewport when bounds are given. + + + RMS-5 inspection programs, adopted code sets, inspections, violations and re-inspection (RMS plan section 4.3). + + + CSV import of code sections: section,title,text,severity,days. + + + + RMS-5 investigation cases (RMS plan section 4.4). RecordRestricted_View is the policy; the service enforces + case membership on top of it and audits every read, so a department administrator without a membership sees + nothing here. Findings never touch the linked incident revision. + + + + The case packet (JSON); audited as an export and refused while any content is concealed. + + + + RMS-5 occupancy/property master (RMS plan section 4.3): the structure the department pre-plans, inspects and + permits, the ContactPreplan/Contact/POI crosswalk, the structure-write ownership switch and + OccupancyDispatchProjectionV1. Reads need Record_View; changes need Record_PreventionAdmin. + + + + Runs the ContactPreplan/Contact/POI inventory into crosswalk candidates. + + + Binds a candidate to an occupancy, or creates one from the source when occupancyId is empty. + + + Switches structure writes from Contacts pre-plans to RMS. Refused until every candidate and pre-plan is decided. + + + OccupancyDispatchProjectionV1 for an occupancy, or for the occupancy a contact is linked to. + + + RMS-5 permits, plan review, conditions, expiration and the optional fee reference (RMS plan section 4.3). + + + RMS-5 community risk reduction activities (RMS plan section 4.3). + + + RMS-4 optional post-finalization quality review (RMS plan section 4.7): rubrics, sampling, scoring and trends. Rubrics are department-administrator work; sampling and scoring need Record_Review. + + + QA notes attached to a record, for the record detail view. Needs only visibility of the record. + + + Files on prevention and investigation aggregates. Authorization follows the parent (see the service). + + + Uploads a base64 file through the record-attachment hygiene and scanner pipeline. + + + RMS-4 release telemetry and the prevention summary (plan section 6, RMS-4 "baseline telemetry"). + + + Department-administrator only: legacy-write attempts, outbox lag, Workflow run outcomes, scan states and the prevention counters. + Department saved reports over v4 (RMS plan section 4.1, RMS-1B): allowlisted typed columns, bounded filters, @@ -2914,6 +3100,13 @@ Loads a record only when the caller passes the per-record visibility rule; a refusal is audited as Denied. + + + Shared gate for the RMS-5 prevention/investigation and RMS-4 quality/health surfaces: the Records flag first, member + principals only (a system principal's department-scoped Record_View grant covers Records, never prevention data or + investigation cases), and one exception-to-status mapping so every module answers the same way. + + RecordOperationalSummaryV1 over the v4 contract (RMS plan sections 5.1 and 4.7): the authorization-scoped, @@ -7535,6 +7728,101 @@ Whether check-in timers are enabled for this call + + + Contacts linked to this call (primary first) with pre-plan / alert / hazard indicators. + Populated by GetCall and GetActiveCalls; empty on other list endpoints. Use + GetCallSiteInfo for the full site knowledge. Contacts plan Phase A, decision 3a. + + + + + A contact linked to a call, as carried on the call payload (Contacts plan Phase A, decision 3a). + + + + Id of the contact + + + Display name (person or company). REDACTED in a protected department without a grant. + + + 0 = Person, 1 = Company + + + 0 = Primary, 1 = Additional + + + True when the contact has a pre-incident plan + + + Count of live ShouldAlert notes + + + Count of live premise hazards + + + + Site information for every contact linked to a call, composed in one round trip + (Contacts plan Phase A, decision 3b). Also the RMS NERIS authoring prefill source (A8): additive changes only. + + + + + Response Data + + + + Id of the call + + + + ADP: true when the department enforces protection; contact identity fields are then decrypted + (valid grant) or the exact "REDACTED" placeholder. Pre-plan and hazard text is not cataloged. + + + + ADP: machine-readable reason when contact fields are redacted; null when nothing is redacted. + + + Linked contacts, primary first + + + Id of the contact + + + Display name (person or company) + + + 0 = Person, 1 = Company + + + 0 = Primary, 1 = Additional + + + Physical location coordinates ("lat,lng") when known + + + Entrance coordinates ("lat,lng") when known + + + Best phone number on file (cell, then office, then home) + + + The pre-incident plan, null when none + + + RMS-5: the Records occupancy the pre-plan is projected from, when the department has switched pre-plan ownership to Records. Null otherwise (additive, contract unchanged). + + + Live premise hazards, most severe first + + + Live ShouldAlert notes (not expired) + + + Attachment metadata with signed download URLs (no Data) + Input information to close a call @@ -7635,6 +7923,18 @@ Contact Info + + + Id of the primary Contact (the premises/customer record) to link to the call. Optional; + the contact must belong to the department. Contacts plan Phase A (A5). + + + + + Ids of additional Contacts to link to the call. Optional. On EditCall, supplying ContactId or + AdditionalContactIds (even empty) replaces the call's existing contact links; omitting both leaves them unchanged. + + External Call Id @@ -7804,6 +8104,18 @@ Contact Info (phone number, email, etc) + + + Id of the primary Contact (the premises/customer record) to link to the call. Optional; + the contact must belong to the department. Contacts plan Phase A (A5). + + + + + Ids of additional Contacts to link to the call. Optional. On EditCall, supplying ContactId or + AdditionalContactIds (even empty) replaces the call's existing contact links; omitting both leaves them unchanged. + + External Call Id @@ -10178,6 +10490,150 @@ Resgrid Datacenter Locations + + + Files attached to a contact (site documents, pre-plans, floor plans, photos, drawings). + + + + + Data payload + + + + + One file attached to a contact. Metadata always; Data only when requested. + + + + + ADP: true when this row belongs to a protection-enforced department (shield indicator). + Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + — never ciphertext. + + + + ADP: machine-readable reason when values are redacted (step_up_required, + grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + nothing is redacted. + + + ADP: stable catalog field ids (catalog v12) whose values are REDACTED on this row. + + + Id of the contact file + + + Id of the Contact + + + Type of the file (Document = 0, PrePlan = 1, FloorPlan = 2, SitePhoto = 3, SiteDrawing = 4, Other = 5) + + + Type display name + + + User friendly title of the file + + + Original file name + + + Mime type + + + Size in bytes + + + Base64 file data (only when includeData was requested) + + + + Signed, expiring anonymous URL to download the file instead of using Data. Regenerated on every + authenticated list call; do not persist it (RMS plan: an authorized capture copies the evidence). + Null for a protected department's enveloped file: the anonymous route can never carry a grant, so + attended clients fetch the bytes through GetFilesForContact with includeData and their grant instead. + + + + User Id of the person who uploaded the file + + + When the file was added (department time) + + + + Input to attach a file to a contact + + + + Type of the file (Document = 0, PrePlan = 1, FloorPlan = 2, SitePhoto = 3, SiteDrawing = 4, Other = 5) + + + User friendly title (defaults to the file name) + + + Original file name, with extension + + + Base64 encoded file contents + + + Id of the new contact file + + + + Premise hazards on a contact's pre-plan (Contacts plan Phase A). + + + + + Response Data + + + + + A single premise hazard. + + + + + ADP: true when this row belongs to a protection-enforced department (shield indicator). + Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + — never ciphertext. + + + + ADP: machine-readable reason when values are redacted (step_up_required, + grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + nothing is redacted. + + + ADP: stable catalog field ids (catalog v12) whose values are REDACTED on this row. + + + ContactPreplanHazardTypes value (General = 0, Hazmat = 1, Structural = 2, Electrical = 3, Biological = 4, Animal = 5, Occupant = 6, Other = 7) + + + ContactPreplanHazardSeverities value (Info = 0, Caution = 1, Danger = 2) + + + "lat,lng" + + + Surfaces in the dispatch alert banner when true. + + + + Input to create (no id) or update (with id) a hazard. A contact without a pre-plan gets an empty one created. + + + + Omit to create a new hazard. + + + Id of the hazard row + Gets the notes for a contact @@ -10210,6 +10666,69 @@ grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when nothing is redacted. + + + A contact's pre-incident plan (Contacts plan Phase A). + + + + + Response Data (null when the contact has no pre-plan) + + + + + Structured NFPA 1620 pre-incident plan for a contact. Enum-valued fields carry the integer value; + see ContactPreplanConstructionTypes / ContactPreplanRoofTypes / ContactPreplanOccupancyTypes. + + + + + ADP: true when this row belongs to a protection-enforced department (shield indicator). + Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + — never ciphertext. + + + + ADP: machine-readable reason when values are redacted (step_up_required, + grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + nothing is redacted. + + + ADP: stable catalog field ids (catalog v12) whose values are REDACTED on this row. + + + ContactPreplanConstructionTypes value + + + ContactPreplanRoofTypes value + + + ContactPreplanOccupancyTypes value + + + ADP catalog v12 field: decrypted with a valid grant, otherwise the REDACTED placeholder. + + + True when NextReviewDue has passed. + + + Live hazards on this pre-plan. + + + + Input to create or replace a contact's pre-plan. The whole plan is written; omitted text fields clear. + + + + When true, LastReviewedOn is stamped now and ReviewedByUserId is the caller. + + + Next scheduled review (UTC). Null leaves no review scheduled. + + + Id of the pre-plan row + Gets the contact categories @@ -12675,6 +13194,9 @@ Per-row rule outcomes for repeating sections (a field rule may look at its own row). + + How many Records of this definition may exist on one Call (plan section 5.2.1). + The print layout for one definition version (RMS plan section 4.10.1). @@ -12770,6 +13292,9 @@ Crosswalk gap report: an unmapped or stale local code is a filing that will need manual work. + + Model → v4 mapping. Entities never cross the wire (IEntity metadata, ProtectionId and attachment bytes stay server-side). + One RecordOperationalSummaryV1 (RMS plan section 5.1); the model class is the versioned contract. diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 506b20c40..cb8c28659 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -322,6 +322,7 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.RecordReport_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordReport, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.RecordDisclosure_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordDisclosure, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.RecordLegalHold_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordLegalHold, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.Record_PreventionAdmin, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.PreventionAdmin)); options.AddPolicy(ResgridResources.Shift_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Shift, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.Shift_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Shift, ResgridClaimTypes.Actions.Update)); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs index 0bbdb744f..d498ce06a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; using Microsoft.AspNetCore.Mvc.Rendering; using Resgrid.Model; using Resgrid.Model.Providers; @@ -19,6 +20,8 @@ using Resgrid.WebCore.Areas.User.Models; using Resgrid.WebCore.Areas.User.Models.Contacts; using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; +using Microsoft.AspNetCore.Http; +using System.IO; namespace Resgrid.Web.Areas.User.Controllers { @@ -40,13 +43,18 @@ public class ContactsController : SecureBaseController private readonly IRouteService _routeService; private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser; private readonly IProtectedReadService _protectedReadService; + private readonly IContactPreplanOwnershipGate _preplanOwnership; + private readonly IStringLocalizer _localizer; public ContactsController(IContactsService contactsService, IDepartmentsService departmentsService, IUserProfileService userProfileService, IAddressService addressService, IEventAggregator eventAggregator, ICallsService callsService, IAuthorizationService authorizationService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, IDepartmentGroupsService departmentGroupsService, IRouteService routeService, IPhoneNumberProcesserProvider phoneNumberProcesser, - IProtectedReadService protectedReadService) + IProtectedReadService protectedReadService, IContactPreplanOwnershipGate preplanOwnership, + IStringLocalizer localizer) { + _preplanOwnership = preplanOwnership; + _localizer = localizer; _contactsService = contactsService; _departmentsService = departmentsService; _userProfileService = userProfileService; @@ -71,6 +79,8 @@ public async Task Index() model.ContactCategories = await _contactsService.GetContactCategoriesForDepartmentAsync(DepartmentId); model.Contacts = await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + model.PreplanReviewOverdueContactIds = new HashSet( + (await _contactsService.GetPreplansDueForReviewAsync(DepartmentId)).Select(x => x.ContactId), StringComparer.Ordinal); // ADP: server-rendered lists show REDACTED for protected values (no grant server-side). await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, model.Contacts, null, UserId); @@ -148,6 +158,16 @@ public async Task View(string contactId) model.Notes = await _contactsService.GetContactNotesByContactIdAsync(contactId, DepartmentId); + // Contacts plan Phase A: Pre-Plan and Files tabs. ADP catalog v12: render REDACTED; RevealContact + // returns the pre-plan and hazard values alongside the contact's own fields. + model.Preplan = await _contactsService.GetPreplanByContactIdAsync(contactId, DepartmentId); + model.Hazards = await _contactsService.GetHazardsByContactIdAsync(contactId, DepartmentId); + model.Attachments = await _contactsService.GetContactAttachmentsAsync(contactId, DepartmentId); + if (model.Preplan != null) + await _protectedReadService.ResolveContactPreplansForReadAsync(DepartmentId, new List { model.Preplan }, null, UserId); + await _protectedReadService.ResolveContactPreplanHazardsForReadAsync(DepartmentId, model.Hazards, null, UserId); + await _protectedReadService.ResolveContactAttachmentsForReadAsync(DepartmentId, model.Attachments, null, UserId); + // ADP: render REDACTED; the reveal is client-side (step-up modal then RevealContact). var protectedRead = await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, new List { model.Contact }, null, UserId); @@ -983,6 +1003,12 @@ public async Task RevealContact([FromForm] string contactId) var fields = Resgrid.Services.ProtectedReadService.ContactFieldAccessors .ToDictionary(a => a.Key, a => a.Value.Get(contact)); + // Catalog v12: the View page's Pre-Plan tab marks its values with the pre-plan/hazard id as a + // suffix, so one step-up reveals the contact, its plan and its hazards together. + var preplanReveal = await AddPreplanRevealFieldsAsync(fields, contactId, grantToken, suffixed: true); + if (preplanReveal != null && preplanReveal.IsProtected && preplanReveal.ProtectedReason != null) + return Json(new { success = false, error = preplanReveal.ProtectedReason }); + // The contact's UDF values are cataloged too, and the form renders them as inputs marked // for this module. Revealing the contact but leaving its custom fields showing the // placeholder would be an odd half-reveal of the same record. @@ -1110,5 +1136,528 @@ public async Task GetCallsJson(string contactId) return Json(callsJson); } + #region Pre-plans and site attachments (Contacts plan Phase A, A6) + + private static readonly HashSet AllowedAttachmentExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "ppt", "pptx", "pps", "ppsx", "odt", "xls", "xlsx", "txt", "csv", "dwg", "dxf" + }; + + [HttpGet] + [Authorize(Policy = ResgridResources.Contacts_Update)] + public async Task Preplan(string contactId) + { + var model = await BuildPreplanViewAsync(contactId, null); + if (model == null) + return Unauthorized(); + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Contacts_Update)] + public async Task Preplan(ContactPreplanView model, CancellationToken cancellationToken) + { + if (model?.Contact == null || string.IsNullOrWhiteSpace(model.Contact.ContactId)) + return BadRequest(); + + var contact = await _contactsService.GetContactByIdAsync(model.Contact.ContactId); + if (contact == null || contact.IsDeleted || contact.DepartmentId != DepartmentId) + return Unauthorized(); + + var posted = model.Preplan ?? new ContactPreplan(); + + if (!Enum.IsDefined(typeof(ContactPreplanConstructionTypes), posted.ConstructionType) || + !Enum.IsDefined(typeof(ContactPreplanRoofTypes), posted.RoofType) || + !Enum.IsDefined(typeof(ContactPreplanOccupancyTypes), posted.OccupancyType)) + ModelState.AddModelError("Preplan", "Unknown construction, roof or occupancy type."); + + if (!ModelState.IsValid) + { + var rebuilt = await BuildPreplanViewAsync(contact.ContactId, posted); + rebuilt.NextReviewDue = model.NextReviewDue; + rebuilt.MarkReviewed = model.MarkReviewed; + return View(rebuilt); + } + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var existing = await _contactsService.GetPreplanByContactIdAsync(contact.ContactId, DepartmentId); + + posted.ContactId = contact.ContactId; + posted.DepartmentId = DepartmentId; + posted.NextReviewDue = model.NextReviewDue.HasValue + ? DateTimeHelpers.ConvertToUtc(model.NextReviewDue.Value, department.TimeZone) + : (DateTime?)null; + posted.LastReviewedOn = existing?.LastReviewedOn; + posted.ReviewedByUserId = existing?.ReviewedByUserId; + + if (model.MarkReviewed) + { + posted.LastReviewedOn = DateTime.UtcNow; + posted.ReviewedByUserId = UserId; + } + + try + { + await _contactsService.SavePreplanAsync(posted, UserId, IpAddressHelper.GetRequestIP(Request, true), + $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + } + catch (InvalidOperationException ex) when (ex.Message == IContactPreplanOwnershipGate.RecordsOwnedReason) + { + var rebuilt = await BuildPreplanViewAsync(contact.ContactId, null); + rebuilt.Message = _localizer["PreplanRecordsOwnedNotice"].Value; + return View(rebuilt); + } + + return RedirectToAction("View", "Contacts", new { Area = "User", contactId = contact.ContactId }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Contacts_Delete)] + public async Task DeletePreplan(string contactId, CancellationToken cancellationToken) + { + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.DepartmentId != DepartmentId) + return Unauthorized(); + + try + { + await _contactsService.DeletePreplanAsync(contactId, DepartmentId, UserId, IpAddressHelper.GetRequestIP(Request, true), + $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + } + catch (InvalidOperationException ex) when (ex.Message == IContactPreplanOwnershipGate.RecordsOwnedReason) + { + return RedirectToAction("Preplan", "Contacts", new { Area = "User", contactId }); + } + + return RedirectToAction("View", "Contacts", new { Area = "User", contactId = contactId }); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Contacts_View)] + public async Task GetHazardsJson(string contactId) + { + var result = new List(); + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.DepartmentId != DepartmentId) + return Json(result); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var hazards = await _contactsService.GetHazardsByContactIdAsync(contactId, DepartmentId); + + // ADP: a server-rendered list holds no grant; the editor page reveals through RevealContactPreplan. + await _protectedReadService.ResolveContactPreplanHazardsForReadAsync(DepartmentId, hazards, null, UserId); + + foreach (var hazard in hazards) + result.Add(await ToHazardJsonAsync(hazard, department)); + + return Json(result); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Contacts_Update)] + public async Task SaveHazard([FromBody] SaveContactHazardInput input, CancellationToken cancellationToken) + { + if (input == null || !ModelState.IsValid) + return Json(new { success = false, error = "A title is required." }); + + var contact = await _contactsService.GetContactByIdAsync(input.ContactId); + if (contact == null || contact.IsDeleted || contact.DepartmentId != DepartmentId) + return Json(new { success = false, error = "Contact not found." }); + + if (!Enum.IsDefined(typeof(ContactPreplanHazardTypes), input.HazardType) || + !Enum.IsDefined(typeof(ContactPreplanHazardSeverities), input.Severity)) + return Json(new { success = false, error = "Unknown hazard type or severity." }); + + var hazard = new ContactPreplanHazard + { + ContactPreplanHazardId = string.IsNullOrWhiteSpace(input.ContactPreplanHazardId) ? null : input.ContactPreplanHazardId, + ContactId = contact.ContactId, + DepartmentId = DepartmentId, + HazardType = input.HazardType, + Severity = input.Severity, + Title = input.Title, + Description = input.Description, + LocationDescription = input.LocationDescription, + GpsCoordinates = input.GpsCoordinates, + ShouldAlert = input.ShouldAlert + }; + + try + { + var saved = await _contactsService.SaveHazardAsync(hazard, UserId, IpAddressHelper.GetRequestIP(Request, true), + $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + + return Json(new { success = true, id = saved.ContactPreplanHazardId }); + } + catch (InvalidOperationException ex) when (ex.Message == IContactPreplanOwnershipGate.RecordsOwnedReason) + { + return Json(new { success = false, error = _localizer["PreplanRecordsOwnedNotice"].Value }); + } + catch (InvalidOperationException) + { + return Json(new { success = false, error = "Hazard not found." }); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Contacts_Update)] + public async Task DeleteHazard([FromForm] string contactPreplanHazardId, CancellationToken cancellationToken) + { + try + { + var deleted = await _contactsService.DeleteHazardAsync(contactPreplanHazardId, DepartmentId, UserId, IpAddressHelper.GetRequestIP(Request, true), + $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + + return Json(new { success = deleted }); + } + catch (InvalidOperationException ex) when (ex.Message == IContactPreplanOwnershipGate.RecordsOwnedReason) + { + return Json(new { success = false, error = _localizer["PreplanRecordsOwnedNotice"].Value }); + } + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Contacts_View)] + public async Task Attachments(string contactId) + { + var model = await BuildAttachmentsViewAsync(contactId); + if (model == null) + return Unauthorized(); + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Contacts_Update)] + public async Task UploadAttachment(string contactId, int attachmentType, string name, IFormFile file, CancellationToken cancellationToken) + { + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.IsDeleted || contact.DepartmentId != DepartmentId) + return Unauthorized(); + + string error = null; + if (file == null || file.Length == 0) + error = "Choose a file to upload."; + else if (!Enum.IsDefined(typeof(ContactAttachmentTypes), attachmentType)) + error = "Unknown file type."; + else if (!AllowedAttachmentExtensions.Contains(FileHelper.GetFileExtensionWithoutDot(file.FileName) ?? string.Empty)) + error = $"File type ({FileHelper.GetFileExtensionWithoutDot(file.FileName)}) is not importable."; + else if (file.Length > ContactAttachment.MaxSizeBytes) + error = "Attachment is too large, must be smaller than 30MB."; + + if (error != null) + { + var model = await BuildAttachmentsViewAsync(contactId); + model.Message = error; + return View("Attachments", model); + } + + byte[] data; + using (var stream = new MemoryStream()) + { + await file.CopyToAsync(stream, cancellationToken); + data = stream.ToArray(); + } + + var attachment = new ContactAttachment + { + ContactId = contact.ContactId, + DepartmentId = DepartmentId, + ContactAttachmentType = attachmentType, + Name = string.IsNullOrWhiteSpace(name) ? file.FileName : name.Trim(), + FileName = Path.GetFileName(file.FileName), + FileType = file.ContentType, + Data = data + }; + + await _contactsService.SaveContactAttachmentAsync(attachment, UserId, IpAddressHelper.GetRequestIP(Request, true), + $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + + return RedirectToAction("Attachments", "Contacts", new { Area = "User", contactId = contact.ContactId }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Contacts_Delete)] + public async Task DeleteAttachment(string contactId, int contactAttachmentId, CancellationToken cancellationToken) + { + await _contactsService.DeleteContactAttachmentAsync(contactAttachmentId, DepartmentId, UserId, IpAddressHelper.GetRequestIP(Request, true), + $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}", cancellationToken); + + return RedirectToAction("Attachments", "Contacts", new { Area = "User", contactId = contactId }); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Contacts_View)] + public async Task GetContactAttachment(int contactAttachmentId) + { + var attachment = await _contactsService.GetContactAttachmentByIdAsync(contactAttachmentId); + + if (attachment == null || attachment.DepartmentId != DepartmentId || attachment.Data == null) + return NotFound(); + + // ADP: an enveloped payload/name is ciphertext — the MVC surface has no per-download grant + // flow, so a protected file is simply not served here (same rule as call files). + if (attachment.IsProtected || Resgrid.Services.ProtectedReadService.IsBinaryEnveloped(attachment.Data) || + ProtectedDataEnvelope.HasEnvelopePrefix(attachment.FileName)) + return NotFound(); + + var contentType = string.IsNullOrWhiteSpace(attachment.FileType) + ? FileHelper.GetContentTypeByExtension(Path.GetExtension(attachment.FileName ?? string.Empty)) + : attachment.FileType; + if (string.IsNullOrWhiteSpace(contentType)) + contentType = "application/octet-stream"; + + return new FileContentResult(attachment.Data, contentType) + { + FileDownloadName = attachment.FileName + }; + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Contacts_View)] + public async Task GetAttachmentsJson(string contactId) + { + var result = new List(); + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.DepartmentId != DepartmentId) + return Json(result); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var attachments = await _contactsService.GetContactAttachmentsAsync(contactId, DepartmentId); + await _protectedReadService.ResolveContactAttachmentsForReadAsync(DepartmentId, attachments, null, UserId); + + foreach (var attachment in attachments) + result.Add(await ToAttachmentJsonAsync(attachment, department)); + + return Json(result); + } + + private async Task BuildPreplanViewAsync(string contactId, ContactPreplan posted) + { + if (string.IsNullOrWhiteSpace(contactId)) + return null; + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.IsDeleted || contact.DepartmentId != DepartmentId) + return null; + + var model = new ContactPreplanView(); + model.Contact = contact; + model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + model.Preplan = posted ?? await _contactsService.GetPreplanByContactIdAsync(contactId, DepartmentId) ?? new ContactPreplan(); + model.Hazards = await _contactsService.GetHazardsByContactIdAsync(contactId, DepartmentId); + + // RMS-5 write cutover: once the department's pre-plans are owned by Records occupancies this page is + // read-only and points at the occupancy; the service refuses writes regardless (Contacts plan Phase A note). + model.IsRecordsOwned = await _preplanOwnership.IsRecordsOwnedAsync(DepartmentId); + if (model.IsRecordsOwned) + model.OccupancyId = await _preplanOwnership.GetOccupancyIdForContactAsync(DepartmentId, contactId); + + // ADP catalog v12: the editor renders REDACTED for enveloped values; RevealContactPreplan fills them + // in after the step-up, and a value that stays REDACTED round-trips to the write net, which + // restores it from the stored row instead of persisting the placeholder. + var preplanRead = posted == null && !string.IsNullOrWhiteSpace(model.Preplan.ContactPreplanId) + ? await _protectedReadService.ResolveContactPreplansForReadAsync(DepartmentId, new List { model.Preplan }, null, UserId) + : new ProtectedReadResult(); + await _protectedReadService.ResolveContactPreplanHazardsForReadAsync(DepartmentId, model.Hazards, null, UserId); + + if (posted == null && model.Preplan.NextReviewDue.HasValue) + model.NextReviewDue = model.Preplan.NextReviewDue.Value.TimeConverter(model.Department); + + model.ConstructionTypes = EnumSelectList(model.Preplan.ConstructionType); + model.RoofTypes = EnumSelectList(model.Preplan.RoofType); + model.OccupancyTypes = EnumSelectList(model.Preplan.OccupancyType); + model.HazardTypes = EnumSelectList(0); + model.HazardSeverities = EnumSelectList(0); + + var protectedRead = await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, new List { contact }, null, UserId); + model.IsProtectedContact = protectedRead.IsProtected || preplanRead.IsProtected; + + return model; + } + + /// + /// Reveal endpoint for the pre-plan editor (ADP plan 7.2, catalog v12): decrypted pre-plan values keyed by + /// catalog field id, plus every hazard's text keyed "<fieldId>:<hazardId>" for the grid. Authorizes + /// the contact on top of validating the grant. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Contacts_Update)] + public async Task RevealContactPreplan([FromForm] string contactId) + { + if (String.IsNullOrWhiteSpace(contactId)) + return BadRequest(); + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.DepartmentId != DepartmentId) + return NotFound(); + + string grantToken = Request.Headers["X-Resgrid-Protected-Grant"]; + var fields = new Dictionary(); + var resolved = await AddPreplanRevealFieldsAsync(fields, contactId, grantToken, suffixed: false); + + if (resolved != null && resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + + return Json(new { success = true, fields }); + } + + /// + /// Adds the contact's pre-plan and hazard values to a reveal payload. Plain field ids for the editor + /// (its inputs are the only ones on the page); ":<rowId>"-suffixed ids for read-only views, where several + /// pre-plans can share a page (the call Site Info tab). Hazards are always suffixed: there are many of them. + /// Returns the first resolve that reported a reason, or null when the contact has no pre-plan. + /// + private async Task AddPreplanRevealFieldsAsync(Dictionary fields, string contactId, string grantToken, bool suffixed) + { + var preplan = await _contactsService.GetPreplanByContactIdAsync(contactId, DepartmentId); + if (preplan == null) + return null; + + var preplanRead = await _protectedReadService.ResolveContactPreplansForReadAsync(DepartmentId, new List { preplan }, grantToken, UserId); + var hazardRead = await _protectedReadService.ResolveContactPreplanHazardsForReadAsync(DepartmentId, preplan.Hazards, grantToken, UserId); + + AddPreplanFields(fields, preplan, suffixed); + + if (preplanRead.IsProtected && preplanRead.ProtectedReason != null) + return preplanRead; + if (hazardRead.IsProtected && hazardRead.ProtectedReason != null) + return hazardRead; + return preplanRead; + } + + /// Writes one resolved pre-plan (and its hazards) into a reveal payload; shared with the call page. + public static void AddPreplanFields(Dictionary fields, ContactPreplan preplan, bool suffixed) + { + if (preplan == null) + return; + + foreach (var accessor in Resgrid.Services.ProtectedReadService.ContactPreplanFieldAccessors) + fields[suffixed ? $"{accessor.Key}:{preplan.ContactPreplanId}" : accessor.Key] = accessor.Value.Get(preplan); + + foreach (var hazard in preplan.Hazards ?? new List()) + { + foreach (var accessor in Resgrid.Services.ProtectedReadService.ContactPreplanHazardFieldAccessors) + fields[$"{accessor.Key}:{hazard.ContactPreplanHazardId}"] = accessor.Value.Get(hazard); + } + } + + private async Task BuildAttachmentsViewAsync(string contactId) + { + if (string.IsNullOrWhiteSpace(contactId)) + return null; + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.IsDeleted || contact.DepartmentId != DepartmentId) + return null; + + var model = new ContactAttachmentsView(); + model.Contact = contact; + model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + model.Attachments = await _contactsService.GetContactAttachmentsAsync(contactId, DepartmentId); + await _protectedReadService.ResolveContactAttachmentsForReadAsync(DepartmentId, model.Attachments, null, UserId); + model.AttachmentTypes = EnumSelectList(0); + + var protectedRead = await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, new List { contact }, null, UserId); + model.IsProtectedContact = protectedRead.IsProtected; + + return model; + } + + private static SelectList EnumSelectList(int selected) where TEnum : struct, Enum + { + var items = Enum.GetValues(typeof(TEnum)).Cast() + .Select(v => new SelectListItem { Value = Convert.ToInt32(v).ToString(), Text = SplitPascalCase(v.ToString()) }) + .ToList(); + + return new SelectList(items, "Value", "Text", selected.ToString()); + } + + /// "TypeIFireResistive" → "Type I Fire Resistive"; keeps roman numerals and acronyms readable without a resource per enum member. + public static string SplitPascalCase(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + var builder = new System.Text.StringBuilder(value.Length + 8); + for (int i = 0; i < value.Length; i++) + { + var c = value[i]; + if (i > 0 && char.IsUpper(c)) + { + var previous = value[i - 1]; + var next = i + 1 < value.Length ? value[i + 1] : '\0'; + if (char.IsLower(previous) || (char.IsUpper(previous) && char.IsLower(next))) + builder.Append(' '); + } + builder.Append(c); + } + + return builder.ToString(); + } + + private async Task ToHazardJsonAsync(ContactPreplanHazard hazard, Department department) + { + var json = new ContactHazardJson(); + json.ContactPreplanHazardId = hazard.ContactPreplanHazardId; + json.ContactId = hazard.ContactId; + json.HazardType = hazard.HazardType; + json.HazardTypeName = Enum.IsDefined(typeof(ContactPreplanHazardTypes), hazard.HazardType) ? SplitPascalCase(((ContactPreplanHazardTypes)hazard.HazardType).ToString()) : hazard.HazardType.ToString(); + json.Severity = hazard.Severity; + json.SeverityName = Enum.IsDefined(typeof(ContactPreplanHazardSeverities), hazard.Severity) ? ((ContactPreplanHazardSeverities)hazard.Severity).ToString() : hazard.Severity.ToString(); + json.SeverityColor = HazardSeverityColor(hazard.Severity); + json.Title = ProtectedDataEnvelope.SafeDisplay(hazard.Title); + json.Description = ProtectedDataEnvelope.SafeDisplay(hazard.Description); + json.LocationDescription = ProtectedDataEnvelope.SafeDisplay(hazard.LocationDescription); + json.GpsCoordinates = ProtectedDataEnvelope.SafeDisplay(hazard.GpsCoordinates); + json.ShouldAlert = hazard.ShouldAlert; + json.AddedOn = hazard.AddedOn.FormatForDepartment(department); + json.AddedBy = string.IsNullOrWhiteSpace(hazard.AddedByUserId) ? "" : await UserHelper.GetFullNameForUser(hazard.AddedByUserId); + + return json; + } + + /// Danger = red, Caution = amber, Info = blue; shared with the dispatch alert banner. + public static string HazardSeverityColor(int severity) + { + switch (severity) + { + case (int)ContactPreplanHazardSeverities.Danger: + return "#F8D7DA"; + case (int)ContactPreplanHazardSeverities.Caution: + return "#FFF3CD"; + default: + return "#D1ECF1"; + } + } + + private async Task ToAttachmentJsonAsync(ContactAttachment attachment, Department department) + { + var json = new ContactAttachmentJson(); + json.ContactAttachmentId = attachment.ContactAttachmentId; + json.ContactId = attachment.ContactId; + json.Type = attachment.ContactAttachmentType; + json.TypeName = Enum.IsDefined(typeof(ContactAttachmentTypes), attachment.ContactAttachmentType) ? SplitPascalCase(((ContactAttachmentTypes)attachment.ContactAttachmentType).ToString()) : attachment.ContactAttachmentType.ToString(); + json.Name = ProtectedDataEnvelope.SafeDisplay(attachment.Name); + json.FileName = ProtectedDataEnvelope.SafeDisplay(attachment.FileName); + json.Mime = attachment.FileType; + json.Size = attachment.Size; + json.AddedOn = attachment.AddedOn.FormatForDepartment(department); + json.AddedBy = string.IsNullOrWhiteSpace(attachment.AddedByUserId) ? "" : await UserHelper.GetFullNameForUser(attachment.AddedByUserId); + json.Url = Url.Action("GetContactAttachment", "Contacts", new { Area = "User", contactAttachmentId = attachment.ContactAttachmentId }); + + return json; + } + + #endregion } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 4b35b6960..4c080255d 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -1323,6 +1323,28 @@ public async Task ViewCall(int callId) model.Stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); model.Protocols = await _protocolsService.GetAllProtocolsForDepartmentAsync(DepartmentId); model.ChildCalls = await _callsService.GetChildCallsForCallAsync(callId); + + // Contacts plan Phase A (A6): Site Info panel. Contact identity/notes render REDACTED in a + // protected department (no grant server-side); pre-plan and hazard text is not cataloged. + model.SiteInfo = await _contactsService.GetCallSiteInfoAsync(callId, DepartmentId); + if (model.SiteInfo != null && model.SiteInfo.Contacts.Any()) + { + await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, model.SiteInfo.Contacts.Select(x => x.Contact).ToList(), null, UserId); + var siteAlertNotes = model.SiteInfo.Contacts.SelectMany(x => x.AlertNotes).ToList(); + if (siteAlertNotes.Any()) + await _protectedReadService.ResolveContactNotesForReadAsync(DepartmentId, siteAlertNotes, null, UserId); + + // Catalog v12: pre-plan text, hazard text and attachment names render REDACTED; RevealCall fills them in. + var sitePreplans = model.SiteInfo.Contacts.Where(x => x.Preplan != null).Select(x => x.Preplan).ToList(); + if (sitePreplans.Any()) + await _protectedReadService.ResolveContactPreplansForReadAsync(DepartmentId, sitePreplans, null, UserId); + var siteHazards = model.SiteInfo.Contacts.SelectMany(x => x.Hazards).ToList(); + if (siteHazards.Any()) + await _protectedReadService.ResolveContactPreplanHazardsForReadAsync(DepartmentId, siteHazards, null, UserId); + var siteAttachments = model.SiteInfo.Contacts.SelectMany(x => x.Attachments).ToList(); + if (siteAttachments.Any()) + await _protectedReadService.ResolveContactAttachmentsForReadAsync(DepartmentId, siteAttachments, null, UserId); + } model.Call = await _callsService.PopulateCallData(model.Call, true, true, true, true, true, true, true, true, true, true); // ADP (plan 7.2): server-rendered pages always render protected values as REDACTED — a @@ -1398,6 +1420,29 @@ public async Task RevealCall([FromForm] int callId) var fields = Resgrid.Services.ProtectedReadService.CallFieldAccessors .ToDictionary(a => a.Key, a => a.Value.Get(resolved.Call)); + // Catalog v12: the Site Info tab marks each linked contact's pre-plan and hazard values with the + // row id as a suffix, so the same step-up reveals the premises knowledge with the call. + var siteInfo = await _contactsService.GetCallSiteInfoAsync(callId, DepartmentId); + if (siteInfo != null && siteInfo.Contacts.Any()) + { + var sitePreplans = siteInfo.Contacts.Where(x => x.Preplan != null).Select(x => x.Preplan).ToList(); + var siteHazards = siteInfo.Contacts.SelectMany(x => x.Hazards).ToList(); + var preplanRead = sitePreplans.Any() + ? await _protectedReadService.ResolveContactPreplansForReadAsync(DepartmentId, sitePreplans, grantToken, UserId) + : null; + var hazardRead = siteHazards.Any() + ? await _protectedReadService.ResolveContactPreplanHazardsForReadAsync(DepartmentId, siteHazards, grantToken, UserId) + : null; + + if (preplanRead != null && preplanRead.IsProtected && preplanRead.ProtectedReason != null) + return Json(new { success = false, error = preplanRead.ProtectedReason }); + if (hazardRead != null && hazardRead.IsProtected && hazardRead.ProtectedReason != null) + return Json(new { success = false, error = hazardRead.ProtectedReason }); + + foreach (var preplan in sitePreplans) + ContactsController.AddPreplanFields(fields, preplan, suffixed: true); + } + // The call's user-defined fields are cataloged too, and both the view and the edit form // mark them for this module. Revealing the call while its custom fields keep showing the // placeholder is a half-reveal of one record. @@ -3112,6 +3157,26 @@ public async Task GetAlertNotesForContact(string contactId) } } } + + // Contacts plan Phase A: ShouldAlert premise hazards ride the same alert modal as alert notes. + // ADP catalog v12: no grant server-side, so enveloped text reads as REDACTED. + var hazards = await _contactsService.GetHazardsByContactIdAsync(contactId, DepartmentId); + await _protectedReadService.ResolveContactPreplanHazardsForReadAsync(DepartmentId, hazards, null, UserId); + foreach (var hazard in hazards.Where(x => x.ShouldAlert)) + { + ContactNoteJson json = new ContactNoteJson(); + json.Id = hazard.ContactPreplanHazardId; + json.ContactId = contactId; + json.Note = string.IsNullOrWhiteSpace(hazard.Description) ? hazard.Title : $"{hazard.Title}: {hazard.Description}"; + if (!string.IsNullOrWhiteSpace(hazard.LocationDescription)) + json.Note += $" ({hazard.LocationDescription})"; + json.ShouldAlert = true; + json.AddedOn = hazard.AddedOn.FormatForDepartment(department, true); + json.AddedBy = string.IsNullOrWhiteSpace(hazard.AddedByUserId) ? "" : await UserHelper.GetFullNameForUser(hazard.AddedByUserId); + json.TypeName = $"{_dispatchLocalizer["PremiseHazard"].Value} - {(ContactPreplanHazardSeverities)hazard.Severity}"; + json.TypeColor = ContactsController.HazardSeverityColor(hazard.Severity); + contactNotesJson.Add(json); + } } return Json(contactNotesJson); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordHydrantsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordHydrantsController.cs new file mode 100644 index 000000000..9811dced6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordHydrantsController.cs @@ -0,0 +1,164 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Records; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// RMS-5 hydrants and water sources (RMS plan section 4.3): list with the map layer, editor, flow tests, maintenance, service state and CSV import. + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordHydrantsController : RecordsPreventionMvcControllerBase + { + private readonly IRecordsHydrantsService _hydrants; + + public RecordHydrantsController(IRecordsHydrantsService hydrants, IRecordsCutoverService cutover, IFeatureToggleService featureToggles, + IStringLocalizer localizer) : base(cutover, featureToggles, localizer) + { + _hydrants = hydrants; + } + + private const string Flag = FeatureFlagKeys.RecordsPreventionHydrants; + + [HttpGet] + public async Task Index() + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordHydrantsIndexView { Hydrants = (await _hydrants.ListAsync(DepartmentId, UserId)).OrderBy(h => h.HydrantNumber).ToList() }); + model.MapPoints = await _hydrants.GetMapLayerAsync(DepartmentId, UserId, null, null, null, null); + model.TestDue = await _hydrants.CountTestDueAsync(DepartmentId, DateTime.UtcNow); + if (TempData["HydrantImport"] is string json) model.ImportResult = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + public async Task Details(string id) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var aggregate = await _hydrants.GetAsync(DepartmentId, UserId, id); + if (aggregate == null) return NotFound(); + return View(Prepare(new RecordHydrantDetailsView { Aggregate = aggregate })); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Edit(string id = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordHydrantEditView()); + if (!string.IsNullOrWhiteSpace(id)) + { + var aggregate = await _hydrants.GetAsync(DepartmentId, UserId, id); + if (aggregate == null) return NotFound(); + model.Hydrant = aggregate.Hydrant; + } + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Edit(RecordHydrantEditView model, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var saved = await _hydrants.SaveAsync(DepartmentId, UserId, model.Hydrant, cancellationToken); + Notify("HydrantSaved"); + return RedirectToAction(nameof(Details), new { id = saved.RmsHydrantId }); + } + catch (Exception ex) + { + var failure = Fail(ex); + if (failure != null) return failure; + Prepare(model); + model.ErrorMessage = TempData["RecordsError"] as string; + return View(model); + } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Delete(string id, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _hydrants.DeleteAsync(DepartmentId, UserId, id, cancellationToken); Notify("HydrantDeleted"); return RedirectToAction(nameof(Index)); } + catch (Exception ex) { return Fail(ex) ?? RedirectToAction(nameof(Details), new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task ServiceState(string id, bool inService, string reason, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _hydrants.SetServiceStateAsync(DepartmentId, UserId, id, inService, reason, cancellationToken); Notify("HydrantSaved"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task FlowTest(string id, RmsHydrantFlowTest test, string testedOn, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + test.RmsHydrantId = id; + test.TestedOn = ParseUtc(testedOn) ?? DateTime.UtcNow; + await _hydrants.RecordFlowTestAsync(DepartmentId, UserId, test, cancellationToken); + Notify("FlowTestRecorded"); + } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Maintenance(string id, RmsHydrantMaintenance row, string performedOn, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + row.RmsHydrantId = id; + row.PerformedOn = ParseUtc(performedOn) ?? DateTime.UtcNow; + await _hydrants.RecordMaintenanceAsync(DepartmentId, UserId, row, cancellationToken); + Notify("MaintenanceRecorded"); + } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Import(string csv, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var result = await _hydrants.ImportCsvAsync(DepartmentId, UserId, csv, cancellationToken); + TempData["HydrantImport"] = Newtonsoft.Json.JsonConvert.SerializeObject(result); + Notify("ImportResult", result.RowsRead, result.Created, result.Updated, result.Rejected.Count); + } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Index)); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs new file mode 100644 index 000000000..893962011 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Records; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// RMS-5 inspections (RMS plan section 4.3): programs and checklists, code sets, scheduling, the inspection record, violations and notices. + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordInspectionsController : RecordsPreventionMvcControllerBase + { + private readonly IRecordsInspectionsService _inspections; + private readonly IRecordsOccupancyService _occupancies; + private readonly IRecordsPreventionAttachmentsService _attachments; + + public RecordInspectionsController(IRecordsInspectionsService inspections, IRecordsOccupancyService occupancies, IRecordsPreventionAttachmentsService attachments, + IRecordsCutoverService cutover, IFeatureToggleService featureToggles, IStringLocalizer localizer) : base(cutover, featureToggles, localizer) + { + _inspections = inspections; + _occupancies = occupancies; + _attachments = attachments; + } + + private const string Flag = FeatureFlagKeys.RecordsPreventionInspections; + + [HttpGet] + public async Task Index(int? state = null, string programId = null, int page = 1) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordInspectionsIndexView { State = state, ProgramId = programId, Page = Math.Max(1, page) }); + var query = new RmsInspectionQuery { States = state.HasValue ? new List { state.Value } : null, ProgramId = programId, Skip = (model.Page - 1) * model.PageSize, Take = model.PageSize }; + model.Inspections = await _inspections.ListAsync(DepartmentId, UserId, query); + model.Total = await _inspections.CountAsync(DepartmentId, UserId, query); + model.Programs = await _inspections.GetProgramsAsync(DepartmentId, UserId, false); + model.OpenViolations = await _inspections.GetOpenViolationsAsync(DepartmentId, UserId, 50); + await PopulateOccupancyNamesAsync(model.OccupancyNames, model.Inspections.Select(i => i.RmsOccupancyId).Concat(model.OpenViolations.Select(v => v.RmsOccupancyId))); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + public async Task Details(string id) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var aggregate = await _inspections.GetAsync(DepartmentId, UserId, id); + if (aggregate == null) return NotFound(); + return View(Prepare(new RecordInspectionDetailsView { Aggregate = aggregate })); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Schedule(string occupancyId, string programId, string scheduledOn, string inspectorUserId, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var when = ParseUtc(scheduledOn) ?? DateTime.UtcNow.Date.AddDays(7); + var inspection = await _inspections.ScheduleAsync(DepartmentId, UserId, occupancyId, programId, when, string.IsNullOrWhiteSpace(inspectorUserId) ? UserId : inspectorUserId, cancellationToken); + Notify("InspectionScheduled"); + return RedirectToAction(nameof(Details), new { id = inspection.RmsInspectionId }); + } + catch (Exception ex) { return Fail(ex) ?? RedirectToAction("Details", "RecordOccupancies", new { id = occupancyId }); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Start(string id, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _inspections.StartAsync(DepartmentId, UserId, id, cancellationToken); Notify("InspectionStarted"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + /// Checklist results arrive as item_{key} = pass|fail|skip plus note_{key}. + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Complete(string id, string notes, string signatureName, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var items = new List(); + foreach (var key in Request.Form.Keys.Where(k => k.StartsWith("item_", StringComparison.Ordinal))) + { + var itemKey = key.Substring(5); + var value = Request.Form[key].ToString(); + bool? passed = value == "pass" ? true : value == "fail" ? false : (bool?)null; + items.Add(new RmsInspectionItemResult { Key = itemKey, Passed = passed, Note = Request.Form["note_" + itemKey].ToString() }); + } + await _inspections.CompleteAsync(DepartmentId, UserId, id, items, notes, signatureName, cancellationToken); + Notify("InspectionCompleted"); + } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Reinspect(string id, string scheduledOn, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var next = await _inspections.ScheduleReinspectionAsync(DepartmentId, UserId, id, ParseUtc(scheduledOn) ?? DateTime.UtcNow.Date.AddDays(30), cancellationToken); + Notify("InspectionScheduled"); + return RedirectToAction(nameof(Details), new { id = next.RmsInspectionId }); + } + catch (Exception ex) { return Fail(ex) ?? RedirectToAction(nameof(Details), new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Close(string id, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _inspections.CloseAsync(DepartmentId, UserId, id, cancellationToken); Notify("InspectionClosed"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Cancel(string id, string reason, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _inspections.CancelAsync(DepartmentId, UserId, id, reason, cancellationToken); Notify("InspectionCancelled"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Notice(string id, string noticeReference, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _inspections.IssueNoticeAsync(DepartmentId, UserId, id, noticeReference, cancellationToken); Notify("NoticeIssued"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task SaveViolation(string id, RmsViolation violation, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { violation.RmsInspectionId = id; await _inspections.SaveViolationAsync(DepartmentId, UserId, violation, cancellationToken); Notify("ViolationSaved"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task TransitionViolation(string violationId, int target, string note, string returnTo, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _inspections.TransitionViolationAsync(DepartmentId, UserId, violationId, (RmsViolationState)target, note, cancellationToken); Notify("ViolationUpdated"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return string.IsNullOrWhiteSpace(returnTo) ? RedirectToAction(nameof(Index)) : RedirectToAction(nameof(Details), new { id = returnTo }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task GenerateDue(CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { var count = await _inspections.GenerateDueInspectionsAsync(DepartmentId, DateTime.UtcNow, cancellationToken); Notify("GenerateDueResult", count); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Index)); + } + + // ---- Programs -------------------------------------------------------------------------------------------- + + [HttpGet] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Programs(string id = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordInspectionProgramsView { Programs = await _inspections.GetProgramsAsync(DepartmentId, UserId, true), CodeSets = await _inspections.GetCodeSetsAsync(DepartmentId, UserId, false) }); + if (!string.IsNullOrWhiteSpace(id)) + { + var program = await _inspections.GetProgramAsync(DepartmentId, UserId, id); + if (program == null) return NotFound(); + model.Editing = program; + model.Checklist = ParseChecklistJson(program.ChecklistJson); + model.ChecklistText = RecordInspectionProgramsView.FormatChecklist(model.Checklist); + } + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task SaveProgram(RecordInspectionProgramsView model, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var checklist = RecordInspectionProgramsView.ParseChecklist(model.ChecklistText); + var saved = await _inspections.SaveProgramAsync(DepartmentId, UserId, model.Editing, checklist, cancellationToken); + Notify("ProgramSaved"); + return RedirectToAction(nameof(Programs), new { id = saved.RmsInspectionProgramId }); + } + catch (Exception ex) { return Fail(ex) ?? RedirectToAction(nameof(Programs), new { id = model.Editing?.RmsInspectionProgramId }); } + } + + // ---- Code sets ------------------------------------------------------------------------------------------- + + [HttpGet] + public async Task CodeSets(string id = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordCodeSetsView { CodeSets = await _inspections.GetCodeSetsAsync(DepartmentId, UserId, true) }); + model.Selected = model.CodeSets.FirstOrDefault(c => c.RmsCodeSetId == id) ?? model.CodeSets.FirstOrDefault(c => c.IsActive); + if (model.Selected != null) model.Sections = await _inspections.GetCodeSectionsAsync(DepartmentId, UserId, model.Selected.RmsCodeSetId); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task SaveCodeSet(RmsCodeSet codeSet, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { var saved = await _inspections.SaveCodeSetAsync(DepartmentId, UserId, codeSet, cancellationToken); Notify("CodeSetSaved"); return RedirectToAction(nameof(CodeSets), new { id = saved.RmsCodeSetId }); } + catch (Exception ex) { return Fail(ex) ?? RedirectToAction(nameof(CodeSets), new { id = codeSet?.RmsCodeSetId }); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task SaveCodeSection(RmsCodeSection section, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _inspections.SaveCodeSectionAsync(DepartmentId, UserId, section, cancellationToken); Notify("CodeSectionSaved"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(CodeSets), new { id = section?.RmsCodeSetId }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task ImportSections(string codeSetId, string csv, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { var count = await _inspections.ImportCodeSectionsAsync(DepartmentId, UserId, codeSetId, csv, cancellationToken); Notify("SectionsImported", count); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(CodeSets), new { id = codeSetId }); + } + + private static List ParseChecklistJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try { return Newtonsoft.Json.JsonConvert.DeserializeObject>(json) ?? new List(); } + catch (Newtonsoft.Json.JsonException) { return new List(); } + } + + private async Task PopulateOccupancyNamesAsync(Dictionary map, IEnumerable ids) + { + var wanted = new HashSet(ids.Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.Ordinal); + if (wanted.Count == 0) return; + try + { + foreach (var o in await _occupancies.ListAsync(DepartmentId, UserId, new RmsOccupancyQuery { Take = 2000 })) + if (wanted.Contains(o.RmsOccupancyId)) map[o.RmsOccupancyId] = (o.OccupancyNumber + " " + o.Name).Trim(); + } + catch (RecordsModuleDisabledException) { } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs new file mode 100644 index 000000000..4c0403f1c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Records; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// RMS-5 investigation cases (RMS plan section 4.4). RecordRestricted_View is the entry policy; the service adds + /// case-membership authorization and audits every read, so this controller only shapes pages. + /// + [Area("User")] + [Authorize(Policy = ResgridResources.RecordRestricted_View)] + public class RecordInvestigationsController : RecordsPreventionMvcControllerBase + { + private readonly IRecordsInvestigationsService _investigations; + private readonly IRecordsOccupancyService _occupancies; + private readonly IUserProfileService _profiles; + + public RecordInvestigationsController(IRecordsInvestigationsService investigations, IRecordsOccupancyService occupancies, IUserProfileService profiles, + IRecordsCutoverService cutover, IFeatureToggleService featureToggles, IStringLocalizer localizer) : base(cutover, featureToggles, localizer) + { + _investigations = investigations; + _occupancies = occupancies; + _profiles = profiles; + } + + private const string Flag = FeatureFlagKeys.RecordsInvestigations; + private string Ip => IpAddressHelper.GetRequestIP(Request, true); + + [HttpGet] + public async Task Index(bool includeClosed = false) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { return View(Prepare(new RecordInvestigationsIndexView { IncludeClosed = includeClosed, Cases = await _investigations.ListMyCasesAsync(DepartmentId, UserId, includeClosed) })); } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + public async Task Details(string id) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var aggregate = await _investigations.GetAsync(DepartmentId, UserId, id, Ip); + if (aggregate == null) return NotFound(); + var model = Prepare(new RecordInvestigationDetailsView { Aggregate = aggregate }); + var profiles = await _profiles.GetAllProfilesForDepartmentAsync(DepartmentId) ?? new Dictionary(); + model.UserNames = profiles.ToDictionary(p => p.Key, p => (p.Value.FirstName + " " + p.Value.LastName).Trim(), StringComparer.Ordinal); + model.DepartmentUsers = profiles.OrderBy(p => p.Value.LastName).ThenBy(p => p.Value.FirstName).Select(p => new SelectListItem { Value = p.Key, Text = (p.Value.FirstName + " " + p.Value.LastName).Trim() }).ToList(); + model.Members = aggregate.Members.Where(m => m.IsActive).Select(m => new SelectListItem { Value = m.UserId, Text = model.UserName(m.UserId) }).ToList(); + if (model.IsLead) model.Audit = await _investigations.GetAccessAuditAsync(DepartmentId, UserId, id, 50); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + public async Task Open(string occupancyId = null, int? callId = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + var model = Prepare(new RecordInvestigationOpenView { OccupancyId = occupancyId, CallId = callId }); + await PopulateOccupanciesAsync(model); + return View(model); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Open(RecordInvestigationOpenView model, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var opened = await _investigations.OpenAsync(DepartmentId, UserId, model.Title, string.IsNullOrWhiteSpace(model.OccupancyId) ? null : model.OccupancyId, model.CallId, model.IncidentSummary, cancellationToken); + Notify("CaseOpened", opened.CaseNumber); + return RedirectToAction(nameof(Details), new { id = opened.RmsInvestigationCaseId }); + } + catch (Exception ex) + { + var failure = Fail(ex); + if (failure != null) return failure; + Prepare(model); + model.ErrorMessage = TempData["RecordsError"] as string; + await PopulateOccupanciesAsync(model); + return View(model); + } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Update(string id, long rowVersion, string title, string incidentSummary, string occupancyId, int? callId, string leadInvestigatorUserId, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + await _investigations.UpdateAsync(DepartmentId, UserId, new RmsInvestigationCase { RmsInvestigationCaseId = id, RowVersion = rowVersion, Title = title, IncidentSummary = incidentSummary, RmsOccupancyId = string.IsNullOrWhiteSpace(occupancyId) ? null : occupancyId, CallId = callId, LeadInvestigatorUserId = leadInvestigatorUserId }, cancellationToken); + Notify("CaseSaved"); + } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task AddMember(string id, string userId, int role, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.AddMemberAsync(DepartmentId, UserId, id, userId, (RmsInvestigationRole)role, cancellationToken); Notify("MemberAdded"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task RemoveMember(string id, string memberId, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.RemoveMemberAsync(DepartmentId, UserId, id, memberId, cancellationToken); Notify("MemberRemoved"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task LinkIncident(string id, string recordId, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.LinkIncidentAsync(DepartmentId, UserId, id, recordId, cancellationToken); Notify("IncidentLinked"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task UnlinkIncident(string id, string linkId, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.UnlinkIncidentAsync(DepartmentId, UserId, id, linkId, cancellationToken); Notify("IncidentUnlinked"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveNote(string id, string noteId, int kind, string occurredOn, string subject, string body, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + if (string.IsNullOrWhiteSpace(noteId)) await _investigations.AddNoteAsync(DepartmentId, UserId, id, (RmsInvestigationNoteKind)kind, ParseUtc(occurredOn) ?? DateTime.UtcNow, subject, body, cancellationToken); + else await _investigations.UpdateNoteAsync(DepartmentId, UserId, noteId, subject, body, cancellationToken); + Notify("NoteSaved"); + } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task AddEvidence(string id, int kind, string description, string collectedOn, string collectedFrom, string storageLocation, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + await _investigations.AddEvidenceAsync(DepartmentId, UserId, id, new RmsInvestigationEvidence { Kind = kind, Description = description, CollectedOn = ParseUtc(collectedOn) ?? DateTime.UtcNow, CollectedByUserId = UserId, CollectedFrom = collectedFrom, StorageLocation = storageLocation }, cancellationToken); + Notify("EvidenceAdded"); + } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpGet] + public async Task Custody(string id, string evidenceId) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var aggregate = await _investigations.GetAsync(DepartmentId, UserId, id, Ip); + var evidence = aggregate?.Evidence.FirstOrDefault(e => e.RmsInvestigationEvidenceId == evidenceId); + if (evidence == null) return NotFound(); + var model = Prepare(new RecordInvestigationCustodyView { CaseId = id, Evidence = evidence, Chain = await _investigations.GetCustodyChainAsync(DepartmentId, UserId, evidenceId) }); + var profiles = await _profiles.GetAllProfilesForDepartmentAsync(DepartmentId) ?? new Dictionary(); + model.UserNames = profiles.ToDictionary(p => p.Key, p => (p.Value.FirstName + " " + p.Value.LastName).Trim(), StringComparer.Ordinal); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task TransferCustody(string id, string evidenceId, string toUserId, string toExternal, string reason, int resultingState, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.TransferCustodyAsync(DepartmentId, UserId, evidenceId, string.IsNullOrWhiteSpace(toUserId) ? null : toUserId, toExternal, reason, (RmsEvidenceState)resultingState, cancellationToken); Notify("CustodyTransferred"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Custody), new { id, evidenceId }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task AddReferral(string id, string agency, string reason, string referenceNumber, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.AddReferralAsync(DepartmentId, UserId, id, agency, reason, referenceNumber, cancellationToken); Notify("ReferralAdded"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task ReferralState(string id, string referralId, int state, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.UpdateReferralStateAsync(DepartmentId, UserId, referralId, (RmsReferralState)state, cancellationToken); Notify("ReferralUpdated"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Findings(string id, int classification, string causeDetail, string originDescription, string findings, bool recommendsAmendment, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.RecordFindingsAsync(DepartmentId, UserId, id, (RmsFireCauseClassification)classification, causeDetail, originDescription, findings, recommendsAmendment, cancellationToken); Notify("FindingsRecorded"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task ApproveFindings(string id, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.ApproveFindingsAsync(DepartmentId, UserId, id, cancellationToken); Notify("FindingsApproved"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task ReturnFindings(string id, string reason, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.ReturnFindingsAsync(DepartmentId, UserId, id, reason, cancellationToken); Notify("FindingsReturned"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Close(string id, string reason, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.CloseAsync(DepartmentId, UserId, id, reason, cancellationToken); Notify("CaseClosed"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Reopen(string id, string reason, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _investigations.ReopenAsync(DepartmentId, UserId, id, reason, cancellationToken); Notify("CaseReopened"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpGet] + public async Task Export(string id, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var bytes = await _investigations.ExportAsync(DepartmentId, UserId, id, Ip, cancellationToken); + return File(bytes, "application/json", $"investigation-{id}.json"); + } + catch (Exception ex) { return Fail(ex) ?? RedirectToAction(nameof(Details), new { id }); } + } + + private async Task PopulateOccupanciesAsync(RecordInvestigationOpenView model) + { + try + { + model.Occupancies = new[] { new SelectListItem { Value = "", Text = "-" } } + .Concat((await _occupancies.ListAsync(DepartmentId, UserId, new RmsOccupancyQuery { Take = 2000 })).OrderBy(o => o.Name).Select(o => new SelectListItem { Value = o.RmsOccupancyId, Text = (o.OccupancyNumber + " " + o.Name + " - " + o.AddressText).Trim(), Selected = o.RmsOccupancyId == model.OccupancyId })) + .ToList(); + } + catch (Exception ex) when (ex is RecordsModuleDisabledException || ex is UnauthorizedAccessException) { } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordOccupanciesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordOccupanciesController.cs new file mode 100644 index 000000000..3dd8a00bc --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordOccupanciesController.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Records; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// RMS-5 occupancy master (RMS plan section 4.3): list, detail, editor, hazards, contact links, and the Contacts + /// pre-plan crosswalk with the write-ownership switch. Reads need Record_View; writes need the prevention + /// administrator permission (69), which the service enforces again. + /// + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordOccupanciesController : RecordsPreventionMvcControllerBase + { + private readonly IRecordsOccupancyService _occupancies; + private readonly IRecordsInspectionsService _inspections; + private readonly IRecordsPermitsService _permits; + private readonly IRecordsHydrantsService _hydrants; + private readonly IRecordsPreventionAttachmentsService _attachments; + private readonly IContactsService _contacts; + + public RecordOccupanciesController(IRecordsOccupancyService occupancies, IRecordsInspectionsService inspections, IRecordsPermitsService permits, IRecordsHydrantsService hydrants, + IRecordsPreventionAttachmentsService attachments, IContactsService contacts, IRecordsCutoverService cutover, IFeatureToggleService featureToggles, + IStringLocalizer localizer) : base(cutover, featureToggles, localizer) + { + _occupancies = occupancies; + _inspections = inspections; + _permits = permits; + _hydrants = hydrants; + _attachments = attachments; + _contacts = contacts; + } + + private const string Flag = FeatureFlagKeys.RecordsPreventionOccupancy; + + [HttpGet] + public async Task Index(string q = null, int? status = null, bool overdue = false, bool hazmat = false, int page = 1) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordOccupanciesIndexView { Search = q, Status = status, ReviewOverdue = overdue, HazmatOnly = hazmat, Page = Math.Max(1, page) }); + var query = new RmsOccupancyQuery { Search = q, Status = status, ReviewOverdue = overdue ? true : (bool?)null, HazmatOnSite = hazmat ? true : (bool?)null, Skip = (model.Page - 1) * model.PageSize, Take = model.PageSize }; + model.Occupancies = await _occupancies.ListAsync(DepartmentId, UserId, query); + model.Total = await _occupancies.CountAsync(DepartmentId, UserId, query); + model.Reconciliation = await _occupancies.GetReconciliationStatusAsync(DepartmentId); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + public async Task Details(string id) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var aggregate = await _occupancies.GetAsync(DepartmentId, UserId, id); + if (aggregate == null) return NotFound(); + var model = Prepare(new RecordOccupancyDetailsView { Aggregate = aggregate }); + model.InspectionsOn = await FlagAsync(FeatureFlagKeys.RecordsPreventionInspections); + model.PermitsOn = await FlagAsync(FeatureFlagKeys.RecordsPreventionPermits); + var contacts = await _contacts.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List(); + model.ContactNames = NameMap(contacts, c => c.ContactId, c => ProtectedDataEnvelope.SafeDisplay(c.Name)); + model.Contacts = contacts.OrderBy(c => c.Name).Select(c => new SelectListItem { Value = c.ContactId, Text = ProtectedDataEnvelope.SafeDisplay(c.Name) }).ToList(); + if (model.InspectionsOn) + { + model.Inspections = await _inspections.ListAsync(DepartmentId, UserId, new RmsInspectionQuery { OccupancyId = id, Take = 25 }); + model.OpenViolations = await _inspections.GetViolationsForOccupancyAsync(DepartmentId, UserId, id, true); + model.Programs = (await _inspections.GetProgramsAsync(DepartmentId, UserId, false)).Select(p => new SelectListItem { Value = p.RmsInspectionProgramId, Text = p.Name }).ToList(); + } + if (model.PermitsOn) model.Permits = await _permits.ListAsync(DepartmentId, UserId, new RmsPermitQuery { OccupancyId = id, Take = 25 }); + model.Attachments = await _attachments.GetMetadataAsync(DepartmentId, UserId, RmsPreventionParentKind.Occupancy, id); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Edit(string id = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordOccupancyEditView()); + if (!string.IsNullOrWhiteSpace(id)) + { + var aggregate = await _occupancies.GetAsync(DepartmentId, UserId, id); + if (aggregate == null) return NotFound(); + model.Occupancy = aggregate.Occupancy; + } + await PopulateEditAsync(model); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Edit(RecordOccupancyEditView model, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var saved = await _occupancies.SaveAsync(DepartmentId, UserId, model.Occupancy, cancellationToken); + Notify("OccupancySaved"); + return RedirectToAction(nameof(Details), new { id = saved.RmsOccupancyId }); + } + catch (Exception ex) + { + var failure = Fail(ex); + if (failure != null) return failure; + Prepare(model); + model.ErrorMessage = TempData["RecordsError"] as string; + await PopulateEditAsync(model); + return View(model); + } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Delete(string id, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _occupancies.DeleteAsync(DepartmentId, UserId, id, cancellationToken); Notify("OccupancyDeleted"); return RedirectToAction(nameof(Index)); } + catch (Exception ex) { return Fail(ex) ?? RedirectToAction(nameof(Details), new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task MarkReviewed(string id, int nextReviewMonths, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _occupancies.MarkReviewedAsync(DepartmentId, UserId, id, nextReviewMonths, cancellationToken); Notify("OccupancyReviewed"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task SaveHazard(string id, RmsOccupancyHazard hazard, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { hazard.RmsOccupancyId = id; await _occupancies.SaveHazardAsync(DepartmentId, UserId, hazard, cancellationToken); Notify("HazardSaved"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task DeleteHazard(string id, string hazardId, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _occupancies.DeleteHazardAsync(DepartmentId, UserId, hazardId, cancellationToken); Notify("HazardRemoved"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task LinkContact(string id, string contactId, int role, bool isPrimary, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _occupancies.LinkContactAsync(DepartmentId, UserId, id, contactId, (RmsOccupancyContactRole)role, isPrimary, cancellationToken); Notify("ContactLinked"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task UnlinkContact(string id, string linkId, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _occupancies.UnlinkContactAsync(DepartmentId, UserId, linkId, cancellationToken); Notify("ContactUnlinked"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + // ---- Crosswalk and write ownership --------------------------------------------------------------------- + + [HttpGet] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Crosswalk() + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordOccupancyCrosswalkView { Status = await _occupancies.GetReconciliationStatusAsync(DepartmentId) }); + model.Candidates = await _occupancies.GetCandidatesAsync(DepartmentId, UserId, RmsOccupancyCrosswalkState.Candidate, 0, 500); + model.Decided = (await _occupancies.GetCandidatesAsync(DepartmentId, UserId, RmsOccupancyCrosswalkState.Linked, 0, 100)) + .Concat(await _occupancies.GetCandidatesAsync(DepartmentId, UserId, RmsOccupancyCrosswalkState.Rejected, 0, 100)) + .OrderByDescending(c => c.DecidedOn).ToList(); + var occupancies = await _occupancies.ListAsync(DepartmentId, UserId, new RmsOccupancyQuery { Take = 2000 }); + model.OccupancyNames = NameMap(occupancies, o => o.RmsOccupancyId, o => (o.OccupancyNumber + " " + o.Name).Trim()); + model.Occupancies = occupancies.OrderBy(o => o.Name).Select(o => new SelectListItem { Value = o.RmsOccupancyId, Text = (o.OccupancyNumber + " " + o.Name + " - " + o.AddressText).Trim() }).ToList(); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Inventory(CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var result = await _occupancies.InventoryCandidatesAsync(DepartmentId, UserId, cancellationToken); + Notify("InventoryResult", result.SourcesScanned, result.CandidatesCreated, result.CandidatesUpdated, result.AlreadyDecided); + } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Crosswalk)); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Link(string crosswalkId, string occupancyId, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _occupancies.LinkCandidateAsync(DepartmentId, UserId, crosswalkId, string.IsNullOrWhiteSpace(occupancyId) ? null : occupancyId, cancellationToken); Notify("CandidateLinked"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Crosswalk)); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Reject(string crosswalkId, string reason, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _occupancies.RejectCandidateAsync(DepartmentId, UserId, crosswalkId, reason, cancellationToken); Notify("CandidateRejected"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Crosswalk)); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Merge(string id, string targetOccupancyId, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var target = await _occupancies.MergeAsync(DepartmentId, UserId, id, targetOccupancyId, cancellationToken); + Notify("OccupancyMerged"); + return RedirectToAction(nameof(Details), new { id = target.RmsOccupancyId }); + } + catch (Exception ex) { return Fail(ex) ?? RedirectToAction(nameof(Details), new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task SwitchOwnership(string reason, bool acknowledge, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + if (!acknowledge) { NotifyError(Localizer["SwitchOwnershipAcknowledge"].Value); return RedirectToAction(nameof(Crosswalk)); } + try { await _occupancies.SwitchWriteOwnershipAsync(DepartmentId, UserId, reason, cancellationToken); Notify("OwnershipSwitched"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Crosswalk)); + } + + private async Task PopulateEditAsync(RecordOccupancyEditView model) + { + if (await FlagAsync(FeatureFlagKeys.RecordsPreventionHydrants)) + { + try + { + model.Hydrants = new[] { new SelectListItem { Value = "", Text = "-" } } + .Concat((await _hydrants.ListAsync(DepartmentId, UserId)).OrderBy(h => h.HydrantNumber).Select(h => new SelectListItem { Value = h.RmsHydrantId, Text = h.HydrantNumber + (string.IsNullOrWhiteSpace(h.AddressText) ? "" : " - " + h.AddressText), Selected = h.RmsHydrantId == model.Occupancy?.NearestHydrantId })) + .ToList(); + } + catch (RecordsModuleDisabledException) { } + } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs new file mode 100644 index 000000000..2e6229cd8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Records; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// RMS-5 permits and plan review (RMS plan section 4.3): permit types, applications, the state machine, review cycles, conditions and the fee reference. + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordPermitsController : RecordsPreventionMvcControllerBase + { + private readonly IRecordsPermitsService _permits; + private readonly IRecordsOccupancyService _occupancies; + + public RecordPermitsController(IRecordsPermitsService permits, IRecordsOccupancyService occupancies, IRecordsCutoverService cutover, IFeatureToggleService featureToggles, + IStringLocalizer localizer) : base(cutover, featureToggles, localizer) + { + _permits = permits; + _occupancies = occupancies; + } + + private const string Flag = FeatureFlagKeys.RecordsPreventionPermits; + + [HttpGet] + public async Task Index(int? state = null, string typeId = null, bool expiring = false, int page = 1) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordPermitsIndexView { State = state, PermitTypeId = typeId, ExpiringOnly = expiring, Page = Math.Max(1, page) }); + var query = new RmsPermitQuery { States = state.HasValue ? new List { state.Value } : null, PermitTypeId = typeId, ExpiresBefore = expiring ? DateTime.UtcNow.AddDays(30) : (DateTime?)null, Skip = (model.Page - 1) * model.PageSize, Take = model.PageSize }; + model.Permits = await _permits.ListAsync(DepartmentId, UserId, query); + model.Total = await _permits.CountAsync(DepartmentId, UserId, query); + model.Types = await _permits.GetTypesAsync(DepartmentId, UserId, true); + await PopulateOccupancyNamesAsync(model.OccupancyNames, model.Permits.Select(p => p.RmsOccupancyId)); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + public async Task Details(string id) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var aggregate = await _permits.GetAsync(DepartmentId, UserId, id); + if (aggregate == null) return NotFound(); + return View(Prepare(new RecordPermitDetailsView { Aggregate = aggregate })); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Edit(string id = null, string occupancyId = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordPermitEditView()); + if (!string.IsNullOrWhiteSpace(id)) + { + var aggregate = await _permits.GetAsync(DepartmentId, UserId, id); + if (aggregate == null) return NotFound(); + model.Permit = aggregate.Permit; + } + else if (!string.IsNullOrWhiteSpace(occupancyId)) model.Permit.RmsOccupancyId = occupancyId; + await PopulateEditAsync(model); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Edit(RecordPermitEditView model, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var saved = model.IsNew + ? await _permits.ApplyAsync(DepartmentId, UserId, model.Permit, cancellationToken) + : await _permits.UpdateAsync(DepartmentId, UserId, model.Permit, cancellationToken); + Notify("PermitSaved"); + return RedirectToAction(nameof(Details), new { id = saved.RmsPermitId }); + } + catch (Exception ex) + { + var failure = Fail(ex); + if (failure != null) return failure; + Prepare(model); + model.ErrorMessage = TempData["RecordsError"] as string; + await PopulateEditAsync(model); + return View(model); + } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Transition(string id, int target, string reason, string effectiveOn, string expiresOn, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _permits.TransitionAsync(DepartmentId, UserId, id, (RmsPermitState)target, reason, ParseUtc(effectiveOn), ParseUtc(expiresOn), cancellationToken); Notify("PermitTransitioned"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task PlanReview(string id, int outcome, string comments, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _permits.RecordPlanReviewAsync(DepartmentId, UserId, id, (RmsPlanReviewOutcome)outcome, comments, cancellationToken); Notify("PlanReviewRecorded"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Fee(string id, decimal amount, string invoiceReference, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _permits.RecordFeePaidAsync(DepartmentId, UserId, id, amount, invoiceReference, cancellationToken); Notify("FeeRecorded"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Types(string id = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordPermitTypesView { Types = await _permits.GetTypesAsync(DepartmentId, UserId, true) }); + if (!string.IsNullOrWhiteSpace(id)) model.Editing = model.Types.FirstOrDefault(t => t.RmsPermitTypeId == id) ?? model.Editing; + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task SaveType(RecordPermitTypesView model, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _permits.SaveTypeAsync(DepartmentId, UserId, model.Editing, cancellationToken); Notify("PermitTypeSaved"); return RedirectToAction(nameof(Types)); } + catch (Exception ex) { return Fail(ex) ?? RedirectToAction(nameof(Types), new { id = model.Editing?.RmsPermitTypeId }); } + } + + private async Task PopulateEditAsync(RecordPermitEditView model) + { + model.Types = (await _permits.GetTypesAsync(DepartmentId, UserId, false)).Select(t => new SelectListItem { Value = t.RmsPermitTypeId, Text = t.Name, Selected = t.RmsPermitTypeId == model.Permit?.RmsPermitTypeId }).ToList(); + try + { + model.Occupancies = new[] { new SelectListItem { Value = "", Text = "-" } } + .Concat((await _occupancies.ListAsync(DepartmentId, UserId, new RmsOccupancyQuery { Take = 2000 })).OrderBy(o => o.Name).Select(o => new SelectListItem { Value = o.RmsOccupancyId, Text = (o.OccupancyNumber + " " + o.Name + " - " + o.AddressText).Trim(), Selected = o.RmsOccupancyId == model.Permit?.RmsOccupancyId })) + .ToList(); + } + catch (RecordsModuleDisabledException) { } + } + + private async Task PopulateOccupancyNamesAsync(Dictionary map, IEnumerable ids) + { + var wanted = new HashSet(ids.Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.Ordinal); + if (wanted.Count == 0) return; + try + { + foreach (var o in await _occupancies.ListAsync(DepartmentId, UserId, new RmsOccupancyQuery { Take = 2000 })) + if (wanted.Contains(o.RmsOccupancyId)) map[o.RmsOccupancyId] = (o.OccupancyNumber + " " + o.Name).Trim(); + } + catch (RecordsModuleDisabledException) { } + } + } + + /// RMS-5 community risk reduction activities (RMS plan section 4.3). + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordCrrController : RecordsPreventionMvcControllerBase + { + private readonly IRecordsCrrService _crr; + private readonly IRecordsOccupancyService _occupancies; + + public RecordCrrController(IRecordsCrrService crr, IRecordsOccupancyService occupancies, IRecordsCutoverService cutover, IFeatureToggleService featureToggles, + IStringLocalizer localizer) : base(cutover, featureToggles, localizer) + { + _crr = crr; + _occupancies = occupancies; + } + + private const string Flag = FeatureFlagKeys.RecordsPreventionCrr; + + [HttpGet] + public async Task Index(string start = null, string end = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordCrrIndexView { Start = ParseUtc(start) ?? DateTime.UtcNow.Date.AddDays(-90), End = ParseUtc(end) ?? DateTime.UtcNow.Date.AddDays(1) }); + model.Activities = await _crr.ListAsync(DepartmentId, UserId, model.Start, model.End, 500); + model.Summary = await _crr.GetSummaryAsync(DepartmentId, UserId, model.Start, model.End); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Edit(string id = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordCrrEditView()); + if (!string.IsNullOrWhiteSpace(id)) + { + var activity = await _crr.GetAsync(DepartmentId, UserId, id); + if (activity == null) return NotFound(); + model.Activity = activity; + } + await PopulateAsync(model); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Edit(RecordCrrEditView model, string occurredOn, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + model.Activity.OccurredOn = ParseUtc(occurredOn) ?? model.Activity.OccurredOn; + await _crr.SaveAsync(DepartmentId, UserId, model.Activity, cancellationToken); + Notify("CrrSaved"); + return RedirectToAction(nameof(Index)); + } + catch (Exception ex) + { + var failure = Fail(ex); + if (failure != null) return failure; + Prepare(model); + model.ErrorMessage = TempData["RecordsError"] as string; + await PopulateAsync(model); + return View(model); + } + } + + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] + public async Task Delete(string id, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { await _crr.DeleteAsync(DepartmentId, UserId, id, cancellationToken); Notify("CrrDeleted"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Index)); + } + + private async Task PopulateAsync(RecordCrrEditView model) + { + try + { + model.Occupancies = new[] { new SelectListItem { Value = "", Text = "-" } } + .Concat((await _occupancies.ListAsync(DepartmentId, UserId, new RmsOccupancyQuery { Take = 2000 })).OrderBy(o => o.Name).Select(o => new SelectListItem { Value = o.RmsOccupancyId, Text = (o.OccupancyNumber + " " + o.Name).Trim(), Selected = o.RmsOccupancyId == model.Activity?.RmsOccupancyId })) + .ToList(); + } + catch (RecordsModuleDisabledException) { } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index 529b4c0f7..696f92353 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -39,6 +39,7 @@ public class RecordsController : SecureBaseController private readonly IRecordsService _recordsService; private readonly IRecordsBulkPacketService _bulk; private readonly IRecordsFieldRolloutService _fieldRollout; + private readonly IFeatureToggleService _featureToggles; private readonly IRecordsCutoverService _cutoverService; private readonly IRecordsAuthorizationService _recordsAuthorizationService; private readonly IRecordsUdfService _udf; @@ -71,7 +72,7 @@ public RecordsController(IRecordsService recordsService, IRecordsCutoverService ICompositeViewEngine viewEngine, IPdfProvider pdfProvider, IRecordsSearchService recordsSearch, IDepartmentDataProtectionService dataProtection, IDepartmentProfileMediaService branding, IRecordsPrintLayoutService printLayouts, IRecordsAccountabilityService accountability, IRecordsDashboardService dashboard, IRecordsUdfService udf, IRecordsProtectionService protection, IProtectedGrantContext grantContext, IRecordsRevealService reveal, IRecordDefinitionsService definitions, IRecordTypedValuesService typedValues, IContactsService contacts, - IRecordsBulkPacketService bulk, IRecordsFieldRolloutService fieldRollout) + IRecordsBulkPacketService bulk, IRecordsFieldRolloutService fieldRollout, IFeatureToggleService featureToggles) { _fieldRollout = fieldRollout; _bulk = bulk; @@ -84,6 +85,7 @@ public RecordsController(IRecordsService recordsService, IRecordsCutoverService _grantContext = grantContext; _recordsService = recordsService; _cutoverService = cutoverService; + _featureToggles = featureToggles; _recordsAuthorizationService = recordsAuthorizationService; _udf = udf; _departmentsService = departmentsService; @@ -209,6 +211,7 @@ public async Task Index(int? year, string definitionKey, string s ModuleState = moduleState, Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), IsDepartmentAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(), + QualityReviewOn = await _featureToggles.IsEnabledAsync(FeatureFlagKeys.RecordsQualityReview, DepartmentId), Year = year, DefinitionKey = definitionKey, StateFilter = state, @@ -1608,6 +1611,8 @@ public async Task FieldRollout(int windowDays = 30, CancellationT try { model.Rollout = await _fieldRollout.GetAsync(DepartmentId, UserId, windowDays, cancellationToken); + // The service clamps the window; the label has to say the window the numbers actually cover. + model.WindowDays = model.Rollout.WindowDays; } catch (UnauthorizedAccessException) { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs new file mode 100644 index 000000000..8dc716a24 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using Resgrid.Web.Areas.User.Models.Records; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Shared plumbing for the RMS-5 prevention, investigation and RMS-4 quality/health pages: the module flag check + /// (404 when off, like the v4 controllers), TempData messages and one exception-to-page mapping. Authorization is + /// enforced by the services; the controllers only pre-hide buttons. + /// + public abstract class RecordsPreventionMvcControllerBase : SecureBaseController + { + protected readonly IRecordsCutoverService Cutover; + protected readonly IFeatureToggleService FeatureToggles; + protected readonly IStringLocalizer Localizer; + + protected RecordsPreventionMvcControllerBase(IRecordsCutoverService cutover, IFeatureToggleService featureToggles, IStringLocalizer localizer) + { + Cutover = cutover; + FeatureToggles = featureToggles; + Localizer = localizer; + } + + /// Records flag on and the module flag on. + protected async Task ModuleOnAsync(string flagKey) + { + if (!(await Cutover.GetModuleStateAsync(DepartmentId)).FlagEnabled) return false; + return string.IsNullOrWhiteSpace(flagKey) || await FeatureToggles.IsEnabledAsync(flagKey, DepartmentId); + } + + protected async Task FlagAsync(string flagKey) => await FeatureToggles.IsEnabledAsync(flagKey, DepartmentId); + + protected T Prepare(T model) where T : RecordsPreventionBaseView + { + model.CanAdminister = ClaimsAuthorizationHelper.CanAdministerRecordsPrevention(); + model.IsDepartmentAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + if (TempData["RecordsMessage"] is string message) model.Message = message; + if (TempData["RecordsError"] is string error) model.ErrorMessage = error; + Response.Headers["Cache-Control"] = "no-store"; + return model; + } + + protected void Notify(string key, params object[] args) => TempData["RecordsMessage"] = args.Length == 0 ? Localizer[key].Value : string.Format(Localizer[key].Value, args); + protected void NotifyError(string message) => TempData["RecordsError"] = message; + + /// Maps service failures for a redirecting POST. Returns null when the caller should redirect normally. + protected IActionResult Fail(Exception ex) + { + switch (ex) + { + case UnauthorizedAccessException _: return Forbid(); + case RecordsModuleDisabledException _: return NotFound(); + case RecordProtectedContentException p: NotifyError(Localizer["ProtectedContentBlocked"].Value + " (" + p.Reason + ")"); return null; + case RecordAttachmentRejectedException a: NotifyError(a.Message); return null; + case ArgumentException _: + case InvalidOperationException _: NotifyError(ex.Message); return null; + default: throw ex; + } + } + + protected static Dictionary NameMap(IEnumerable items, Func id, Func name) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var item in items ?? Enumerable.Empty()) + { + var key = id(item); + if (!string.IsNullOrWhiteSpace(key) && !map.ContainsKey(key)) map[key] = name(item); + } + return map; + } + + protected static DateTime? ParseUtc(string value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + return DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var d) ? d : (DateTime?)null; + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs new file mode 100644 index 000000000..b1f6dbab3 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Records; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// RMS-4 optional post-finalization quality review (RMS plan section 4.7): rubrics, deterministic sampling, scoring and trends. + [Area("User")] + [Authorize(Policy = ResgridResources.Record_Review)] + public class RecordsQualityController : RecordsPreventionMvcControllerBase + { + private readonly IRecordsQualityReviewService _quality; + private readonly IRecordDefinitionsService _definitions; + + public RecordsQualityController(IRecordsQualityReviewService quality, IRecordDefinitionsService definitions, IRecordsCutoverService cutover, IFeatureToggleService featureToggles, + IStringLocalizer localizer) : base(cutover, featureToggles, localizer) + { + _quality = quality; + _definitions = definitions; + } + + private const string Flag = FeatureFlagKeys.RecordsQualityReview; + + [HttpGet] + public async Task Index() + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordsQualityIndexView { Rubrics = await _quality.GetRubricsAsync(DepartmentId, UserId, true), Pending = await _quality.GetPendingAsync(DepartmentId, UserId, 200) }); + model.CanManageRubrics = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpGet] + public async Task Rubric(string id = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var model = Prepare(new RecordsQualityRubricView()); + if (!string.IsNullOrWhiteSpace(id)) + { + var rubric = await _quality.GetRubricAsync(DepartmentId, UserId, id); + if (rubric == null) return NotFound(); + model.Rubric = rubric; + model.CriteriaText = RecordsQualityRubricView.FormatCriteria(ParseCriteria(rubric.CriteriaJson)); + } + await PopulateAsync(model); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Rubric(RecordsQualityRubricView model, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + await _quality.SaveRubricAsync(DepartmentId, UserId, model.Rubric, RecordsQualityRubricView.ParseCriteria(model.CriteriaText), cancellationToken); + Notify("RubricSaved"); + return RedirectToAction(nameof(Index)); + } + catch (Exception ex) + { + var failure = Fail(ex); + if (failure != null) return failure; + Prepare(model); + model.ErrorMessage = TempData["RecordsError"] as string; + await PopulateAsync(model); + return View(model); + } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Sample(string rubricId, string since, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try { var reviews = await _quality.SampleAsync(DepartmentId, UserId, rubricId, ParseUtc(since) ?? DateTime.UtcNow.AddDays(-30), cancellationToken); Notify("SampleCreated", reviews.Count); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return RedirectToAction(nameof(Index)); + } + + [HttpGet] + public async Task Review(string id) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var review = await _quality.GetReviewAsync(DepartmentId, UserId, id); + if (review == null) return NotFound(); + var model = Prepare(new RecordsQualityReviewView { Review = review, Criteria = ParseCriteria(review.CriteriaJson) }); + foreach (var f in ParseFindings(review.FindingsJson)) if (!string.IsNullOrWhiteSpace(f.Key)) model.Findings[f.Key] = f; + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + /// Findings arrive as score_{key} (0-4) and note_{key}. + [HttpPost, ValidateAntiForgeryToken] + public async Task Score(string id, string note, bool amendmentRecommended, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var findings = new List(); + foreach (var key in Request.Form.Keys.Where(k => k.StartsWith("score_", StringComparison.Ordinal))) + { + var criterion = key.Substring(6); + int.TryParse(Request.Form[key].ToString(), out var score); + findings.Add(new RmsQualityFinding { Key = criterion, Score = score, Note = Request.Form["note_" + criterion].ToString() }); + } + await _quality.ScoreAsync(DepartmentId, UserId, id, findings, note, amendmentRecommended, cancellationToken); + Notify("ReviewScored"); + return RedirectToAction(nameof(Index)); + } + catch (Exception ex) { return Fail(ex) ?? RedirectToAction(nameof(Review), new { id }); } + } + + [HttpGet] + public async Task Trends(string since = null) + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + var from = ParseUtc(since) ?? DateTime.UtcNow.AddDays(-90); + return View(Prepare(new RecordsQualityTrendsView { Since = from, Trends = await _quality.GetTrendsAsync(DepartmentId, UserId, from) })); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + private async Task PopulateAsync(RecordsQualityRubricView model) + { + model.Definitions = new[] { new SelectListItem { Value = "", Text = Localizer["AllDefinitions"].Value } } + .Concat((await _definitions.ListAsync(DepartmentId)).Select(d => new SelectListItem { Value = d.Key, Text = d.Name, Selected = d.Key == model.Rubric?.DefinitionKey })) + .ToList(); + } + + private static List ParseCriteria(string json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try { return Newtonsoft.Json.JsonConvert.DeserializeObject>(json) ?? new List(); } + catch (Newtonsoft.Json.JsonException) { return new List(); } + } + + private static List ParseFindings(string json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try { return Newtonsoft.Json.JsonConvert.DeserializeObject>(json) ?? new List(); } + catch (Newtonsoft.Json.JsonException) { return new List(); } + } + } + + /// RMS-4 release telemetry (RMS plan section 6): one page a department administrator can read during activation and after. + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordsHealthController : RecordsPreventionMvcControllerBase + { + private readonly IRecordsReleaseTelemetryService _telemetry; + private readonly IRecordsPreventionSweepService _prevention; + + public RecordsHealthController(IRecordsReleaseTelemetryService telemetry, IRecordsPreventionSweepService prevention, IRecordsCutoverService cutover, IFeatureToggleService featureToggles, + IStringLocalizer localizer) : base(cutover, featureToggles, localizer) + { + _telemetry = telemetry; + _prevention = prevention; + } + + [HttpGet] + public async Task Index(int windowHours = 24) + { + if (!await ModuleOnAsync(null)) return NotFound(); + try + { + var model = Prepare(new RecordsHealthView { WindowHours = Math.Clamp(windowHours, 1, 24 * 30) }); + model.Telemetry = await _telemetry.GetAsync(DepartmentId, UserId, model.WindowHours); + model.Prevention = await _prevention.GetSummaryAsync(DepartmentId, UserId); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + } + + /// Files on prevention and investigation aggregates; the service authorizes through the parent. + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordPreventionAttachmentsController : RecordsPreventionMvcControllerBase + { + private readonly IRecordsPreventionAttachmentsService _attachments; + + public RecordPreventionAttachmentsController(IRecordsPreventionAttachmentsService attachments, IRecordsCutoverService cutover, IFeatureToggleService featureToggles, + IStringLocalizer localizer) : base(cutover, featureToggles, localizer) + { + _attachments = attachments; + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Add(int parentKind, string parentId, string description, bool restricted, string returnUrl, Microsoft.AspNetCore.Http.IFormFile file, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(null)) return NotFound(); + if (file == null || file.Length == 0) { NotifyError(Localizer["AttachmentFileRequired"].Value); return Back(returnUrl); } + try + { + byte[] bytes; + using (var stream = new System.IO.MemoryStream()) { await file.CopyToAsync(stream, cancellationToken); bytes = stream.ToArray(); } + await _attachments.AddAsync(DepartmentId, UserId, (RmsPreventionParentKind)parentKind, parentId, file.FileName, file.ContentType, bytes, description, restricted, cancellationToken); + Notify("AttachmentAdded"); + } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return Back(returnUrl); + } + + [HttpGet] + public async Task Download(string id) + { + if (!await ModuleOnAsync(null)) return NotFound(); + try + { + var attachment = await _attachments.GetWithDataAsync(DepartmentId, UserId, id); + if (attachment?.Data == null) return NotFound(); + return File(attachment.Data, attachment.ContentType ?? "application/octet-stream", attachment.FileName ?? "attachment"); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (RecordProtectedContentException) { return Forbid(); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Remove(string id, string returnUrl, CancellationToken cancellationToken) + { + if (!await ModuleOnAsync(null)) return NotFound(); + try { await _attachments.RemoveAsync(DepartmentId, UserId, id, cancellationToken); Notify("AttachmentRemoved"); } + catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } + return Back(returnUrl); + } + + private IActionResult Back(string returnUrl) => !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl) ? Redirect(returnUrl) : RedirectToAction("Index", "Records"); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs index 215e1bbf4..a7de9e75c 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs @@ -27,6 +27,9 @@ public class ViewCallView: BaseUserModel public List Protocols { get; set; } public List ChildCalls { get; set; } public List Contacts { get; set; } + + /// Pre-plans, hazards, alert notes and file metadata for the linked contacts (Contacts plan Phase A, A6 Site Info panel). + public CallSiteInfo SiteInfo { get; set; } public List VideoFeeds { get; set; } = new List(); public string DestinationName { get; set; } public string DestinationAddress { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Contacts/ContactPreplanViews.cs b/Web/Resgrid.Web/Areas/User/Models/Contacts/ContactPreplanViews.cs new file mode 100644 index 000000000..ac9358a80 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Contacts/ContactPreplanViews.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Contacts +{ + /// Pre-plan editor page (Contacts plan Phase A, A6). + public class ContactPreplanView + { + public Contact Contact { get; set; } + public Department Department { get; set; } + public ContactPreplan Preplan { get; set; } + public List Hazards { get; set; } = new List(); + + public SelectList ConstructionTypes { get; set; } + public SelectList RoofTypes { get; set; } + public SelectList OccupancyTypes { get; set; } + public SelectList HazardTypes { get; set; } + public SelectList HazardSeverities { get; set; } + + /// Bound from the form; null clears the schedule. + public DateTime? NextReviewDue { get; set; } + + /// Stamps LastReviewedOn / ReviewedByUserId on save. + public bool MarkReviewed { get; set; } + + public string Message { get; set; } + + /// ADP: the contact's own identity fields render as REDACTED (the pre-plan itself is not cataloged). + public bool IsProtectedContact { get; set; } + + /// RMS-5 write cutover: pre-plans are projected from Records occupancies and this editor is read-only. + public bool IsRecordsOwned { get; set; } + + /// The occupancy the projection comes from when (null when the contact has none yet). + public string OccupancyId { get; set; } + } + + /// Site attachments page (Contacts plan Phase A, A6). + public class ContactAttachmentsView + { + public Contact Contact { get; set; } + public Department Department { get; set; } + public List Attachments { get; set; } = new List(); + public SelectList AttachmentTypes { get; set; } + public string Message { get; set; } + public bool IsProtectedContact { get; set; } + } + + /// JSON body for the hazard add/edit endpoint. + public class SaveContactHazardInput + { + public string ContactPreplanHazardId { get; set; } + + [Required] + public string ContactId { get; set; } + + public int HazardType { get; set; } + public int Severity { get; set; } + + [Required] + public string Title { get; set; } + public string Description { get; set; } + public string LocationDescription { get; set; } + public string GpsCoordinates { get; set; } + public bool ShouldAlert { get; set; } + } + + public class ContactHazardJson + { + public string ContactPreplanHazardId { get; set; } + public string ContactId { get; set; } + public int HazardType { get; set; } + public string HazardTypeName { get; set; } + public int Severity { get; set; } + public string SeverityName { get; set; } + public string SeverityColor { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string LocationDescription { get; set; } + public string GpsCoordinates { get; set; } + public bool ShouldAlert { get; set; } + public string AddedOn { get; set; } + public string AddedBy { get; set; } + } + + public class ContactAttachmentJson + { + public int ContactAttachmentId { get; set; } + public string ContactId { get; set; } + public int Type { get; set; } + public string TypeName { get; set; } + public string Name { get; set; } + public string FileName { get; set; } + public string Mime { get; set; } + public int Size { get; set; } + public string AddedOn { get; set; } + public string AddedBy { get; set; } + public string Url { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Contacts/ContactsIndexView.cs b/Web/Resgrid.Web/Areas/User/Models/Contacts/ContactsIndexView.cs index cbfbc4269..6078a23e0 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Contacts/ContactsIndexView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Contacts/ContactsIndexView.cs @@ -9,5 +9,8 @@ public class ContactsIndexView public List Contacts { get; set; } public List ContactCategories { get; set; } public string TreeData { get; set; } + + /// Contacts whose pre-plan review date has passed (Contacts plan Phase A, A6 index badge). + public HashSet PreplanReviewOverdueContactIds { get; set; } = new HashSet(); } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Contacts/ViewContactView.cs b/Web/Resgrid.Web/Areas/User/Models/Contacts/ViewContactView.cs index 6898bacdd..4cec37b11 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Contacts/ViewContactView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Contacts/ViewContactView.cs @@ -15,6 +15,11 @@ public class ViewContactView public List RouteStops { get; set; } public List RoutePlans { get; set; } + /// Pre-plan (null when none), hazards and attachment metadata for the Pre-Plan / Files tabs (Contacts plan Phase A). + public ContactPreplan Preplan { get; set; } + public List Hazards { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + /// ADP: true when this contact carries protected fields rendered as REDACTED (plan 7.2). public bool IsProtectedContact { get; set; } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs index b2470e16d..2cb172925 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs @@ -55,6 +55,7 @@ public class RecordDefinitionEditView : RecordsBaseView public string Description { get; set; } public string PermittedSubjectTypes { get; set; } public int LifecyclePreset { get; set; } = (int)RmsLifecyclePreset.QuickEntry; + public int Cardinality { get; set; } = (int)RmsRecordCardinality.MultiplePerCall; public List ReviewerRoleIds { get; set; } = new List(); public List ApproverRoleIds { get; set; } = new List(); public int? ReviewDueHours { get; set; } @@ -105,7 +106,8 @@ public RecordDefinitionDraftInput ToDraftInput() return new RecordDefinitionDraftInput { Name = Name, Category = Category, Description = Description, PermittedSubjectTypes = PermittedSubjectTypes, - LifecyclePreset = (RmsLifecyclePreset)LifecyclePreset, ReviewerRoleIds = ReviewerRoleIds ?? new List(), ApproverRoleIds = ApproverRoleIds ?? new List(), + LifecyclePreset = (RmsLifecyclePreset)LifecyclePreset, Cardinality = (RmsRecordCardinality)Cardinality, + ReviewerRoleIds = ReviewerRoleIds ?? new List(), ApproverRoleIds = ApproverRoleIds ?? new List(), ReviewDueHours = ReviewDueHours, ApproveDueHours = ApproveDueHours, RequireAuthorAttestation = RequireAuthorAttestation, Numbering = new RecordDefinitionNumbering { Prefix = NumberPrefix?.Trim().ToUpperInvariant(), Assignment = (RmsNumberAssignment)NumberAssignment, PerGroupSequence = PerGroupSequence, PerIncidentSequence = PerIncidentSequence, ResetYearly = ResetYearly, SequenceWidth = SequenceWidth }, RetentionYears = RetentionYears, Classification = (RmsFieldClassification)Classification, @@ -123,7 +125,8 @@ public static RecordDefinitionEditView From(RecordDefinitionAggregate aggregate, { Aggregate = aggregate, VersionRow = version, DefinitionKey = aggregate.Definition.DefinitionKey, Version = version.Version, RowVersion = version.RowVersion, Name = aggregate.Definition.Name, Category = aggregate.Definition.Category, Description = aggregate.Definition.Description, PermittedSubjectTypes = aggregate.Definition.PermittedSubjectTypes, - LifecyclePreset = version.LifecyclePreset, ReviewerRoleIds = Resgrid.Services.Records.RecordDefinitionsService.ParseIds(version.ReviewerRoleIds), ApproverRoleIds = Resgrid.Services.Records.RecordDefinitionsService.ParseIds(version.ApproverRoleIds), + LifecyclePreset = version.LifecyclePreset, Cardinality = version.Cardinality, + ReviewerRoleIds = Resgrid.Services.Records.RecordDefinitionsService.ParseIds(version.ReviewerRoleIds), ApproverRoleIds = Resgrid.Services.Records.RecordDefinitionsService.ParseIds(version.ApproverRoleIds), ReviewDueHours = version.ReviewDueHours, ApproveDueHours = version.ApproveDueHours, RequireAuthorAttestation = version.RequireAuthorAttestation, NumberPrefix = numbering.Prefix, NumberAssignment = (int)numbering.Assignment, PerGroupSequence = numbering.PerGroupSequence, PerIncidentSequence = numbering.PerIncidentSequence, ResetYearly = numbering.ResetYearly, SequenceWidth = numbering.SequenceWidth, RetentionYears = version.RetentionYears, Classification = version.Classification, diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs new file mode 100644 index 000000000..b3eea6573 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs @@ -0,0 +1,425 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Areas.User.Models.Records +{ + /// + /// View helpers shared by the RMS-5 prevention and investigation pages. Enum members render as split PascalCase + /// (the Contacts pre-plan editor precedent) so the localized surface stays to page text. + /// + public static class RmsEnumDisplay + { + private static readonly Regex Split = new Regex("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", RegexOptions.Compiled); + + public static string Label(int value) where TEnum : struct, Enum + { + var name = Enum.IsDefined(typeof(TEnum), value) ? Enum.GetName(typeof(TEnum), value) : value.ToString(); + return Split.Replace(name, " "); + } + + public static List Items(int? selected = null, bool includeZero = true) where TEnum : struct, Enum + { + return Enum.GetValues(typeof(TEnum)).Cast() + .Select(v => Convert.ToInt32(v)) + .Where(v => includeZero || v != 0) + .Select(v => new SelectListItem { Value = v.ToString(), Text = Label(v), Selected = selected == v }) + .ToList(); + } + + public static string StateClass(RmsInspectionState state) + { + switch (state) + { + case RmsInspectionState.Scheduled: return "info"; + case RmsInspectionState.InProgress: return "primary"; + case RmsInspectionState.Completed: return "success"; + case RmsInspectionState.ReinspectionRequired: return "warning"; + case RmsInspectionState.Cancelled: return "danger"; + default: return "default"; + } + } + + public static string StateClass(RmsViolationState state) + { + switch (state) + { + case RmsViolationState.Open: return "warning"; + case RmsViolationState.Escalated: return "danger"; + case RmsViolationState.Corrected: return "info"; + case RmsViolationState.Verified: return "success"; + default: return "default"; + } + } + + public static string StateClass(RmsPermitState state) + { + switch (state) + { + case RmsPermitState.Applied: return "info"; + case RmsPermitState.UnderReview: return "primary"; + case RmsPermitState.Approved: return "success"; + case RmsPermitState.Issued: return "success"; + case RmsPermitState.Expired: return "warning"; + case RmsPermitState.Revoked: return "danger"; + case RmsPermitState.Denied: return "danger"; + default: return "default"; + } + } + + public static string StateClass(RmsInvestigationCaseState state) + { + switch (state) + { + case RmsInvestigationCaseState.Open: return "info"; + case RmsInvestigationCaseState.Active: return "primary"; + case RmsInvestigationCaseState.PendingReview: return "warning"; + default: return "default"; + } + } + + public static string Utc(DateTime? value) => value.HasValue ? value.Value.ToString("yyyy-MM-dd HH:mm") + " UTC" : ""; + public static string Day(DateTime? value) => value.HasValue ? value.Value.ToString("yyyy-MM-dd") : ""; + } + + public abstract class RecordsPreventionBaseView : RecordsBaseView + { + public bool CanAdminister { get; set; } + public bool IsDepartmentAdmin { get; set; } + } + + // ---- Occupancies ------------------------------------------------------------------------------------------- + + public class RecordOccupanciesIndexView : RecordsPreventionBaseView + { + public List Occupancies { get; set; } = new List(); + public int Total { get; set; } + public string Search { get; set; } + public int? Status { get; set; } + public bool ReviewOverdue { get; set; } + public bool HazmatOnly { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + public OccupancyReconciliationStatus Reconciliation { get; set; } + public List Statuses => RmsEnumDisplay.Items(Status); + } + + public class RecordOccupancyEditView : RecordsPreventionBaseView + { + public RmsOccupancy Occupancy { get; set; } = new RmsOccupancy { Status = (int)RmsOccupancyStatus.Active }; + public bool IsNew => string.IsNullOrWhiteSpace(Occupancy?.RmsOccupancyId); + public List Statuses => RmsEnumDisplay.Items(Occupancy?.Status); + public List ConstructionTypes => RmsEnumDisplay.Items(Occupancy?.ConstructionType); + public List RoofTypes => RmsEnumDisplay.Items(Occupancy?.RoofType); + public List OccupancyTypes => RmsEnumDisplay.Items(Occupancy?.OccupancyType); + public List SprinklerTypes => RmsEnumDisplay.Items(Occupancy?.SprinklerType); + public List Hydrants { get; set; } = new List(); + } + + public class RecordOccupancyDetailsView : RecordsPreventionBaseView + { + public OccupancyAggregate Aggregate { get; set; } + public RmsOccupancy Occupancy => Aggregate.Occupancy; + public Dictionary ContactNames { get; set; } = new Dictionary(); + public List Inspections { get; set; } = new List(); + public List OpenViolations { get; set; } = new List(); + public List Permits { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + public bool InspectionsOn { get; set; } + public bool PermitsOn { get; set; } + public List HazardTypes => RmsEnumDisplay.Items(); + public List HazardSeverities => RmsEnumDisplay.Items(); + public List ContactRoles => RmsEnumDisplay.Items(); + public List Contacts { get; set; } = new List(); + public List Programs { get; set; } = new List(); + } + + public class RecordOccupancyCrosswalkView : RecordsPreventionBaseView + { + public OccupancyReconciliationStatus Status { get; set; } + public List Candidates { get; set; } = new List(); + public List Decided { get; set; } = new List(); + public Dictionary OccupancyNames { get; set; } = new Dictionary(); + public List Occupancies { get; set; } = new List(); + } + + // ---- Inspections ------------------------------------------------------------------------------------------- + + public class RecordInspectionsIndexView : RecordsPreventionBaseView + { + public List Inspections { get; set; } = new List(); + public int Total { get; set; } + public List Programs { get; set; } = new List(); + public Dictionary OccupancyNames { get; set; } = new Dictionary(); + public int? State { get; set; } + public string ProgramId { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + public List OpenViolations { get; set; } = new List(); + public List States => RmsEnumDisplay.Items(State); + public List ProgramItems => Programs.Select(p => new SelectListItem { Value = p.RmsInspectionProgramId, Text = p.Name, Selected = p.RmsInspectionProgramId == ProgramId }).ToList(); + } + + public class RecordInspectionProgramsView : RecordsPreventionBaseView + { + public List Programs { get; set; } = new List(); + public List CodeSets { get; set; } = new List(); + public RmsInspectionProgram Editing { get; set; } = new RmsInspectionProgram { FrequencyMonths = 12, IsActive = true }; + public List Checklist { get; set; } = new List(); + public string ChecklistText { get; set; } + public List CodeSetItems => new[] { new SelectListItem { Value = "", Text = "-" } }.Concat(CodeSets.Select(c => new SelectListItem { Value = c.RmsCodeSetId, Text = c.Name + (string.IsNullOrWhiteSpace(c.Edition) ? "" : " " + c.Edition), Selected = c.RmsCodeSetId == Editing?.RmsCodeSetId })).ToList(); + public List OccupancyTypes => RmsEnumDisplay.Items(); + + /// One checklist line per row: "key | text | required(y/n) | code section number". + public static List ParseChecklist(string text) + { + var items = new List(); + if (string.IsNullOrWhiteSpace(text)) return items; + var order = 0; + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0) continue; + var parts = line.Split('|').Select(p => p.Trim()).ToArray(); + var item = new RmsInspectionChecklistItem { Key = parts[0], Text = parts.Length > 1 ? parts[1] : parts[0], Order = order++ }; + item.Required = parts.Length > 2 && (parts[2].Equals("y", StringComparison.OrdinalIgnoreCase) || parts[2].Equals("yes", StringComparison.OrdinalIgnoreCase) || parts[2].Equals("true", StringComparison.OrdinalIgnoreCase)); + item.RmsCodeSectionId = parts.Length > 3 && parts[3].Length > 0 ? parts[3] : null; + items.Add(item); + } + return items; + } + + public static string FormatChecklist(IEnumerable items) + { + var sb = new StringBuilder(); + foreach (var i in (items ?? Enumerable.Empty()).OrderBy(i => i.Order)) + sb.Append(i.Key).Append(" | ").Append(i.Text).Append(" | ").Append(i.Required ? "y" : "n").Append(" | ").Append(i.RmsCodeSectionId ?? "").Append('\n'); + return sb.ToString(); + } + } + + public class RecordCodeSetsView : RecordsPreventionBaseView + { + public List CodeSets { get; set; } = new List(); + public RmsCodeSet Selected { get; set; } + public List Sections { get; set; } = new List(); + public List Severities => RmsEnumDisplay.Items(); + } + + public class RecordInspectionDetailsView : RecordsPreventionBaseView + { + public InspectionAggregate Aggregate { get; set; } + public RmsInspection Inspection => Aggregate.Inspection; + public Dictionary Results => Aggregate.Items.Where(i => !string.IsNullOrWhiteSpace(i.Key)).GroupBy(i => i.Key).ToDictionary(g => g.Key, g => g.First()); + public bool CanRecord => Inspection.State == (int)RmsInspectionState.Scheduled || Inspection.State == (int)RmsInspectionState.InProgress; + public List ViolationStates => RmsEnumDisplay.Items(includeZero: false); + } + + // ---- Hydrants ---------------------------------------------------------------------------------------------- + + public class RecordHydrantsIndexView : RecordsPreventionBaseView + { + public List Hydrants { get; set; } = new List(); + public List MapPoints { get; set; } = new List(); + public HydrantImportResult ImportResult { get; set; } + public int TestDue { get; set; } + } + + public class RecordHydrantEditView : RecordsPreventionBaseView + { + public RmsHydrant Hydrant { get; set; } = new RmsHydrant { Type = (int)RmsHydrantType.DryBarrel, OwnerKind = (int)RmsHydrantOwnerKind.Municipal, InService = true }; + public bool IsNew => string.IsNullOrWhiteSpace(Hydrant?.RmsHydrantId); + public List Types => RmsEnumDisplay.Items(Hydrant?.Type, false); + public List OwnerKinds => RmsEnumDisplay.Items(Hydrant?.OwnerKind, false); + } + + public class RecordHydrantDetailsView : RecordsPreventionBaseView + { + public HydrantAggregate Aggregate { get; set; } + public RmsHydrant Hydrant => Aggregate.Hydrant; + public List MaintenanceKinds => RmsEnumDisplay.Items(includeZero: false); + } + + // ---- Permits ----------------------------------------------------------------------------------------------- + + public class RecordPermitsIndexView : RecordsPreventionBaseView + { + public List Permits { get; set; } = new List(); + public int Total { get; set; } + public List Types { get; set; } = new List(); + public Dictionary OccupancyNames { get; set; } = new Dictionary(); + public int? State { get; set; } + public string PermitTypeId { get; set; } + public bool ExpiringOnly { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + public List States => RmsEnumDisplay.Items(State, false); + public List TypeItems => Types.Select(t => new SelectListItem { Value = t.RmsPermitTypeId, Text = t.Name, Selected = t.RmsPermitTypeId == PermitTypeId }).ToList(); + public string TypeName(string id) => Types.FirstOrDefault(t => t.RmsPermitTypeId == id)?.Name ?? id; + } + + public class RecordPermitTypesView : RecordsPreventionBaseView + { + public List Types { get; set; } = new List(); + public RmsPermitType Editing { get; set; } = new RmsPermitType { DefaultValidityDays = 365, IsActive = true }; + } + + public class RecordPermitEditView : RecordsPreventionBaseView + { + public RmsPermit Permit { get; set; } = new RmsPermit(); + public bool IsNew => string.IsNullOrWhiteSpace(Permit?.RmsPermitId); + public List Types { get; set; } = new List(); + public List Occupancies { get; set; } = new List(); + } + + public class RecordPermitDetailsView : RecordsPreventionBaseView + { + public PermitAggregate Aggregate { get; set; } + public RmsPermit Permit => Aggregate.Permit; + public List Transitions => RmsEnumDisplay.Items(includeZero: false).Where(i => i.Value != Permit.State.ToString()).ToList(); + public List Outcomes => RmsEnumDisplay.Items(includeZero: false); + } + + // ---- Community risk reduction ------------------------------------------------------------------------------ + + public class RecordCrrIndexView : RecordsPreventionBaseView + { + public List Activities { get; set; } = new List(); + public CrrSummary Summary { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + } + + public class RecordCrrEditView : RecordsPreventionBaseView + { + public RmsCrrActivity Activity { get; set; } = new RmsCrrActivity { Kind = (int)RmsCrrActivityKind.PublicEducation, OccurredOn = DateTime.UtcNow.Date }; + public bool IsNew => string.IsNullOrWhiteSpace(Activity?.RmsCrrActivityId); + public List Kinds => RmsEnumDisplay.Items(Activity?.Kind, false); + public List Occupancies { get; set; } = new List(); + } + + // ---- Investigations ---------------------------------------------------------------------------------------- + + public class RecordInvestigationsIndexView : RecordsPreventionBaseView + { + public List Cases { get; set; } = new List(); + public bool IncludeClosed { get; set; } + } + + public class RecordInvestigationOpenView : RecordsPreventionBaseView + { + public string Title { get; set; } + public string OccupancyId { get; set; } + public int? CallId { get; set; } + public string IncidentSummary { get; set; } + public List Occupancies { get; set; } = new List(); + } + + public class RecordInvestigationDetailsView : RecordsPreventionBaseView + { + public InvestigationCaseAggregate Aggregate { get; set; } + public RmsInvestigationCase Case => Aggregate.Case; + public Dictionary UserNames { get; set; } = new Dictionary(); + public List Members { get; set; } = new List(); + public List DepartmentUsers { get; set; } = new List(); + public List Audit { get; set; } = new List(); + public bool IsLead => Aggregate.CallerRole == RmsInvestigationRole.Lead; + public bool CanWrite => !Case.IsClosed && (Aggregate.CallerRole == RmsInvestigationRole.Lead || Aggregate.CallerRole == RmsInvestigationRole.Investigator); + public bool CanReview => Aggregate.CallerRole == RmsInvestigationRole.Lead || Aggregate.CallerRole == RmsInvestigationRole.Reviewer; + public List Roles => RmsEnumDisplay.Items(includeZero: false); + public List NoteKinds => RmsEnumDisplay.Items(includeZero: false); + public List EvidenceKinds => RmsEnumDisplay.Items(includeZero: false); + public List EvidenceStates => RmsEnumDisplay.Items(includeZero: false); + public List ReferralStates => RmsEnumDisplay.Items(includeZero: false); + public List Classifications => RmsEnumDisplay.Items(Case?.CauseClassification); + public string UserName(string id) => string.IsNullOrWhiteSpace(id) ? "" : UserNames.TryGetValue(id, out var n) ? n : id; + } + + public class RecordInvestigationCustodyView : RecordsPreventionBaseView + { + public RmsInvestigationEvidence Evidence { get; set; } + public string CaseId { get; set; } + public List Chain { get; set; } = new List(); + public Dictionary UserNames { get; set; } = new Dictionary(); + public string UserName(string id) => string.IsNullOrWhiteSpace(id) ? "" : UserNames.TryGetValue(id, out var n) ? n : id; + } + + // ---- Quality review ---------------------------------------------------------------------------------------- + + public class RecordsQualityIndexView : RecordsPreventionBaseView + { + public List Rubrics { get; set; } = new List(); + public List Pending { get; set; } = new List(); + public bool CanManageRubrics { get; set; } + } + + public class RecordsQualityRubricView : RecordsPreventionBaseView + { + public RmsQualityRubric Rubric { get; set; } = new RmsQualityRubric { SampleSize = 10, IsActive = true }; + public bool IsNew => string.IsNullOrWhiteSpace(Rubric?.RmsQualityRubricId); + /// One criterion per line: "key | text | weight". + public string CriteriaText { get; set; } + public List Definitions { get; set; } = new List(); + + public static List ParseCriteria(string text) + { + var list = new List(); + if (string.IsNullOrWhiteSpace(text)) return list; + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0) continue; + var parts = line.Split('|').Select(p => p.Trim()).ToArray(); + var c = new RmsQualityCriterion { Key = parts[0], Text = parts.Length > 1 ? parts[1] : parts[0] }; + if (parts.Length > 2 && int.TryParse(parts[2], out var w) && w > 0) c.Weight = w; + list.Add(c); + } + return list; + } + + public static string FormatCriteria(IEnumerable items) + { + var sb = new StringBuilder(); + foreach (var c in items ?? Enumerable.Empty()) sb.Append(c.Key).Append(" | ").Append(c.Text).Append(" | ").Append(c.Weight).Append('\n'); + return sb.ToString(); + } + } + + public class RecordsQualityReviewView : RecordsPreventionBaseView + { + public RmsQualityReview Review { get; set; } + public List Criteria { get; set; } = new List(); + public Dictionary Findings { get; set; } = new Dictionary(); + } + + public class RecordsQualityTrendsView : RecordsPreventionBaseView + { + public RecordsQualityTrends Trends { get; set; } + public DateTime Since { get; set; } + } + + // ---- Release health ---------------------------------------------------------------------------------------- + + public class RecordsHealthView : RecordsPreventionBaseView + { + public RecordsReleaseTelemetry Telemetry { get; set; } + public PreventionSummary Prevention { get; set; } + public int WindowHours { get; set; } = 24; + } + + /// Model for the shared _PreventionAttachments partial. + public class PreventionAttachmentsPartialView + { + public RmsPreventionParentKind ParentKind { get; set; } + public string ParentId { get; set; } + public List Attachments { get; set; } = new List(); + public bool CanWrite { get; set; } + public bool AllowRestricted { get; set; } + public string ReturnUrl { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs index cd131a273..27f61c282 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs @@ -85,6 +85,8 @@ public class RecordsFieldRolloutView : RecordsBaseView public class RecordsIndexView : RecordsBaseView { public RecordsModuleState ModuleState { get; set; } + /// RMS-4 quality review flag (Records.QualityReview); shows the button on the index only. + public bool QualityReviewOn { get; set; } public Department Department { get; set; } public bool IsDepartmentAdmin { get; set; } public List Records { get; set; } = new List(); diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/Attachments.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/Attachments.cshtml new file mode 100644 index 000000000..a1b5c9ad3 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/Attachments.cshtml @@ -0,0 +1,137 @@ +@model Resgrid.Web.Areas.User.Models.Contacts.ContactAttachmentsView +@inject IStringLocalizer localizer +@inject IStringLocalizer commonLocalizer +@{ + ViewData["Title"] = "Resgrid | " + @localizer["SiteFiles"]; + var contactName = Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(Model.Contact.Name); + var canEdit = ClaimsAuthorizationHelper.CanEditContacts(); + var canDelete = ClaimsAuthorizationHelper.CanDeleteContacts(); +} + +
+
+

@localizer["SiteFiles"]: @contactName

+ +
+
+
+ @if (canEdit) + { + @localizer["PreplanTitle"] + } +
+
+
+ +
+ @if (!string.IsNullOrWhiteSpace(Model.Message)) + { +
@Model.Message
+ } + + @if (canEdit) + { +
+
+
+
@localizer["UploadFile"]
+
+
+ @Html.AntiForgeryToken() + +
+ +
+
+
+ +
+
+
+ +
+ + @localizer["UploadFileInfo"] +
+
+
+
+ +
+
+
+
+
+
+
+ } + +
+
+
+
@localizer["SiteFiles"]
+
+ @if (Model.Attachments == null || Model.Attachments.Count == 0) + { +

@localizer["NoFiles"]

+ } + else + { + + + + + + + + + + + + + @foreach (var attachment in Model.Attachments) + { + var typeName = Enum.IsDefined(typeof(Resgrid.Model.ContactAttachmentTypes), attachment.ContactAttachmentType) + ? Resgrid.Web.Areas.User.Controllers.ContactsController.SplitPascalCase(((Resgrid.Model.ContactAttachmentTypes)attachment.ContactAttachmentType).ToString()) + : attachment.ContactAttachmentType.ToString(); + + + + + + + + + } + +
@commonLocalizer["Type"]@commonLocalizer["Name"]@localizer["FileName"]@localizer["Size"]@localizer["AddedOn"]
@typeName@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(attachment.Name)@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(attachment.FileName)@(attachment.Size >= 1024 * 1024 ? $"{attachment.Size / (1024.0 * 1024.0):0.0} MB" : $"{Math.Max(1, attachment.Size / 1024)} KB")@attachment.AddedOn.TimeConverter(Model.Department).FormatForDepartment(Model.Department) + @localizer["Download"] + @if (canDelete) + { +
+ @Html.AntiForgeryToken() + + + +
+ } +
+ } +
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml index 2b2216f58..505595562 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml @@ -125,7 +125,10 @@ timestamp = c.AddedOn.TimeConverter(Model.Department).FormatForDepartment(Model.Department); } - @Html.Raw("" + c.GetName() + "" + c.GetTypeName() + "" + c.GetCategoryName() + "" + timestamp + "") + var reviewBadge = Model.PreplanReviewOverdueContactIds != null && Model.PreplanReviewOverdueContactIds.Contains(c.ContactId) + ? " " + localizer["PreplanReviewDue"] + "" + : ""; + @Html.Raw("" + c.GetName() + reviewBadge + "" + c.GetTypeName() + "" + c.GetCategoryName() + "" + timestamp + "") @Html.Raw("") diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/Preplan.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/Preplan.cshtml new file mode 100644 index 000000000..35d504308 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/Preplan.cshtml @@ -0,0 +1,485 @@ +@model Resgrid.Web.Areas.User.Models.Contacts.ContactPreplanView +@inject IStringLocalizer localizer +@inject IStringLocalizer commonLocalizer +@{ + ViewData["Title"] = "Resgrid | " + @localizer["PreplanTitle"]; + var contactName = Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(Model.Contact.Name); + // ADP reveal (plan 7.2, catalog v12): the banner in the body and the step-up wiring in the Scripts + // section share one descriptor; both only render when the page is protected. + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = commonLocalizer["AdpProtectedContact"], + RevealController = "Contacts", + RevealAction = "RevealContactPreplan", + RevealData = new Dictionary { { "contactId", Model.Contact.ContactId } } + }; +} + +
+
+

@localizer["PreplanTitle"]: @contactName

+ +
+ +
+ +
+ @if (Model.IsProtectedContact) + { +
+
+ +
+
+ } + @if (!string.IsNullOrWhiteSpace(Model.Message)) + { +
@Model.Message
+ } + @if (Model.IsRecordsOwned) + { +
+ @localizer["PreplanRecordsOwnedNotice"] + @if (!string.IsNullOrWhiteSpace(Model.OccupancyId)) + { + @localizer["PreplanOpenOccupancy"] + } +
+ } +
+ +
+ @Html.AntiForgeryToken() + + + +
+
+
+
@localizer["PreplanOccupancySection"]
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+ +
+
@localizer["PreplanUtilitiesSection"]
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+ +
+
@localizer["PreplanWaterSection"]
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
@localizer["PreplanAccessSection"]
+
+
+ +
+
+
+ +
+ + @localizer["GateCodeInfo"] +
+
+
+ +
+
+
+ +
+ + +
+
+
+ +
+
+
+
+ +
+
@localizer["PreplanContactsSection"]
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
@localizer["PreplanTacticalSection"]
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+ +
+
@localizer["PreplanReviewSection"]
+
+
+ +
+

+ @if (Model.Preplan.LastReviewedOn.HasValue) + { + @Model.Preplan.LastReviewedOn.Value.TimeConverter(Model.Department).FormatForDepartment(Model.Department) + } + else + { + @localizer["NeverReviewed"] + } +

+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ @commonLocalizer["Cancel"] + @if (!Model.IsRecordsOwned) + { + + } +
+
+
+
+
+ +
+
+
+
+
@localizer["PremiseHazards"]
+
+ @if (!Model.IsRecordsOwned) + { + + } +
+
+
+

@localizer["PremiseHazardsInfo"]

+ + + + + + + + + + + + +
@localizer["Severity"]@commonLocalizer["Type"]@localizer["Title"]@localizer["Location"]@localizer["Alert"]
+ +
+
+
+
+
+ + + +@section Scripts +{ + @if (Model.IsProtectedContact) + { + + } + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml index 728f560c6..ea774c4eb 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml @@ -46,6 +46,20 @@ @localizer["Calls"] + + @if (Model.RouteStops != null && Model.RouteStops.Count > 0) {
  • +
  • @if (!String.IsNullOrWhiteSpace(Model.Call.CallFormData)) {
  • @@ -753,6 +754,75 @@ +
    + @if (Model.SiteInfo == null || !Model.SiteInfo.Contacts.Any()) + { +

    @localizer["NoSiteInfo"]

    + } + else + { + ViewData["Department"] = Model.Department; + foreach (var site in Model.SiteInfo.Contacts) + { +
    +
    +
    @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(site.Contact.Name) (@(site.CallContactType == 0 ? localizer["Primary"] : localizer["Additional"]))
    + +
    +
    + @if (site.AlertNotes.Any()) + { +

    @localizer["AlertNotes"]

    + foreach (var note in site.AlertNotes) + { +
    @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(note.Note)
    + } + } + @if (site.Hazards.Any()) + { +

    @localizer["PremiseHazards"]

    + + + @foreach (var hazard in site.Hazards) + { + + + + + + + } + +
    @((Resgrid.Model.ContactPreplanHazardSeverities)hazard.Severity)@Resgrid.Web.Areas.User.Controllers.ContactsController.SplitPascalCase(((Resgrid.Model.ContactPreplanHazardTypes)hazard.HazardType).ToString())@hazard.Title@if (!string.IsNullOrWhiteSpace(hazard.Description)) {
    @hazard.Description }
    @hazard.LocationDescription @if (!string.IsNullOrWhiteSpace(hazard.GpsCoordinates)) { (@hazard.GpsCoordinates) }
    + } + @if (site.Preplan != null) + { + + } + else + { +

    @localizer["NoPreplan"]

    + } + @if (site.Attachments.Any()) + { +

    @localizer["SiteFiles"]

    + + } +
    +
    + } + } +
    @if (Model.VideoFeeds != null && Model.VideoFeeds.Any()) { diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordCrr/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordCrr/Edit.cshtml new file mode 100644 index 000000000..af36018ab --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordCrr/Edit.cshtml @@ -0,0 +1,51 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordCrrEditView +@inject IStringLocalizer L +@{ + var title = Model.IsNew ? L["NewCrrActivity"].Value : L["Edit"].Value + ": " + Model.Activity.Title; + ViewBag.Title = "Resgrid | " + title; +} + +
    + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    @L["StaffHelp"]
    +
    +
    + + @L["BackToRecords"] +
    +
    + @if (!Model.IsNew) + { +
    + @Html.AntiForgeryToken() + +
    + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordCrr/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordCrr/Index.cshtml new file mode 100644 index 000000000..f12e46f7d --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordCrr/Index.cshtml @@ -0,0 +1,75 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordCrrIndexView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["CrrActivities"]; +} +
    +
    +

    @L["CrrActivities"]

    + +
    +
    +
    + @if (Model.CanAdminister) + { + @L["NewCrrActivity"] + } +
    +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    +
    +
    + + + +
    + @if (Model.Activities.Count == 0) {

    @L["NoCrr"]

    } + else + { + + + + @foreach (var a in Model.Activities) + { + + + + + + + + + + + } + +
    @L["OccurredOn"]@L["Kind"]@L["Title"]@L["Location"]@L["AudienceCount"]@L["SmokeAlarmsInstalled"]@L["HoursSpent"]
    @RmsEnumDisplay.Day(a.OccurredOn)@(RmsEnumDisplay.Label(a.Kind))@a.Title@a.LocationText@a.AudienceCount@a.SmokeAlarmsInstalled@a.HoursSpent@if (Model.CanAdminister) { @L["Edit"] }
    + } +
    +
    +
    +
    @L["CrrSummary"]
    +
    +
    @L["CrrActivities"]
    @Model.Summary.Activities
    +
    @L["AudienceCount"]
    @Model.Summary.Audience
    +
    @L["SmokeAlarmsInstalled"]
    @Model.Summary.SmokeAlarmsInstalled
    +
    @L["HoursSpent"]
    @Model.Summary.Hours
    +
    + + @foreach (var kv in Model.Summary.ByKind.OrderByDescending(k => k.Value)) { } +
    @(RmsEnumDisplay.Label(kv.Key))@kv.Value
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml index 30cb12bb8..bcabe859d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml @@ -67,6 +67,7 @@
    +
    @localizer["DefinitionCardinalityHelp"]
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDeploymentConnectors/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDeploymentConnectors/Edit.cshtml index 4fe9b545d..6c69a44d5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordDeploymentConnectors/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDeploymentConnectors/Edit.cshtml @@ -111,7 +111,7 @@
    @Html.AntiForgeryToken()
    }
    @Html.AntiForgeryToken()
    -
    @Html.AntiForgeryToken()
    +
    @Html.AntiForgeryToken()
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml new file mode 100644 index 000000000..d368fe35e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml @@ -0,0 +1,123 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordHydrantDetailsView +@inject IStringLocalizer L +@{ + var h = Model.Hydrant; + ViewBag.Title = "Resgrid | " + L["Hydrants"] + " | " + h.HydrantNumber; + var returnUrl = Url.Action("Details", "RecordHydrants", new { area = "User", id = h.RmsHydrantId }); +} +
    +
    +

    @h.HydrantNumber @(h.InService ? Html.Raw("") : Html.Raw("" + L["OutOfService"] + ""))

    + +
    +
    +
    + @if (Model.CanAdminister) + { + @L["Edit"] + } +
    +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    +
    @L["Summary"]
    +
    +
    @L["Type"]
    @(RmsEnumDisplay.Label(h.Type))
    +
    @L["AddressText"]
    @h.AddressText
    @h.Latitude, @h.Longitude
    +
    @L["HydrantOwner"]
    @(RmsEnumDisplay.Label(h.OwnerKind)) @h.OwnerName
    +
    @L["MainSize"]
    @h.MainSizeInches
    +
    @L["FlowClass"]
    @(RmsEnumDisplay.Label(h.FlowClass)) · @h.FlowGpm gpm
    +
    @L["StaticPressure"]
    @h.StaticPressurePsi psi · @L["ResidualPressure"] @h.ResidualPressurePsi psi
    +
    @L["LastTestedOn"]
    @RmsEnumDisplay.Day(h.LastTestedOn)
    +
    @L["LastMaintainedOn"]
    @RmsEnumDisplay.Day(h.LastMaintainedOn)
    +
    @L["Source"]
    @h.Source
    + @if (!h.InService) {
    @L["OutOfServiceReason"]
    @h.OutOfServiceReason (@RmsEnumDisplay.Utc(h.OutOfServiceSince))
    } +
    + @if (!string.IsNullOrWhiteSpace(h.Notes)) {

    @h.Notes

    } + @if (Model.CanAdminister) + { +
    + @Html.AntiForgeryToken() + @if (h.InService) { } + +
    +
    + @Html.AntiForgeryToken() + +
    + } +
    + +
    +
    +
    @L["FlowTests"]
    + @if (Model.Aggregate.FlowTests.Count == 0) {

    @L["NoFlowTests"]

    } + else + { + + + + @foreach (var t in Model.Aggregate.FlowTests.OrderByDescending(t => t.TestedOn)) + { + + } + +
    @L["TestedOn"]@L["StaticPressure"]@L["ResidualPressure"]@L["PitotPressure"]@L["OutletDiameter"]@L["FlowGpm"]@L["FlowClass"]
    @RmsEnumDisplay.Day(t.TestedOn)@t.StaticPressurePsi@t.ResidualPressurePsi@t.PitotPressurePsi@t.OutletDiameterInches (c=@t.Coefficient)@t.FlowGpm@(RmsEnumDisplay.Label(t.FlowClass))
    + } + @if (Model.CanAdminister) + { +
    + @Html.AntiForgeryToken() +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    @L["FlowTestHelp"]

    +
    + } +
    +
    @L["MaintenanceLog"]
    + @if (Model.Aggregate.Maintenance.Count == 0) {

    @L["NoMaintenance"]

    } + else + { + + + + @foreach (var m in Model.Aggregate.Maintenance.OrderByDescending(m => m.PerformedOn)) + { + + } + +
    @L["PerformedOn"]@L["Kind"]@L["ItemNote"]@L["ReturnedToService"]
    @RmsEnumDisplay.Day(m.PerformedOn)@(RmsEnumDisplay.Label(m.Kind))@m.Notes@(m.ReturnedToService ? L["Yes"].Value : "")
    + } + @if (Model.CanAdminister) + { +
    + @Html.AntiForgeryToken() + + + + + +
    + } +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Edit.cshtml new file mode 100644 index 000000000..1d6b0cfc4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Edit.cshtml @@ -0,0 +1,42 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordHydrantEditView +@inject IStringLocalizer L +@{ + var title = Model.IsNew ? L["NewHydrant"].Value : L["EditHydrant"].Value; + ViewBag.Title = "Resgrid | " + title; +} + +
    + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    +
    +
    +
    @L["FlowGpmHelp"]
    +
    +
    +
    + + @L["BackToRecords"] +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml new file mode 100644 index 000000000..6780757de --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml @@ -0,0 +1,104 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordHydrantsIndexView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["Hydrants"]; +} +
    +
    +

    @L["Hydrants"]

    + +
    +
    +
    + @if (Model.CanAdminister) + { + @L["NewHydrant"] + } +
    +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } + @if (Model.ImportResult != null && Model.ImportResult.Rejected.Count > 0) + { +
    + @L["ImportRejectedRows"] +
      @foreach (var r in Model.ImportResult.Rejected.Take(50)) {
    • #@r.Line @r.HydrantNumber: @r.Error
    • }
    +
    + } +
    +
    +
    +

    @string.Format(L["HydrantCounts"].Value, Model.Hydrants.Count, Model.Hydrants.Count(h => !h.InService), Model.TestDue)

    + @if (Model.Hydrants.Count == 0) {

    @L["NoHydrants"]

    } + else + { +
    + + + + @foreach (var h in Model.Hydrants) + { + + + + + + + + + + + + } + +
    @L["HydrantNumber"]@L["Type"]@L["AddressText"]@L["FlowClass"]@L["FlowGpm"]@L["MainSize"]@L["InService"]@L["LastTestedOn"]
    @h.HydrantNumber@(RmsEnumDisplay.Label(h.Type))@h.AddressText
    @h.Latitude, @h.Longitude
    @(RmsEnumDisplay.Label(h.FlowClass))@h.FlowGpm@h.MainSizeInches@(h.InService ? L["Yes"].Value : L["No"].Value)@RmsEnumDisplay.Day(h.LastTestedOn)@L["ViewRecordHeader"]
    +
    + } +
    +
    +
    +
    @L["HydrantMap"]
    +
    +

    @L["HydrantMapHint"]

    +
    + @if (Model.CanAdminister) + { +
    @L["ImportHydrants"]
    +

    @L["ImportHydrantsHelp"]

    +
    + @Html.AntiForgeryToken() + + +
    +
    + } +
    +
    +
    +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/CodeSets.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/CodeSets.cshtml new file mode 100644 index 000000000..79a8067bc --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/CodeSets.cshtml @@ -0,0 +1,97 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordCodeSetsView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["CodeSets"]; +} +
    +
    +

    @L["CodeSets"]

    + +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    +
    @L["CodeSets"]
    +

    @L["CodeSetsIntro"]

    +
      + @foreach (var c in Model.CodeSets) + { +
    • @c.Name @c.Edition @(c.IsActive ? "" : "(" + L["DefinitionRetiredLabel"] + ")")
    • + } +
    + @if (Model.CanAdminister) + { +

    @L["NewCodeSet"]

    +
    + @Html.AntiForgeryToken() +
    +
    +
    + + +
    + } +
    +
    +
    + @if (Model.Selected != null) + { +
    @Model.Selected.Name @Model.Selected.Edition · @L["CodeSections"] (@Model.Sections.Count)
    + @if (Model.CanAdminister) + { +
    + @Html.AntiForgeryToken() + + + + + +
    +
    + } + + + + @foreach (var s in Model.Sections) + { + + } + +
    @L["SectionNumber"]@L["Title"]@L["DefaultSeverity"]@L["DefaultCorrectionDays"]
    @s.SectionNumber@s.Title
    @s.Text
    @(RmsEnumDisplay.Label(s.DefaultSeverity))@s.DefaultCorrectionDays
    + @if (Model.CanAdminister) + { +
    + @Html.AntiForgeryToken() +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    @L["ImportSections"]

    +

    @L["ImportSectionsHelp"]

    +
    + @Html.AntiForgeryToken() + + +
    + } +
    + } +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml new file mode 100644 index 000000000..f90f656ba --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml @@ -0,0 +1,154 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordInspectionDetailsView +@inject IStringLocalizer L +@{ + var i = Model.Inspection; + ViewBag.Title = "Resgrid | " + L["Inspections"] + " | " + i.InspectionNumber; + var returnUrl = Url.Action("Details", "RecordInspections", new { area = "User", id = i.RmsInspectionId }); + string Text(string v) => Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(v); + var editable = Model.CanAdminister && Model.CanRecord; +} +
    +
    +

    @i.InspectionNumber @(RmsEnumDisplay.Label(i.State))

    + +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } + @if (Model.Aggregate.Protection.IsProtected) {
    @L["ProtectedRecordBanner"]
    } +
    +
    +
    @L["Summary"]
    +
    +
    @L["Occupancies"]
    @if (Model.Aggregate.Occupancy != null) { @Model.Aggregate.Occupancy.OccupancyNumber @Text(Model.Aggregate.Occupancy.Name)
    @Text(Model.Aggregate.Occupancy.AddressText) } else { @i.RmsOccupancyId }
    +
    @L["InspectionPrograms"]
    @Model.Aggregate.Program?.Name
    +
    @L["ScheduledOn"]
    @RmsEnumDisplay.Utc(i.ScheduledOn)
    +
    @L["Inspector"]
    @i.InspectorUserId
    +
    @L["StartedOn"]
    @RmsEnumDisplay.Utc(i.StartedOn)
    +
    @L["CompletedOn"]
    @RmsEnumDisplay.Utc(i.CompletedOn)
    +
    @L["InspectionResult"]
    @(RmsEnumDisplay.Label(i.Result))
    +
    @L["SignatureName"]
    @Text(i.SignatureName) @RmsEnumDisplay.Utc(i.SignedOn)
    +
    @L["NoticeReference"]
    @Text(i.NoticeReference) @RmsEnumDisplay.Utc(i.NoticeIssuedOn)
    + @if (!string.IsNullOrWhiteSpace(i.ParentInspectionId)) {
    @L["ReinspectionOf"]
    @i.ParentInspectionId
    } +
    + @if (!string.IsNullOrWhiteSpace(i.Notes)) {

    @Text(i.Notes)

    } +
    + @if (Model.CanAdminister) + { +
    @L["Actions"]
    + @if (i.State == (int)RmsInspectionState.Scheduled) + { +
    @Html.AntiForgeryToken()
    + } + @if (i.State == (int)RmsInspectionState.Completed || i.State == (int)RmsInspectionState.ReinspectionRequired) + { +
    @Html.AntiForgeryToken() +
    +
    @Html.AntiForgeryToken() +
    +
    @Html.AntiForgeryToken()
    + } + @if (i.State == (int)RmsInspectionState.Scheduled || i.State == (int)RmsInspectionState.InProgress) + { +
    @Html.AntiForgeryToken() +
    + } +
    + } + +
    +
    +
    @L["Checklist"]
    + @if (Model.Aggregate.Checklist.Count == 0) {

    @L["NoChecklist"]

    } +
    + @Html.AntiForgeryToken() + + + + @foreach (var item in Model.Aggregate.Checklist.OrderBy(c => c.Order)) + { + Model.Results.TryGetValue(item.Key, out var result); + + + + + + + } + +
    #@L["ChecklistItem"]@L["InspectionResult"]@L["ItemNote"]
    @item.Key @(item.Required ? "*" : "")@item.Text + @if (editable) + { + + + + } + else + { + @(result?.Passed == true ? L["Passed"].Value : result?.Passed == false ? L["Failed"].Value : L["NotRecordedShort"].Value) + } + @if (editable) { } else { @Text(result?.Note) }
    + @if (editable) + { +
    +
    + + } +
    +
    +
    @L["Violations"] (@Model.Aggregate.Violations.Count)
    + @if (Model.Aggregate.Violations.Count == 0) {

    @L["NoViolations"]

    } + else + { + + + + @foreach (var v in Model.Aggregate.Violations) + { + + + + + + + + + } + +
    @L["Severity"]@L["DefinitionDescription"]@L["CorrectiveAction"]@L["DueOn"]@L["State"]
    @(RmsEnumDisplay.Label(v.Severity))@Text(v.Description)
    @v.ChecklistItemKey @v.RmsCodeSectionId
    @Text(v.CorrectiveAction)@RmsEnumDisplay.Day(v.DueOn)@(RmsEnumDisplay.Label(v.State))
    @RmsEnumDisplay.Day(v.CorrectedOn) @RmsEnumDisplay.Day(v.VerifiedOn)
    + @if (Model.CanAdminister) + { +
    + @Html.AntiForgeryToken() + + + +
    + } +
    + } + @if (Model.CanAdminister && i.State != (int)RmsInspectionState.Cancelled && i.State != (int)RmsInspectionState.Closed) + { +
    + @Html.AntiForgeryToken() +
    +
    +
    +
    +
    +
    + +
    + } +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml new file mode 100644 index 000000000..4bd312bdd --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml @@ -0,0 +1,97 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordInspectionsIndexView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["Inspections"]; + var now = DateTime.UtcNow; + string Occ(string id) => Model.OccupancyNames.TryGetValue(id ?? "", out var n) ? n : id; +} +
    +
    +

    @L["Inspections"]

    + +
    +
    +
    + @if (Model.CanAdminister) + { + @L["InspectionPrograms"] +
    @Html.AntiForgeryToken()
    + } + @L["CodeSets"] +
    +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    +
    + + + +
    + @if (Model.Inspections.Count == 0) {

    @L["NoInspections"]

    } + else + { +
    + + + + @foreach (var i in Model.Inspections) + { + var overdue = i.State == (int)RmsInspectionState.Scheduled && i.ScheduledOn.HasValue && i.ScheduledOn < now; + + + + + + + + + + } + +
    @L["InspectionNumber"]@L["Occupancies"]@L["State"]@L["ScheduledOn"]@L["CompletedOn"]@L["InspectionResult"]
    @i.InspectionNumber@Occ(i.RmsOccupancyId)@(RmsEnumDisplay.Label(i.State))@RmsEnumDisplay.Day(i.ScheduledOn)@RmsEnumDisplay.Day(i.CompletedOn)@(RmsEnumDisplay.Label(i.Result))@L["ViewRecordHeader"]
    +
    + @if (Model.Total > Model.PageSize) + { +
      + @for (var p = 1; p <= (Model.Total + Model.PageSize - 1) / Model.PageSize; p++) {
    • @p
    • } +
    + } + } +
    +
    +
    +
    @L["OpenViolations"] (@Model.OpenViolations.Count)
    +
    + @if (Model.OpenViolations.Count == 0) {

    @L["NoViolations"]

    } + else + { + + + + @foreach (var v in Model.OpenViolations) + { + + + + + + + + + } + +
    @L["Occupancies"]@L["Severity"]@L["DefinitionDescription"]@L["DueOn"]@L["State"]
    @Occ(v.RmsOccupancyId)@(RmsEnumDisplay.Label(v.Severity))@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(v.Description)@RmsEnumDisplay.Day(v.DueOn)@(RmsEnumDisplay.Label(v.State))@if (!string.IsNullOrWhiteSpace(v.RmsInspectionId)) { @L["ViewRecordHeader"] }
    + } +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Programs.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Programs.cshtml new file mode 100644 index 000000000..bc706e9e2 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Programs.cshtml @@ -0,0 +1,55 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordInspectionProgramsView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["InspectionPrograms"]; +} +
    +
    +

    @L["InspectionPrograms"]

    + +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    +
    @L["InspectionPrograms"]
    +

    @L["InspectionProgramsIntro"]

    + + + + @foreach (var p in Model.Programs) + { + + } + +
    @L["DefinitionName"]@L["FrequencyMonths"]@L["Enabled"]
    @p.Name@p.FrequencyMonths@(p.IsActive ? L["Yes"].Value : L["No"].Value)@L["Edit"]
    + @L["NewProgram"] +
    +
    +
    +
    @(string.IsNullOrWhiteSpace(Model.Editing.RmsInspectionProgramId) ? L["NewProgram"] : L["Edit"] + ": " + Model.Editing.Name)
    +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    @L["OccupancyTypesApplyHelp"] @string.Join(", ", Model.OccupancyTypes.Select(t => t.Value + "=" + t.Text))
    +
    +
    @L["ChecklistHelp"]
    +
    +
    +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml new file mode 100644 index 000000000..fa872f138 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml @@ -0,0 +1,63 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordInvestigationCustodyView +@inject IStringLocalizer L +@{ + var e = Model.Evidence; + ViewBag.Title = "Resgrid | " + L["CustodyChain"] + " | " + e.EvidenceNumber; + string Text(string v) => Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(v); +} +
    +
    +

    @L["CustodyChain"]: @e.EvidenceNumber

    + +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    +
    @L["EvidenceItems"]
    +
    +
    @L["Kind"]
    @(RmsEnumDisplay.Label(e.Kind))
    +
    @L["DefinitionDescription"]
    @Text(e.Description)
    +
    @L["CollectedOn"]
    @RmsEnumDisplay.Utc(e.CollectedOn) · @Model.UserName(e.CollectedByUserId)
    +
    @L["CollectedFrom"]
    @Text(e.CollectedFrom)
    +
    @L["State"]
    @(RmsEnumDisplay.Label(e.State))
    +
    @L["Custodian"]
    @(string.IsNullOrWhiteSpace(e.CurrentCustodianUserId) ? Text(e.CurrentCustodianExternal) : Model.UserName(e.CurrentCustodianUserId))
    +
    @L["StorageLocation"]
    @Text(e.StorageLocation)
    +
    +

    @L["TransferCustody"]

    +
    + @Html.AntiForgeryToken() +
    +
    +
    +
    + +
    +
    +
    +
    +
    @L["CustodyChain"]
    +

    @L["CustodyChainHelp"]

    + + + + @foreach (var t in Model.Chain.OrderBy(t => t.Sequence)) + { + + } + +
    #@L["TransferredOn"]@L["FromCustodian"]@L["TransferTo"]@L["ResultingState"]@L["ReasonText"]@L["RecordedBy"]
    @t.Sequence@RmsEnumDisplay.Utc(t.TransferredOn)@(string.IsNullOrWhiteSpace(t.FromUserId) ? Text(t.FromExternal) : Model.UserName(t.FromUserId))@(string.IsNullOrWhiteSpace(t.ToUserId) ? Text(t.ToExternal) : Model.UserName(t.ToUserId))@(RmsEnumDisplay.Label(t.ResultingState))@Text(t.Reason)@Model.UserName(t.RecordedByUserId)
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml new file mode 100644 index 000000000..f44396cd3 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml @@ -0,0 +1,190 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordInvestigationDetailsView +@inject IStringLocalizer L +@{ + var c = Model.Case; + ViewBag.Title = "Resgrid | " + L["Investigations"] + " | " + c.CaseNumber; + var returnUrl = Url.Action("Details", "RecordInvestigations", new { area = "User", id = c.RmsInvestigationCaseId }); + string Text(string v) => Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(v); + var pendingReview = c.State == (int)RmsInvestigationCaseState.PendingReview; +} +
    +
    +

    @c.CaseNumber @(RmsEnumDisplay.Label(c.State))

    + +
    + +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } + @if (Model.Aggregate.Protection.IsProtected) {
    @L["ProtectedRecordBanner"]
    } +
    @L["CaseAccessNotice"] @L["Role"]: @(Model.Aggregate.CallerRole.HasValue ? RmsEnumDisplay.Label((int)Model.Aggregate.CallerRole.Value) : "-")
    +
    +
    +
    @Text(c.Title)
    +
    + @Html.AntiForgeryToken() +
    +

    @RmsEnumDisplay.Utc(c.OpenedOn) · @Model.UserName(c.OpenedByUserId)

    +
    @if (Model.IsLead && !c.IsClosed) { } else {

    @Model.UserName(c.LeadInvestigatorUserId)

    }
    +
    @if (!string.IsNullOrWhiteSpace(c.RmsOccupancyId)) { @L["ViewRecordHeader"] }
    +
    +
    + @if (Model.CanWrite) {
    } +
    +
    +
    @L["Findings"]
    + @if (c.FindingsApprovedOn.HasValue) {

    @L["FindingsApprovedNotice"] @RmsEnumDisplay.Utc(c.FindingsApprovedOn) · @Model.UserName(c.FindingsApprovedByUserId)

    } + else if (c.FindingsRecordedOn.HasValue) {

    @L["FindingsPendingNotice"] @RmsEnumDisplay.Utc(c.FindingsRecordedOn) · @Model.UserName(c.FindingsAuthorUserId)

    } +
    + @Html.AntiForgeryToken() +
    +
    +
    +
    +
    @L["RecommendsAmendmentHelp"]
    + @if (Model.CanWrite && !pendingReview) {
    } +
    + @if (pendingReview && Model.CanReview && !c.IsClosed) + { +
    +

    @L["ApproveFindingsHint"]

    +
    @Html.AntiForgeryToken()
    +
    @Html.AntiForgeryToken()
    + } +
    +
    @L["CaseMembers"]
    + + + @foreach (var m in Model.Aggregate.Members.Where(m => m.IsActive)) + { + + } + +
    @Model.UserName(m.UserId)@(RmsEnumDisplay.Label(m.Role))@RmsEnumDisplay.Day(m.AddedOn)@if (Model.IsLead && !c.IsClosed && m.UserId != c.LeadInvestigatorUserId) {
    @Html.AntiForgeryToken()
    }
    + @if (Model.IsLead && !c.IsClosed) + { +
    + @Html.AntiForgeryToken() + + + +
    + } +
    +
    @L["LinkedIncidents"]
    +

    @L["LinkedIncidentsHelp"]

    + @foreach (var inc in Model.Aggregate.Incidents) + { +

    @inc.RecordNumber · @L["PinnedRevision"] @inc.PinnedRevisionId · @RmsEnumDisplay.Day(inc.LinkedOn) + @if (Model.CanWrite) {

    @Html.AntiForgeryToken()
    }

    + } + @if (Model.CanWrite) + { +
    + @Html.AntiForgeryToken() + + +
    + } +
    +
    @L["Referrals"]
    + @foreach (var r in Model.Aggregate.Referrals) + { +

    @Text(r.Agency) · @(RmsEnumDisplay.Label(r.State)) · @RmsEnumDisplay.Day(r.ReferredOn) · @Text(r.ReferenceNumber)
    @Text(r.Reason) + @if (Model.CanWrite) {

    @Html.AntiForgeryToken()
    }

    + } + @if (Model.CanWrite) + { +
    + @Html.AntiForgeryToken() +
    + +
    + } +
    +
    +
    +
    @L["CaseNotes"] (@Model.Aggregate.Notes.Count)
    + @foreach (var n in Model.Aggregate.Notes.OrderByDescending(n => n.OccurredOn)) + { +
    +

    @Text(n.Subject) @(RmsEnumDisplay.Label(n.Kind)) · @RmsEnumDisplay.Utc(n.OccurredOn) · @Model.UserName(n.AuthorUserId) @(n.IsLocked ? Html.Raw("") : null)

    +

    @Text(n.Body)

    +
    + } + @if (Model.CanWrite) + { +
    + @Html.AntiForgeryToken() +
    + + +
    + } +
    +
    @L["EvidenceItems"] (@Model.Aggregate.Evidence.Count)
    + @if (Model.Aggregate.Evidence.Count > 0) + { + + + + @foreach (var e in Model.Aggregate.Evidence) + { + + } + +
    @L["EvidenceNumber"]@L["Kind"]@L["DefinitionDescription"]@L["State"]@L["Custodian"]
    @e.EvidenceNumber@(RmsEnumDisplay.Label(e.Kind))@Text(e.Description)
    @RmsEnumDisplay.Day(e.CollectedOn) @Text(e.CollectedFrom)
    @(RmsEnumDisplay.Label(e.State))@(string.IsNullOrWhiteSpace(e.CurrentCustodianUserId) ? Text(e.CurrentCustodianExternal) : Model.UserName(e.CurrentCustodianUserId))@L["CustodyChain"]
    + } + @if (Model.CanWrite) + { +
    + @Html.AntiForgeryToken() +
    +
    + +
    + } +
    + + @if (Model.IsLead) + { +
    @L["Actions"]
    + @if (!c.IsClosed) + { +
    + @Html.AntiForgeryToken() + + +
    + } + else + { +

    @L["ClosedOn"] @RmsEnumDisplay.Utc(c.ClosedOn) · @Model.UserName(c.ClosedByUserId)
    @Text(c.ClosureReason)

    +
    + @Html.AntiForgeryToken() + + +
    + } +
    +
    @L["AccessAudit"]
    + + @foreach (var a in Model.Audit) { } +
    @RmsEnumDisplay.Utc(a.OccurredOn)@Model.UserName(a.ActorUserId)@(RmsEnumDisplay.Label(a.Action))@(a.Successful ? "" : L["Rejected"].Value)
    +
    + } +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml new file mode 100644 index 000000000..1258faa62 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml @@ -0,0 +1,54 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordInvestigationsIndexView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["Investigations"]; +} +
    +
    +

    @L["Investigations"]

    + +
    + +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +

    @L["InvestigationsIntro"]

    +
    + + +
    + @if (Model.Cases.Count == 0) {

    @L["NoCases"]

    } + else + { + + + + @foreach (var c in Model.Cases) + { + + + + + + + + + + } + +
    @L["CaseNumber"]@L["Title"]@L["State"]@L["CauseClassification"]@L["OpenedOn"]@L["ClosedOn"]
    @c.CaseNumber@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(c.Title)@(RmsEnumDisplay.Label(c.State))@(RmsEnumDisplay.Label(c.CauseClassification))@RmsEnumDisplay.Day(c.OpenedOn)@RmsEnumDisplay.Day(c.ClosedOn)@L["ViewRecordHeader"]
    + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Open.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Open.cshtml new file mode 100644 index 000000000..57e990a7f --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Open.cshtml @@ -0,0 +1,35 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordInvestigationOpenView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["OpenCase"]; +} +
    +
    +

    @L["OpenCase"]

    + +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +

    @L["OpenCaseIntro"]

    +
    + @Html.AntiForgeryToken() +
    +
    +
    +
    +
    + + @L["BackToRecords"] +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml new file mode 100644 index 000000000..d9e3e96b8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml @@ -0,0 +1,116 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordOccupancyCrosswalkView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["PreplanCrosswalk"]; + var s = Model.Status; +} +
    +
    +

    @L["PreplanCrosswalk"]

    + +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    +
    @L["PreplanOwnership"]
    +

    @L["CrosswalkIntroText"]

    +
    +
    @L["DeploymentStatus"]
    @(RmsEnumDisplay.Label((int)s.State))
    +
    @L["InventoriedOn"]
    @RmsEnumDisplay.Utc(s.InventoriedOn)
    +
    @L["Occupancies"]
    @s.Occupancies
    +
    @L["Candidates"]
    @s.Candidates
    +
    @L["LinkedCount"]
    @s.Linked
    +
    @L["Rejected"]
    @s.Rejected
    +
    @L["UnreconciledPreplans"]
    @s.UnreconciledPreplans
    +
    @L["SwitchedOn"]
    @RmsEnumDisplay.Utc(s.SwitchedOn)
    +
    + @if (s.State != RmsOccupancyOwnershipState.RecordsOwned) + { +
    + @Html.AntiForgeryToken() + +
    +
    +

    @L["SwitchOwnership"]

    +

    @L["SwitchOwnershipIntro"]

    + @if (s.CanSwitchToRecords) + { +
    + @Html.AntiForgeryToken() +
    +
    + +
    + } + else + { +

    @L["SwitchBlocked"]

    + } + } + else + { +

    @L["OwnershipRecordsOwnedNotice"]

    + } +
    +
    +
    +
    @L["Candidates"] (@Model.Candidates.Count)
    + @if (Model.Candidates.Count == 0) {

    @L["NoCandidates"]

    } + @foreach (var group in Model.Candidates.GroupBy(c => c.GroupKey ?? c.RmsOccupancyCrosswalkId)) + { +
    + @foreach (var c in group) + { +
    +
    + @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(c.SourceDisplayName)
    + @(RmsEnumDisplay.Label(c.SourceKind)) · @c.NormalizedAddress
    + @L["MatchConfidence"] @c.MatchConfidence% · @c.MatchReason + @if (!string.IsNullOrWhiteSpace(c.SuggestedOccupancyId)) {
    @L["SuggestedOccupancy"]: @(Model.OccupancyNames.TryGetValue(c.SuggestedOccupancyId, out var sn) ? sn : c.SuggestedOccupancyId) } +
    +
    +
    + @Html.AntiForgeryToken() + + +
    +
    + @Html.AntiForgeryToken() + + +
    +
    +
    + } +
    + } +
    + @if (Model.Decided.Count > 0) + { +
    @L["History"]
    + + + @foreach (var c in Model.Decided) + { + + } + +
    @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(c.SourceDisplayName)@(RmsEnumDisplay.Label(c.State))@(c.RmsOccupancyId != null && Model.OccupancyNames.TryGetValue(c.RmsOccupancyId, out var n) ? n : "")@RmsEnumDisplay.Utc(c.DecidedOn)
    +
    + } +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml new file mode 100644 index 000000000..b48abb151 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml @@ -0,0 +1,232 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordOccupancyDetailsView +@inject IStringLocalizer L +@{ + var o = Model.Occupancy; + var name = Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(o.Name); + ViewBag.Title = "Resgrid | " + L["Occupancies"] + " | " + o.OccupancyNumber; + var returnUrl = Url.Action("Details", "RecordOccupancies", new { area = "User", id = o.RmsOccupancyId }); + string Text(string v) => Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(v); +} +
    +
    +

    @o.OccupancyNumber · @name

    + +
    +
    +
    + @if (Model.CanAdminister) + { + @L["Edit"] + if (Model.PermitsOn) { @L["NewPermit"] } + } +
    +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } + @if (Model.Aggregate.Protection.IsProtected) {
    @L["ProtectedRecordBanner"]
    } + @if (o.Status == (int)RmsOccupancyStatus.Merged) {
    @L["OccupancyMergedNotice"] @o.MergedIntoOccupancyId
    } + @if (o.IsReviewOverdue(DateTime.UtcNow)) {
    @L["PreplanReviewOverdue"] (@RmsEnumDisplay.Day(o.NextReviewDue))
    } +
    +
    +
    @L["OccupancyIdentity"]
    +
    +
    @L["DeploymentStatus"]
    @(RmsEnumDisplay.Label(o.Status))
    +
    @L["AddressText"]
    @Text(o.AddressText)
    @o.City @o.StateProvince @o.PostalCode @o.Country
    +
    @L["Coordinates"]
    @(o.Latitude.HasValue ? o.Latitude + ", " + o.Longitude : "")
    +
    @L["ParcelId"]
    @o.ParcelId
    +
    @L["OccupancyType"]
    @(RmsEnumDisplay.Label(o.OccupancyType))
    +
    @L["ConstructionType"]
    @(RmsEnumDisplay.Label(o.ConstructionType)) · @(RmsEnumDisplay.Label(o.RoofType))
    +
    @L["Stories"]
    @o.Stories · @L["YearBuilt"] @o.YearBuilt · @o.SquareFeet ft² · @L["OccupantLoad"] @o.OccupantLoad
    +
    @L["OccupancyHours"]
    @Text(o.OccupancyHours)
    +
    @L["OccupantsNeedingAssistance"]
    @(o.HasOccupantsNeedingAssistance ? L["Yes"].Value : L["No"].Value) @Text(o.OccupantsNeedingAssistanceNotes)
    +
    @L["ReviewDue"]
    @RmsEnumDisplay.Day(o.NextReviewDue) · @L["LastReviewedOn"] @RmsEnumDisplay.Day(o.LastReviewedOn)
    +
    @L["LastInspectedOn"]
    @RmsEnumDisplay.Day(o.LastInspectedOn)
    +
    + @if (Model.CanAdminister && o.Status != (int)RmsOccupancyStatus.Merged) + { +
    + @Html.AntiForgeryToken() + + +
    + } +
    +
    @L["OccupancyProtection"]
    +
    +
    @L["SprinklerType"]
    @(RmsEnumDisplay.Label(o.SprinklerType)) · @L["Standpipe"]: @(o.HasStandpipe ? L["Yes"].Value : L["No"].Value) · @L["FireAlarm"]: @(o.HasFireAlarm ? L["Yes"].Value : L["No"].Value)
    +
    @L["FdcLocation"]
    @Text(o.FdcLocation)
    +
    @L["GasShutoff"]
    @Text(o.GasShutoffLocation)
    +
    @L["ElectricShutoff"]
    @Text(o.ElectricShutoffLocation)
    +
    @L["WaterShutoff"]
    @Text(o.WaterShutoffLocation)
    +
    @L["UtilityNotes"]
    @Text(o.UtilityNotes)
    +
    @L["NearestHydrant"]
    @o.NearestHydrantId · @L["RequiredFireFlow"] @o.RequiredFireFlowGpm
    +
    @L["WaterSupplyNotes"]
    @Text(o.WaterSupplyNotes)
    +
    +
    +
    @L["OccupancyAccess"]
    +
    +
    @L["KnoxBox"]
    @Text(o.KnoxBoxLocation)
    +
    @L["GateCode"]
    @Text(o.GateCode)
    +
    @L["AlarmPanel"]
    @Text(o.AlarmPanelLocation)
    +
    @L["AlarmCompany"]
    @Text(o.AlarmCompany) @Text(o.AlarmCompanyPhone)
    +
    @L["AccessNotes"]
    @Text(o.AccessNotes)
    +
    @L["EmergencyContact"]
    @Text(o.EmergencyContactName) @Text(o.EmergencyContactPhone)
    +
    +
    +
    @L["OccupancyTactical"]
    + @if (o.HazmatOnSite) {

    HAZMAT

    } +
    +
    @L["GeneralHazardNotes"]
    @Text(o.GeneralHazardNotes)
    +
    @L["TacticalSummary"]
    @Text(o.TacticalSummary)
    +
    +
    +
    +
    +
    @L["Hazards"]
    + @if (Model.Aggregate.Hazards.Count == 0) {

    @L["NoHazards"]

    } + else + { + + + + @foreach (var h in Model.Aggregate.Hazards.OrderByDescending(h => h.Severity)) + { + + + + + + + + } + +
    @L["Severity"]@L["Type"]@L["Title"]@L["Location"]
    @(RmsEnumDisplay.Label(h.Severity))@(RmsEnumDisplay.Label(h.HazardType))@Text(h.Title)@(h.ShouldAlert ? " " : "")@(h.ShouldAlert ? Html.Raw("") : null)
    @Text(h.Description)
    @Text(h.LocationDescription) @h.GpsCoordinates@if (Model.CanAdminister) {
    @Html.AntiForgeryToken()
    }
    + } + @if (Model.CanAdminister) + { +
    + @Html.AntiForgeryToken() +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + } +
    +
    @L["LinkedContacts"]
    + @if (Model.Aggregate.ContactLinks.Count == 0) {

    @L["NoLinkedContacts"]

    } + else + { + + + @foreach (var link in Model.Aggregate.ContactLinks) + { + + + + + + } + +
    @(Model.ContactNames.TryGetValue(link.ContactId, out var cn) ? cn : link.ContactId)@(RmsEnumDisplay.Label(link.Role)) @(link.IsPrimary ? Html.Raw("" + L["Primary"] + "") : null)@if (Model.CanAdminister) {
    @Html.AntiForgeryToken()
    }
    + } + @if (Model.CanAdminister) + { +
    + @Html.AntiForgeryToken() + + + + +
    + } +
    + @if (Model.InspectionsOn) + { +
    @L["Inspections"] @Model.OpenViolations.Count @L["OpenViolations"]
    + @if (Model.Inspections.Count == 0) {

    @L["NoInspections"]

    } + else + { + + + + @foreach (var i in Model.Inspections.OrderByDescending(i => i.ScheduledOn)) + { + + } + +
    @L["InspectionNumber"]@L["State"]@L["ScheduledOn"]@L["InspectionResult"]
    @i.InspectionNumber@(RmsEnumDisplay.Label(i.State))@RmsEnumDisplay.Day(i.ScheduledOn)@(RmsEnumDisplay.Label(i.Result))
    + } + @if (Model.CanAdminister && Model.Programs.Count > 0) + { +
    + @Html.AntiForgeryToken() + + + +
    + } +
    + } + @if (Model.PermitsOn && Model.Permits.Count > 0) + { +
    @L["Permits"]
    + + + @foreach (var p in Model.Permits) + { + + } + +
    @p.PermitNumber@(RmsEnumDisplay.Label(p.State))@Text(p.Description)@RmsEnumDisplay.Day(p.ExpiresOn)
    +
    + } + + @if (Model.Aggregate.Crosswalks.Count > 0 || Model.Aggregate.Provenance.Count > 0) + { +
    @L["Provenance"]
    + @foreach (var c in Model.Aggregate.Crosswalks) + { +

    @(RmsEnumDisplay.Label(c.SourceKind)) @c.SourceId · @(RmsEnumDisplay.Label(c.State)) · @RmsEnumDisplay.Utc(c.DecidedOn)

    + } + @if (Model.Aggregate.Provenance.Count > 0) + { + + @foreach (var p in Model.Aggregate.Provenance.OrderBy(p => p.FieldKey)) { } +
    @L["FieldKey"]@L["Source"]@L["CapturedOn"]
    @p.FieldKey@(RmsEnumDisplay.Label(p.SourceKind)) @p.SourceId@RmsEnumDisplay.Utc(p.CapturedOn)
    + } +
    + } + @if (Model.CanAdminister && o.Status != (int)RmsOccupancyStatus.Merged) + { +
    @L["Actions"]
    +
    + @Html.AntiForgeryToken() + + +
    +
    + @Html.AntiForgeryToken() + +
    +
    + } +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Edit.cshtml new file mode 100644 index 000000000..60d37c8c1 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Edit.cshtml @@ -0,0 +1,88 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordOccupancyEditView +@inject IStringLocalizer L +@{ + var title = Model.IsNew ? L["NewOccupancy"].Value : L["EditOccupancy"].Value; + ViewBag.Title = "Resgrid | " + title; +} + +
    + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } + @if (Model.Occupancy.IsProtected) {
    @L["ProtectedEditHint"]
    } +
    + @Html.AntiForgeryToken() + + + +
    +
    +
    @L["OccupancyIdentity"]
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    @L["OccupancyStructure"]
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    @L["OccupancyProtection"]
    +
    +
    +
    +
    +
    +
    +
    +
    @if (Model.Hydrants.Count > 0) { } else { }
    +
    +
    +
    +
    @L["OccupancyAccess"]
    +
    +
    +
    +
    +
    +
    +
    +
    @L["OccupancyTactical"]
    +
    +
    +
    +
    +
    +
    +
    +
    + + @L["BackToRecords"] +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Index.cshtml new file mode 100644 index 000000000..9e529ba36 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Index.cshtml @@ -0,0 +1,82 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordOccupanciesIndexView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["Occupancies"]; + var now = DateTime.UtcNow; +} +
    +
    +

    @L["Occupancies"]

    + +
    +
    +
    + @if (Model.CanAdminister) + { + @L["NewOccupancy"] + @L["PreplanCrosswalk"] + } +
    +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } + @if (Model.Reconciliation != null && Model.Reconciliation.State != RmsOccupancyOwnershipState.RecordsOwned && Model.CanAdminister) + { +
    @L["OwnershipContactsOwnedNotice"] @L["PreplanCrosswalk"]
    + } +
    +
    +
    + + + + + +
    +

    @string.Format(L["OccupancyCount"].Value, Model.Total)

    + @if (Model.Occupancies.Count == 0) + { +

    @L["NoOccupancies"]

    + } + else + { +
    + + + + @foreach (var o in Model.Occupancies) + { + + + + + + + + + + } + +
    @L["OccupancyNumber"]@L["DefinitionName"]@L["AddressText"]@L["DeploymentStatus"]@L["ReviewDue"]@L["LastInspectedOn"]
    @o.OccupancyNumber@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(o.Name) @(o.HazmatOnSite ? Html.Raw("HAZMAT") : null)@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(o.AddressText)@(string.IsNullOrWhiteSpace(o.City) ? "" : ", " + o.City)@(RmsEnumDisplay.Label(o.Status))@RmsEnumDisplay.Day(o.NextReviewDue)@RmsEnumDisplay.Day(o.LastInspectedOn)@L["ViewRecordHeader"]
    +
    + @if (Model.Total > Model.PageSize) + { +
      + @for (var p = 1; p <= (Model.Total + Model.PageSize - 1) / Model.PageSize; p++) + { +
    • @p
    • + } +
    + } + } +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Details.cshtml new file mode 100644 index 000000000..0d617999d --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Details.cshtml @@ -0,0 +1,100 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordPermitDetailsView +@inject IStringLocalizer L +@{ + var p = Model.Permit; + ViewBag.Title = "Resgrid | " + L["Permits"] + " | " + p.PermitNumber; + var returnUrl = Url.Action("Details", "RecordPermits", new { area = "User", id = p.RmsPermitId }); + string Text(string v) => Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(v); + var terminal = p.State == (int)RmsPermitState.Closed || p.State == (int)RmsPermitState.Denied || p.State == (int)RmsPermitState.Revoked; +} +
    +
    +

    @p.PermitNumber @(RmsEnumDisplay.Label(p.State))

    + +
    +
    +
    + @if (Model.CanAdminister && !terminal) + { + @L["Edit"] + } +
    +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } + @if (Model.Aggregate.Protection.IsProtected) {
    @L["ProtectedRecordBanner"]
    } +
    +
    +
    @L["Summary"]
    +
    +
    @L["PermitType"]
    @Model.Aggregate.Type?.Name (@Model.Aggregate.Type?.Code)
    +
    @L["Occupancies"]
    @if (Model.Aggregate.Occupancy != null) { @Model.Aggregate.Occupancy.OccupancyNumber @Text(Model.Aggregate.Occupancy.Name) }
    +
    @L["Applicant"]
    @Text(p.ApplicantName)
    @Text(p.ApplicantPhone) @Text(p.ApplicantEmail)
    +
    @L["DefinitionDescription"]
    @Text(p.Description)
    +
    @L["AppliedOn"]
    @RmsEnumDisplay.Utc(p.AppliedOn)
    +
    @L["ReviewedOn"]
    @RmsEnumDisplay.Utc(p.ReviewedOn) @p.ReviewedByUserId
    +
    @L["IssuedOn"]
    @RmsEnumDisplay.Utc(p.IssuedOn) @p.IssuedByUserId
    +
    @L["EffectiveOn"]
    @RmsEnumDisplay.Day(p.EffectiveOn) · @L["ExpiresOn"] @RmsEnumDisplay.Day(p.ExpiresOn)
    +
    @L["Conditions"]
    @Text(p.Conditions)
    +
    @L["ReviewNotes"]
    @Text(p.ReviewNotes)
    +
    @L["DecisionReason"]
    @Text(p.DecisionReason)
    +
    @L["FeeAmount"]
    @p.FeeAmount @(p.FeePaidOn.HasValue ? "· " + L["FeePaidOn"] + " " + RmsEnumDisplay.Day(p.FeePaidOn) + " " + p.InvoiceReference : "")
    +
    +
    + +
    +
    +
    @L["PlanReviews"]
    + @if (Model.Aggregate.PlanReviews.Count == 0) {

    @L["NoPlanReviews"]

    } + else + { + + + + @foreach (var r in Model.Aggregate.PlanReviews.OrderBy(r => r.CycleNumber)) + { + + } + +
    @L["ReviewCycle"]@L["SubmittedOn"]@L["ReviewedOn"]@L["PlanReviewOutcome"]@L["Comments"]
    @r.CycleNumber@RmsEnumDisplay.Day(r.SubmittedOn)@RmsEnumDisplay.Day(r.ReviewedOn) @r.ReviewerUserId@(RmsEnumDisplay.Label(r.Outcome))@Text(r.Comments)
    + } + @if (Model.CanAdminister && !terminal) + { +
    + @Html.AntiForgeryToken() +
    + +
    + } +
    + @if (Model.CanAdminister && !terminal) + { +
    @L["TransitionPermit"]
    +
    + @Html.AntiForgeryToken() +
    +
    +
    +
    +
    +
    +
    + @Html.AntiForgeryToken() + + + +
    +
    + } +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Edit.cshtml new file mode 100644 index 000000000..8644d5d43 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Edit.cshtml @@ -0,0 +1,41 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordPermitEditView +@inject IStringLocalizer L +@{ + var title = Model.IsNew ? L["NewPermit"].Value : L["EditPermit"].Value; + ViewBag.Title = "Resgrid | " + title; +} + +
    + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } + @if (Model.Permit.IsProtected) {
    @L["ProtectedEditHint"]
    } +
    +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    +
    +
    +
    @L["ExpiresOnHelp"]
    +
    + + @L["BackToRecords"] +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml new file mode 100644 index 000000000..fa996ba09 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml @@ -0,0 +1,67 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordPermitsIndexView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["Permits"]; + string Occ(string id) => string.IsNullOrWhiteSpace(id) ? "" : Model.OccupancyNames.TryGetValue(id, out var n) ? n : id; +} +
    +
    +

    @L["Permits"]

    + +
    +
    +
    + @if (Model.CanAdminister) + { + @L["NewPermit"] + @L["PermitTypes"] + } +
    +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    + + + + +
    + @if (Model.Permits.Count == 0) {

    @L["NoPermits"]

    } + else + { +
    + + + + @foreach (var p in Model.Permits) + { + + + + + + + + + + + } + +
    @L["PermitNumber"]@L["PermitType"]@L["Occupancies"]@L["Applicant"]@L["State"]@L["AppliedOn"]@L["ExpiresOn"]
    @p.PermitNumber@Model.TypeName(p.RmsPermitTypeId)@Occ(p.RmsOccupancyId)@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(p.ApplicantName)@(RmsEnumDisplay.Label(p.State))@RmsEnumDisplay.Day(p.AppliedOn)@RmsEnumDisplay.Day(p.ExpiresOn)@L["ViewRecordHeader"]
    +
    + @if (Model.Total > Model.PageSize) + { +
      @for (var pg = 1; pg <= (Model.Total + Model.PageSize - 1) / Model.PageSize; pg++) {
    • @pg
    • }
    + } + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Types.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Types.cshtml new file mode 100644 index 000000000..77a4d5801 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Types.cshtml @@ -0,0 +1,54 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordPermitTypesView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["PermitTypes"]; +} +
    +
    +

    @L["PermitTypes"]

    + +
    +
    +
    + @if (!string.IsNullOrEmpty(Model.Message)) {
    @Model.Message
    } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
    @Model.ErrorMessage
    } +
    +
    +
    + + + + @foreach (var t in Model.Types) + { + + } + +
    @L["PermitCode"]@L["DefinitionName"]@L["ValidityDays"]@L["RequiresPlanReview"]@L["FeeAmount"]@L["Enabled"]
    @t.Code@t.Name@t.DefaultValidityDays@(t.RequiresPlanReview ? L["Yes"].Value : L["No"].Value)@t.FeeAmount@(t.IsActive ? L["Yes"].Value : L["No"].Value)@L["Edit"]
    + @L["NewPermitType"] +
    +
    +
    +
    @(string.IsNullOrWhiteSpace(Model.Editing.RmsPermitTypeId) ? L["NewPermitType"] : L["Edit"] + ": " + Model.Editing.Name)
    +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    @L["FeeAmountHelp"]
    +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml index b99deb65c..4194afb50 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml @@ -70,8 +70,13 @@ { @localizer["Accountability"] } + @if (Model.ModuleState.RecordsUsable && Model.QualityReviewOn && ClaimsAuthorizationHelper.CanReviewRecords()) + { + @localizer["QualityReview"] + } @if (Model.IsDepartmentAdmin) { + @localizer["RecordsHealth"] @localizer["FieldRollout"] @localizer["Settings"] } diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml index ead1d84fe..2a162bb5b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml @@ -81,7 +81,9 @@ // The draft is hydrated with canViewRestricted:true so the author's own save can round-trip it, so a withheld // field still arrives here carrying its plaintext. Blank it once, up front, rather than per input type: // "disabled" hides nothing from anyone reading the markup. - if (withheld) { Value = string.Empty; Reference = string.Empty; } + // Unit and Currency are part of the withheld entry too: a quantity's unit or a cost's currency is a fact + // about the entry, and "disabled" leaves the selected option readable in the markup. + if (withheld) { Value = string.Empty; Reference = string.Empty; Unit = field.DefaultUnit; Currency = field.DefaultCurrency; } sb.Append("
    "); sb.Append("