From 08ddf0ac524965f8b608c3183f6b17f0fedbaf1e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 6 Sep 2026 15:29:58 -0700 Subject: [PATCH] RG-T55 RMS Address PR #498 review findings and app fixes --- Core/Resgrid.Config/DataProtectionConfig.cs | 8 + Core/Resgrid.Config/RecordsFieldConfig.cs | 24 + .../Areas/User/Records/Records.ar.resx | 251 ++++ .../Areas/User/Records/Records.de.resx | 251 ++++ .../Areas/User/Records/Records.el.resx | 251 ++++ .../Areas/User/Records/Records.en.resx | 251 ++++ .../Areas/User/Records/Records.es.resx | 251 ++++ .../Areas/User/Records/Records.fr.resx | 251 ++++ .../Areas/User/Records/Records.it.resx | 251 ++++ .../Areas/User/Records/Records.pl.resx | 251 ++++ .../Areas/User/Records/Records.sv.resx | 251 ++++ .../Areas/User/Records/Records.uk.resx | 251 ++++ Core/Resgrid.Model/AdpTableBinding.cs | 12 + .../ProtectedFieldStorageKind.cs | 10 +- .../Records/FieldRecordsContracts.cs | 328 ++++++ .../Records/IncidentReportContracts.cs | 10 + .../Records/RecordsBulkContracts.cs | 52 + .../Resgrid.Model/Records/RecordsContracts.cs | 9 + .../Records/RmsEvidenceArtifact.cs | 5 +- .../Records/RmsExportTemplate.cs | 4 +- .../Records/RmsExternalOrders.cs | 277 +++++ .../Records/RmsIncidentReport.cs | 4 + .../Records/RmsProtectedFields.cs | 18 + .../Records/RmsRecordDefinitions.cs | 659 +++++++++++ .../Records/RmsRecordPrintLayout.cs | 65 +- Core/Resgrid.Model/Records/RmsRecordValues.cs | 499 ++++++++ .../Records/RmsRecordWorkAssignment.cs | 150 +++ Core/Resgrid.Model/Records/RmsSavedReport.cs | 139 +++ .../Resgrid.Model/Records/RmsTemplatePacks.cs | 242 ++++ .../IRmsDefinitionRepositories.cs | 93 ++ .../Repositories/IRmsExportRepositories.cs | 8 + .../Repositories/IRmsFieldRepositories.cs | 20 + .../Repositories/IRmsIncidentRepositories.cs | 8 + .../Repositories/IRmsRepositories.cs | 4 + .../Services/IFieldRecordsService.cs | 27 + .../Services/IRecordDefinitionsService.cs | 60 + .../Services/IRecordDeploymentsService.cs | 28 + .../Services/IRecordSavedReportsService.cs | 19 + .../Services/IRecordTemplatePacksService.cs | 25 + .../Services/IRecordTypedValuesService.cs | 46 + .../Services/IRecordWorkAssignmentsService.cs | 32 + .../Services/IRecordsBulkPacketService.cs | 22 + .../Services/IRecordsPrintLayoutService.cs | 9 + .../Services/IRecordsProtectionService.cs | 9 + .../Services/IRecordsRevealService.cs | 25 + .../Resgrid.Model/Services/IRecordsService.cs | 3 + .../WorkflowTemplateVariableCatalog.cs | 42 + .../Resgrid.Model/WorkflowTriggerEventType.cs | 9 + Core/Resgrid.Services/AdpTableBindings.cs | 14 +- .../DepartmentDataMigrationEngine.cs | 50 + .../Resgrid.Services/ProtectedFieldCatalog.cs | 15 + .../Evidence/PackProjectionEvidenceAdapter.cs | 218 ++++ .../Records/FieldRecordsService.cs | 582 +++++++++ .../Records/IncidentAnalysisService.cs | 3 +- .../Records/IncidentReportsService.cs | 33 +- .../Records/RecordDefinitionsService.cs | 949 +++++++++++++++ .../Records/RecordDeploymentsService.cs | 388 ++++++ .../Records/RecordEvidenceSelectionService.cs | 6 + .../Records/RecordSavedReportsService.cs | 333 ++++++ .../Records/RecordSnapshotSerializer.cs | 42 + .../Records/RecordTemplateCatalog.cs | 461 ++++++++ .../Records/RecordTemplatePacksService.cs | 185 +++ .../Records/RecordTypedValuesService.cs | 1041 +++++++++++++++++ .../Records/RecordWorkAssignmentsService.cs | 274 +++++ .../Records/RecordsBulkPacketService.cs | 278 +++++ .../RecordsDisclosureService.Packet.cs | 3 + .../Records/RecordsDocumentService.cs | 122 +- .../Records/RecordsExportService.cs | 76 +- .../Records/RecordsLegalHoldService.cs | 11 +- .../Records/RecordsNfirsLegacyService.cs | 10 +- .../Records/RecordsPrintLayoutService.cs | 93 ++ .../Records/RecordsProtectionService.cs | 38 +- .../Records/RecordsRevealService.cs | 87 ++ .../Records/RecordsService.cs | 169 ++- Core/Resgrid.Services/ServicesModule.cs | 12 + .../WorkflowSampleDataGenerator.cs | 38 + .../WorkflowTemplateContextBuilder.cs | 4 +- .../M0158_AddRmsRecordDefinitions.cs | 152 +++ .../Migrations/M0159_AddRmsRecordValues.cs | 86 ++ .../Migrations/M0161_AddRmsSavedReports.cs | 46 + ...RmsTemplatePacksAndJurisdictionProfiles.cs | 82 ++ .../M0163_AddRmsExternalOrderReferences.cs | 132 +++ .../M0178_RmsProtectedDataCatalogV11.cs | 36 + .../M0179_AddRmsRecordWorkAssignments.cs | 64 + .../M0158_AddRmsRecordDefinitionsPg.cs | 152 +++ .../Migrations/M0159_AddRmsRecordValuesPg.cs | 86 ++ .../Migrations/M0161_AddRmsSavedReportsPg.cs | 46 + ...sTemplatePacksAndJurisdictionProfilesPg.cs | 82 ++ .../M0163_AddRmsExternalOrderReferencesPg.cs | 132 +++ .../M0178_RmsProtectedDataCatalogV11Pg.cs | 34 + .../M0179_AddRmsRecordWorkAssignmentsPg.cs | 60 + .../DepartmentDataProtectionBulkRepository.cs | 29 +- .../Modules/DataModule.cs | 14 + .../Modules/TestingDataModule.cs | 14 + .../RmsDefinitionRepositories.cs | 232 ++++ .../RmsExportRepositories.cs | 10 + .../RmsFieldRepositories.cs | 89 ++ .../RmsIncidentRepositories.cs | 10 + .../RmsRepositories.cs | 17 + .../Allocations/trigger-baseline.json | 2 + .../TranslationCompletenessTests.cs | 25 + Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs | 4 + .../Rms/FakeRmsDefinitionStore.cs | 183 +++ .../Rms/FieldRecordCatalogTests.cs | 321 +++++ .../Rms/IncidentOfficerJourneyTests.cs | 2 +- .../Rms/IncidentReportsServiceTests.cs | 36 + .../Rms/Parity/RecordsParityHarness.cs | 4 +- .../Rms/PassthroughRecordsProtection.cs | 3 + .../Rms/RecordDefinitionsServiceTests.cs | 275 +++++ .../Rms/RecordDeploymentsServiceTests.cs | 195 +++ .../Rms/RecordEvidenceAdapterTests.cs | 28 + .../RecordOperationalSummaryServiceTests.cs | 2 +- .../Rms/RecordSavedReportsServiceTests.cs | 150 +++ .../Rms/RecordTemplateCatalogTests.cs | 152 +++ .../Rms/RecordTypedValuesServiceTests.cs | 365 ++++++ .../Rms/RecordWorkAssignmentsServiceTests.cs | 181 +++ .../Rms/RecordsBulkPacketServiceTests.cs | 165 +++ .../Rms/RecordsDefinitionLayoutTests.cs | 150 +++ .../Rms/RecordsDisclosureServiceTests.cs | 2 +- .../Resgrid.Tests/Rms/RecordsDocumentTests.cs | 2 +- .../Rms/RecordsExportServiceTests.cs | 53 + .../Rms/RecordsNfirsLegacyServiceTests.cs | 7 +- .../Rms/RecordsServiceDefinitionTests.cs | 167 +++ .../Resgrid.Tests/Rms/RecordsServiceTests.cs | 2 +- .../Rms/RmsContainerCompositionTests.cs | 24 + .../Resgrid.Tests/Rms/RmsDefinitionHarness.cs | 157 +++ .../Rms/RmsIdentifierPinTests.cs | 2 + .../Rms/RmsProtectedFieldsCatalogTests.cs | 2 +- .../Services/AdpSizingServiceTests.cs | 6 +- .../Services/BrokerOperationServiceTests.cs | 67 ++ .../Services/ProtectedReadServiceTests.cs | 4 +- .../RemainingCandidateProtectionTests.cs | 9 +- .../FieldRecordsApiControllerTests.cs | 179 +++ .../RecordDefinitionsApiControllerTests.cs | 127 ++ .../Web/Services/RecordsApiControllerTests.cs | 2 +- .../Controllers/BrokerController.cs | 13 + .../Services/BrokerOperationService.cs | 44 +- .../Controllers/v4/FieldRecordsController.cs | 291 +++++ .../v4/IncidentReportsController.cs | 28 +- .../v4/RecordDefinitionsController.cs | 397 +++++++ .../v4/RecordDeploymentsController.cs | 233 ++++ .../v4/RecordExportTemplatesController.cs | 187 +++ .../v4/RecordSavedReportsController.cs | 165 +++ .../Controllers/v4/RecordsController.cs | 100 +- .../Helpers/RecordsApiHelper.cs | 6 +- .../Helpers/RecordsRms1bApiMapper.cs | 195 +++ .../Helpers/RecordsRms3ApiHelper.cs | 1 + .../v4/Records/FieldRecordsApiModels.cs | 196 ++++ .../v4/Records/IncidentReportsApiModels.cs | 7 + .../Models/v4/Records/RecordsApiModels.cs | 4 + .../v4/Records/RecordsRms1bApiModels.cs | 646 ++++++++++ .../Resgrid.Web.Services.xml | 189 +++ .../Controllers/IncidentAnalysisController.cs | 2 + .../Controllers/IncidentReportsController.cs | 47 +- .../RecordDefinitionsController.cs | 338 ++++++ .../RecordDeploymentsController.cs | 220 ++++ .../Controllers/RecordEvidenceController.cs | 3 +- .../RecordSavedReportsController.cs | 154 +++ .../User/Controllers/RecordsController.cs | 254 +++- .../Records/IncidentSectionViewModels.cs | 6 + .../Records/RecordDefinitionsViewModels.cs | 430 +++++++ .../User/Models/Records/RecordsViewModels.cs | 11 + .../User/Views/IncidentAnalysis/Edit.cshtml | 1 + .../User/Views/IncidentReports/Edit.cshtml | 4 +- .../Views/RecordDefinitions/Create.cshtml | 88 ++ .../User/Views/RecordDefinitions/Edit.cshtml | 141 +++ .../Views/RecordDefinitions/History.cshtml | 85 ++ .../Views/RecordDefinitions/Impact.cshtml | 73 ++ .../User/Views/RecordDefinitions/Index.cshtml | 75 ++ .../Views/RecordDefinitions/Layout.cshtml | 102 ++ .../Views/RecordDefinitions/Templates.cshtml | 53 + .../Views/RecordDeployments/Details.cshtml | 136 +++ .../User/Views/RecordDeployments/Index.cshtml | 51 + .../User/Views/RecordDeployments/New.cshtml | 64 + .../User/Views/RecordEvidence/Select.cshtml | 7 +- .../User/Views/RecordSavedReports/Edit.cshtml | 90 ++ .../Views/RecordSavedReports/Index.cshtml | 59 + .../User/Views/RecordSavedReports/Run.cshtml | 52 + .../Areas/User/Views/Records/Details.cshtml | 11 +- .../User/Views/Records/EditDefinition.cshtml | 146 +++ .../Areas/User/Views/Records/Index.cshtml | 57 + .../Areas/User/Views/Records/Print.cshtml | 6 +- .../Views/Records/_DefinitionFields.cshtml | 196 ++++ .../Views/Records/_DefinitionValues.cshtml | 49 + .../Helpers/IncidentGuidedFormMapper.cs | 1 + .../dataprotection/resgrid.adp.reveal.js | 5 + .../wwwroot/js/record-definition-form.js | 164 +++ 187 files changed, 21421 insertions(+), 171 deletions(-) create mode 100644 Core/Resgrid.Config/RecordsFieldConfig.cs create mode 100644 Core/Resgrid.Model/Records/FieldRecordsContracts.cs create mode 100644 Core/Resgrid.Model/Records/RecordsBulkContracts.cs create mode 100644 Core/Resgrid.Model/Records/RmsExternalOrders.cs create mode 100644 Core/Resgrid.Model/Records/RmsRecordDefinitions.cs create mode 100644 Core/Resgrid.Model/Records/RmsRecordValues.cs create mode 100644 Core/Resgrid.Model/Records/RmsRecordWorkAssignment.cs create mode 100644 Core/Resgrid.Model/Records/RmsSavedReport.cs create mode 100644 Core/Resgrid.Model/Records/RmsTemplatePacks.cs create mode 100644 Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs create mode 100644 Core/Resgrid.Model/Repositories/IRmsFieldRepositories.cs create mode 100644 Core/Resgrid.Model/Services/IFieldRecordsService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordDefinitionsService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordDeploymentsService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordSavedReportsService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordTemplatePacksService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordTypedValuesService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordWorkAssignmentsService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsBulkPacketService.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsRevealService.cs create mode 100644 Core/Resgrid.Services/Records/Evidence/PackProjectionEvidenceAdapter.cs create mode 100644 Core/Resgrid.Services/Records/FieldRecordsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordDefinitionsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordDeploymentsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordSavedReportsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordTemplateCatalog.cs create mode 100644 Core/Resgrid.Services/Records/RecordTemplatePacksService.cs create mode 100644 Core/Resgrid.Services/Records/RecordTypedValuesService.cs create mode 100644 Core/Resgrid.Services/Records/RecordWorkAssignmentsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsBulkPacketService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsRevealService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0158_AddRmsRecordDefinitions.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0159_AddRmsRecordValues.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0161_AddRmsSavedReports.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0162_AddRmsTemplatePacksAndJurisdictionProfiles.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0163_AddRmsExternalOrderReferences.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0178_RmsProtectedDataCatalogV11.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0179_AddRmsRecordWorkAssignments.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0158_AddRmsRecordDefinitionsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0159_AddRmsRecordValuesPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0161_AddRmsSavedReportsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0162_AddRmsTemplatePacksAndJurisdictionProfilesPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0163_AddRmsExternalOrderReferencesPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0178_RmsProtectedDataCatalogV11Pg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0179_AddRmsRecordWorkAssignmentsPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs create mode 100644 Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs create mode 100644 Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordDefinitionsServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordDeploymentsServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordSavedReportsServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordTemplateCatalogTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordTypedValuesServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordWorkAssignmentsServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsDefinitionLayoutTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsServiceDefinitionTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/FieldRecordsApiControllerTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/RecordDefinitionsApiControllerTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordDefinitionsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordExportTemplatesController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordSavedReportsController.cs create mode 100644 Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Records/FieldRecordsApiModels.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordDefinitionsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Create.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/History.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Impact.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Layout.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Templates.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Details.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordDeployments/New.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordSavedReports/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordSavedReports/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordSavedReports/Run.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/EditDefinition.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionValues.cshtml create mode 100644 Web/Resgrid.Web/wwwroot/js/record-definition-form.js diff --git a/Core/Resgrid.Config/DataProtectionConfig.cs b/Core/Resgrid.Config/DataProtectionConfig.cs index 64827e91..1b9ac9ba 100644 --- a/Core/Resgrid.Config/DataProtectionConfig.cs +++ b/Core/Resgrid.Config/DataProtectionConfig.cs @@ -52,6 +52,14 @@ public static class DataProtectionConfig /// Maximum field items one broker request may carry; larger requests are refused. public static int BrokerMaxItemsPerRequest = 200; + /// + /// Purposes the broker's workload decrypt lane (POST api/v1/broker/workload/decrypt?purpose=) accepts, comma + /// separated (RMS plan section 5.9.4). Each purpose is an egress the department acknowledged in the + /// application before the caller reaches the broker: neris-submission (worker 41) and records-export + /// (worker 45 / Workflow renders). Empty disables the lane; callers fail closed with workload_purpose_denied. + /// + public static string BrokerWorkloadPurposes = "neris-submission,records-export"; + /// True on the broker host to run the ADP migration coordinator sweep there (the only /// host with a real KMS adapter). Workers.Console keeps its sweep for liveness/offboarding /// flips but never runs nights — its engine reports unavailable. diff --git a/Core/Resgrid.Config/RecordsFieldConfig.cs b/Core/Resgrid.Config/RecordsFieldConfig.cs new file mode 100644 index 00000000..5f145851 --- /dev/null +++ b/Core/Resgrid.Config/RecordsFieldConfig.cs @@ -0,0 +1,24 @@ +namespace Resgrid.Config +{ + /// + /// Field Records client change control (RMS plan RMS-1D): the minimum app version each operational app must + /// report before the server hands it a Field Records catalog, and the sync page bound. A blank minimum accepts + /// any version. Environment keys: RESGRID:RecordsFieldConfig:MinimumResponderVersion and so on. + /// + public static class RecordsFieldConfig + { + public static string MinimumResponderVersion = ""; + public static string MinimumUnitVersion = ""; + public static string MinimumIncidentCommandVersion = ""; + public static string MinimumDispatchVersion = ""; + + /// Most rows one sync page carries; a field bundle is a working set, not an archive pull. + public static int SyncTakeMax = 200; + + /// Most drafts and returned Records of the caller included in one bundle. + public static int SyncDraftsMax = 100; + + /// Most open work assignments returned to one caller. + public static int AssignmentsMax = 200; + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index 6534225e..2751a5d2 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -1064,4 +1064,255 @@ تم الإقرار في يُطبَّق نظام حماية البيانات المتقدّمة: ستفشل عمليات الإرسال المحمية حتى يُسجَّل هذا الإقرار. معرّف السجل + إضافة صف + إزالة الصف + محجوب + الإحداثيات (خط العرض، خط الطول) أو مرجع المكان + أوقّع هذا السجل بصفتي المؤلف + نعم + لا + تعديل + مفعّل + معاينة + يتبع هذا السجل تعريفًا خاصًا بالقسم. الحقول المميزة بقفل مقيدة؛ وقد تُظهر القواعد حقولًا أو تطلبها أثناء الكتابة. + التعريفات + تصف تعريفات السجلات النماذج التي يعبّئها قسمك: الأقسام والحقول والقواعد والترقيم ومن يراجع أو يعتمد. النشر يجمّد الإصدار؛ وتثبّت السجلات الإصدار الذي كُتبت به. + تعريف جديد + إنشاء تعريف + البدء من قالب + استنساخ تعريف موجود + تعريف فارغ + استعراض القوالب + حزم القوالب هي نقاط انطلاق مُراجعة. تعرض كل حزمة مصادرها وتاريخ مراجعتها؛ وحزم المعاينة قابلة للاستخدام لكنها ما تزال قيد التحقق مع الأقسام. + تمت المراجعة + المصادر + الأقسام + الحقول + استخدام هذا القالب + ملف الاختصاص + الإعدادات الإقليمية + معاينة القالب + أحرف صغيرة وأرقام ونقاط وشرطات. لا يمكن تغييره بعد الإنشاء. + الاسم + الفئة + الوصف + المالك + المنتج (مقفل) + الإصدار المنشور + إصدار المسودة + إعداد دورة الحياة + قدرة العميل + متقاعد + إظهار التعريفات المتقاعدة + إخفاء التعريفات المتقاعدة + السجل + الإصدار + الحالة + نُشر في + ملاحظات التغيير + الإعدادات + الموضوعات المسموح بها + أدوار المراجع + أدوار المعتمد + مهلة المراجعة (ساعات) + مهلة الاعتماد (ساعات) + طلب إقرار المؤلف عند الإنهاء + الترقيم + بادئة الرقم + عرض التسلسل + تعيين الرقم + تسلسل لكل محطة + إعادة تعيين سنويًا + التصنيف + الاحتفاظ (سنوات) + واجهات العميل + السماح دون اتصال + السماح بالمرفقات + خريطة الترحيل (JSON) + المخطط (JSON) + تحتوي الأقسام على حقول؛ لكل حقل مفتاح وتسمية ونوع وقواعد اختيارية. تحقق قبل النشر؛ يعكس جدول الحقول أدناه آخر مسودة محفوظة. + الحقول + متكرر + القواعد + المفتاح + التسمية + نوع الحقل + مطلوب + التصنيف + العلامات + تحقق + حفظ المسودة + مراجعة الأثر والنشر + فتح مسودة جديدة + حذف المسودة + هل تريد حذف هذه المسودة؟ لن تتأثر الإصدارات المنشورة. + هذا الإصدار منشور وللقراءة فقط. افتح مسودة جديدة لإجراء التغييرات. + مشكلات التحقق + الاختلافات عن القالب الذي أُنشئ منه هذا التعريف + أثر النشر + النشر يجمّد هذا الإصدار. تحتفظ المسودات الحالية بإصدارها حتى ترحيلها؛ والسجلات المنتهية لا تتغير أبدًا. + المسودات المفتوحة على الإصدار الحالي + السجلات المنتهية على إصدارات سابقة + تغيير جذري + جاهزية العملاء + التغييرات + مقارنة من + إلى + مقارنة + لا توجد اختلافات بين هذين الإصدارين. + هل تريد نشر هذا الإصدار؟ سيصبح للقراءة فقط وستستخدمه السجلات الجديدة. + نشر + يتطلب النشر إذن إدارة تعريفات السجلات. + إيقاف + سبب إيقاف هذا التعريف + ترحيل المسودات المفتوحة + معاينة الترحيل + الترحيل الآن + هل تريد نقل جميع المسودات المفتوحة إلى الإصدار الأحدث؟ سيتم إسقاط القيم غير المعينة. + تم إنشاء التعريف. حرّر المخطط وتحقق منه ثم انشره. + تم حفظ المسودة. + التعريف صالح. + يحتوي التعريف على أخطاء. أصلحها قبل النشر. + تم حذف المسودة. + تم نشر الإصدار {0}. + تم إيقاف التعريف. تبقى السجلات الحالية قابلة للقراءة؛ ولا يمكن إنشاء سجلات جديدة. + التقارير المحفوظة + تستعلم التقارير المحفوظة عن سجلات تعريف واحد حسب الحقل وتجمعها وتلخصها وتصدّرها إلى CSV. تحترم النتائج رؤية السجلات والحقول المقيدة. + تقرير جديد + اسم التقرير + إعدادات التقرير + الأعمدة + أعمدة السجل + اختر تعريفًا لتحديد الأعمدة. + ترتيب حسب + تنازلي + تجميع حسب + التجميعات (JSON) + Count وSum وAverage وMin وMax على الحقول القابلة للتجميع + عوامل التصفية (JSON) + الحقول القابلة للتصفية + تعيينات الإصدارات (JSON) + الحد الأقصى للصفوف + تضمين المسودات + تضمين الحقول المقيدة + تحقق + تشغيل + آخر تشغيل + هل تريد حذف هذا التقرير المحفوظ؟ + الصفوف + المجموعات + العدد + تم اقتطاع النتائج عند حد الصفوف؛ ضيّق عوامل التصفية أو صدّر إلى CSV. + تم تضمين السجلات على إصدارات تعريف غير معينة مع خلايا فارغة + تم حفظ التقرير. + تم حذف التقرير. + التقرير صالح. + يحتوي التقرير على أخطاء. + الانتشارات + طلبات موارد خارجية يلبيها قسمك. يبقى نظام الطلب هو المرجع: تُخزَّن المستندات كلقطات ثابتة ولا يُكتب شيء إليه. + انتشار جديد + الانتشارات ميزة معاينة: بُنيت النماذج من وثائق IROC وCIFFC وEMAC المنشورة وليس من تكاملات مباشرة. تحقق منها مقابل نظام الطلب لديك. + الطلب + رقم الطلب + الحادث + الدولة / المنطقة + المكاتب + مكتب الطلب + مكتب الإرسال + الجهات + الجهة الطالبة + الجهة المستلمة + الجهة المرسلة + دور القسم + يلبي الطلب + يطلب الموارد + يستضيف الحادث + التكلفة + رمز التكلفة + مرجع الاتفاقية + العملة / الوحدات / المنطقة الزمنية + مستند الطلب + ارفع مستند الطلب من النظام المصدر (PDF أو JSON أو CSV). يُخزَّن كلقطة ثابتة مع مجموع تحقق. + المصدر + النظام المصدر + إصدار المصدر + فتح في النظام المصدر + الحالة + إظهار الانتشارات المغلقة + إخفاء الانتشارات المغلقة + تمت التعبئة + تم الإفراج + تم الإغلاق + تسجيل لقطة جديدة + حفظ اللقطة + إغلاق الانتشار + ملاحظات الإغلاق + هل تريد إغلاق هذا الانتشار؟ لن يمكن تغيير التلبيات بعد ذلك. + يجب أن تكون كل تلبية مقبولة بحالة عادت قبل الإغلاق. + التلبيات + التلبية الأولى (اختياري) + أضف أول طلب/تلبية الآن أو من صفحة الانتشار. + إضافة تلبية + رقم الطلب الفرعي + الطلب الأصل + المورد + المنصب + متدرب + الشخص المعيّن + الوحدة الأصلية + الجهة المضيفة + مطلوب في + سجل الانتشار + إنشاء انتشار + سبب رفض هذا الطلب + تم إنشاء الانتشار. + تمت إضافة التلبية. + تم تحديث التلبية. + اختر مستند الطلب لحفظه كلقطة. + تم حفظ اللقطة. تُحفظ اللقطة السابقة وتُستبدل. + تم إغلاق الانتشار. + تخطيط الطباعة + رتّب الأقسام والحقول أو أعد تسميتها أو أخفها للطباعة وملفات PDF، وأضف فواصل الصفحات، وحدد موضع كتلة التوقيع وقائمة المرفقات. كل حفظ ينشئ إصدار تخطيط جديدًا يُختم في تذييل المصدر. + إصدار التخطيط + الافتراضي للقسم + الترتيب + عنوان بديل + مرئي + فاصل صفحة قبل + الحقول + ينطبق على الإصدار + جميع الإصدارات + كتلة التوقيع + في النهاية + ضمن قسمه + مخفي + قائمة المرفقات + جدول + قائمة بسيطة + مخفية + تجاوز هوية القسم لهذا التعريف + ترويسة + تم حفظ تخطيط الطباعة. + محدد + تحديد الكل في هذه الصفحة + المراجع… + السبب + تعيين للمراجعة + عنوان الحزمة + الغرض (مُدقّق) + إرسال الحزمة بالبريد إلى (اختياري) + ملف PDF مجمّع + حزمة zip + حتى {0} سجلًا لكل حزمة. لا إلغاء أو حذف جماعي. + حدد سجلًا واحدًا على الأقل. + تم تعيين {0} سجل(ات) للمراجعة؛ تم تخطي {1}. + تم تجميع الحزمة من {0} سجل(ات)؛ تم تخطي {1}. تبقى قابلة للتنزيل لمدة 30 يومًا. + تم الإرسال بالبريد. + إسقاط الوحدة + يُنشئ لقطة محدودة من الوحدة المالكة: المعرّفات والرموز والأعداد والحالات فقط مع مراجع المصدر. تبقى الوحدة هي نظام السجل. + الإسقاط + تسجيل حضور الأفراد + ملخص الموارد + المؤهلات + ملخص القيادة diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index b137f555..c64259a8 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -1064,4 +1064,255 @@ Bestätigt am Erweiterter Datenschutz ist erzwungen: Geschützte Übermittlungen schlagen fehl, bis diese Bestätigung erfasst ist. Datensatz-ID + Zeile hinzufügen + Zeile entfernen + Zurückgehalten + Koordinaten (Breite, Länge) oder Ortsreferenz + Ich unterzeichne diesen Datensatz als Autor + Ja + Nein + Bearbeiten + Aktiviert + Vorschau + Dieser Datensatz folgt einer Abteilungsdefinition. Mit einem Schloss markierte Felder sind eingeschränkt; Regeln können Felder während der Eingabe ein- oder ausblenden bzw. erfordern. + Definitionen + Datensatzdefinitionen beschreiben die Formulare Ihrer Abteilung: Abschnitte, Felder, Regeln, Nummerierung und wer prüft oder genehmigt. Veröffentlichen friert eine Version ein; Datensätze binden sich an die Version, mit der sie erstellt wurden. + Neue Definition + Definition erstellen + Aus Vorlage starten + Vorhandene Definition klonen + Leere Definition + Vorlagen durchsuchen + Vorlagenpakete sind geprüfte Ausgangspunkte. Jedes zeigt seine Quellen und das Prüfdatum; Vorschau-Pakete sind nutzbar, werden aber noch mit Abteilungen validiert. + Geprüft am + Quellen + Abschnitte + Felder + Diese Vorlage verwenden + Zuständigkeitsprofil + Sprachraum + Vorlagenvorschau + Kleinbuchstaben, Ziffern, Punkte und Bindestriche. Kann nach der Erstellung nicht geändert werden. + Name + Kategorie + Beschreibung + Eigentümer + Produkt (gesperrt) + Veröffentlichte Version + Entwurfsversion + Lebenszyklus-Voreinstellung + Client-Fähigkeit + Ausgemustert + Ausgemusterte Definitionen anzeigen + Ausgemusterte Definitionen ausblenden + Verlauf + Version + Status + Veröffentlicht am + Änderungshinweise + Einstellungen + Zulässige Betreffe + Prüferrollen + Genehmigerrollen + Prüffrist (Stunden) + Genehmigungsfrist (Stunden) + Autorbestätigung beim Abschließen verlangen + Nummerierung + Nummernpräfix + Sequenzbreite + Nummer vergeben + Sequenz pro Wache + Jährlich zurücksetzen + Klassifizierung + Aufbewahrung (Jahre) + Client-Oberflächen + Offline erlauben + Anhänge erlauben + Migrationszuordnung (JSON) + Schema (JSON) + Abschnitte enthalten Felder; jedes Feld hat Schlüssel, Bezeichnung, Typ und optionale Regeln. Vor dem Veröffentlichen validieren; die Feldtabelle unten zeigt den zuletzt gespeicherten Entwurf. + Felder + Wiederholend + Regeln + Schlüssel + Bezeichnung + Feldtyp + Erforderlich + Klassifizierung + Merkmale + Validieren + Entwurf speichern + Auswirkungen prüfen & veröffentlichen + Neuen Entwurf öffnen + Entwurf löschen + Diese Entwurfsversion löschen? Veröffentlichte Versionen bleiben unberührt. + Diese Version ist veröffentlicht und schreibgeschützt. Öffnen Sie einen neuen Entwurf für Änderungen. + Validierungsprobleme + Abweichungen von der Vorlage, aus der diese Definition erstellt wurde + Auswirkungen der Veröffentlichung + Veröffentlichen friert diese Version ein. Vorhandene Entwürfe behalten ihre Version bis zur Migration; abgeschlossene Datensätze ändern sich nie. + Offene Entwürfe der aktuellen Version + Abgeschlossene Datensätze auf früheren Versionen + Inkompatible Änderung + Client-Bereitschaft + Änderungen + Vergleichen von + bis + Vergleichen + Keine Unterschiede zwischen diesen Versionen. + Diese Version veröffentlichen? Sie wird schreibgeschützt und neue Datensätze verwenden sie. + Veröffentlichen + Veröffentlichen erfordert die Berechtigung zum Verwalten von Datensatzdefinitionen. + Ausmustern + Grund für das Ausmustern dieser Definition + Offene Entwürfe migrieren + Migration vorab anzeigen + Jetzt migrieren + Alle offenen Entwürfe auf die neuere Version verschieben? Nicht zugeordnete Werte gehen verloren. + Definition erstellt. Schema bearbeiten, validieren, dann veröffentlichen. + Entwurf gespeichert. + Die Definition ist gültig. + Die Definition enthält Fehler. Beheben Sie sie vor dem Veröffentlichen. + Entwurfsversion gelöscht. + Version {0} veröffentlicht. + Definition ausgemustert. Vorhandene Datensätze bleiben lesbar; neue können nicht erstellt werden. + Gespeicherte Berichte + Gespeicherte Berichte fragen die Datensätze einer Definition nach Feldern ab, gruppieren und aggregieren sie und exportieren nach CSV. Ergebnisse respektieren Sichtbarkeit und eingeschränkte Felder. + Neuer Bericht + Berichtsname + Berichtseinstellungen + Spalten + Datensatzspalten + Wählen Sie eine Definition, um Spalten auszuwählen. + Sortieren nach + Absteigend + Gruppieren nach + Aggregate (JSON) + Count, Sum, Average, Min, Max auf aggregierbaren Feldern + Filter (JSON) + Filterbare Felder + Versionszuordnungen (JSON) + Max. Zeilen + Entwürfe einbeziehen + Eingeschränkte Felder einbeziehen + Validieren + Ausführen + Letzte Ausführung + Diesen gespeicherten Bericht löschen? + Zeilen + Gruppen + Anzahl + Ergebnisse wurden am Zeilenlimit abgeschnitten; Filter einschränken oder als CSV exportieren. + Datensätze auf nicht zugeordneten Definitionsversionen wurden mit leeren Zellen einbezogen + Bericht gespeichert. + Bericht gelöscht. + Der Bericht ist gültig. + Der Bericht enthält Fehler. + Einsätze + Externe Ressourcenanforderungen, die Ihre Abteilung erfüllt. Das anfordernde System bleibt maßgeblich: Artefakte werden als unveränderliche Snapshots gespeichert, nichts wird zurückgeschrieben. + Neuer Einsatz + Einsätze sind eine Vorschaufunktion: Vorlagen wurden aus veröffentlichter IROC-, CIFFC- und EMAC-Dokumentation erstellt, nicht aus Live-Integrationen. Prüfen Sie gegen Ihr Anforderungssystem. + Anforderung + Anforderungsnummer + Ereignis + Land / Region + Stellen + Anfordernde Stelle + Leitstelle + Behörden + Anfordernde Behörde + Empfangende Behörde + Entsendende Behörde + Rolle der Abteilung + Erfüllt die Anforderung + Fordert Ressourcen an + Ausrichter des Ereignisses + Kosten + Kostenstelle + Vereinbarungsreferenz + Währung / Einheiten / Zeitzone + Anforderungsdokument + Laden Sie das Anforderungsdokument aus dem Quellsystem hoch (PDF, JSON oder CSV). Es wird als unveränderlicher Snapshot mit Prüfsumme gespeichert. + Quelle + Quellsystem + Quellversion + Im Quellsystem öffnen + Status + Abgeschlossene Einsätze anzeigen + Abgeschlossene Einsätze ausblenden + Mobilisiert + Freigegeben + Abgeschlossen + Neuen Snapshot erfassen + Snapshot speichern + Einsatz abschließen + Abschlussnotizen + Diesen Einsatz abschließen? Besetzungen können danach nicht mehr geändert werden. + Jede angenommene Besetzung muss vor dem Abschluss zurückgekehrt sein. + Besetzungen + Erste Besetzung (optional) + Fügen Sie die erste Anfrage/Besetzung jetzt hinzu oder später auf der Einsatzseite. + Besetzung hinzufügen + Anfragenummer + Übergeordnete Anfrage + Ressource + Position + Auszubildende/r + Zugewiesene Person + Heimateinheit + Gastgebende Behörde + Benötigt am + Einsatzdatensatz + Einsatz erstellen + Grund für die Ablehnung dieser Anfrage + Einsatz erstellt. + Besetzung hinzugefügt. + Besetzung aktualisiert. + Wählen Sie das Anforderungsdokument, das als Snapshot gespeichert werden soll. + Snapshot gespeichert. Der vorherige Snapshot bleibt erhalten und wird ersetzt. + Einsatz abgeschlossen. + Drucklayout + Abschnitte und Felder für Druck und PDF anordnen, umbenennen oder ausblenden, Seitenumbrüche hinzufügen und Unterschriftsblock sowie Anhangsliste platzieren. Jedes Speichern erzeugt eine neue Layoutversion, die in der Herkunftsfußzeile vermerkt wird. + Layoutversion + Abteilungsstandard + Reihenfolge + Überschrift überschreiben + Sichtbar + Seitenumbruch davor + Felder + Gilt für Version + Alle Versionen + Unterschriftsblock + Am Ende + Innerhalb des Abschnitts + Ausgeblendet + Anhangsliste + Tabelle + Einfache Liste + Ausgeblendet + Abteilungsbranding für diese Definition überschreiben + Briefkopf + Drucklayout gespeichert. + ausgewählt + Alle auf dieser Seite auswählen + Prüfer… + Grund + Zur Prüfung zuweisen + Pakettitel + Zweck (protokolliert) + Paket per E-Mail an (optional) + Zusammengefasstes PDF + Zip-Paket + Bis zu {0} Datensätze pro Paket. Kein Massen-Stornieren oder -Löschen. + Wählen Sie mindestens einen Datensatz aus. + {0} Datensatz/Datensätze zur Prüfung zugewiesen; {1} übersprungen. + Paket aus {0} Datensatz/Datensätzen erstellt; {1} übersprungen. 30 Tage lang herunterladbar. + Per E-Mail gesendet. + Modulprojektion + Erstellt einen begrenzten Schnappschuss aus dem zuständigen Modul: nur Kennungen, Codes, Zähler und Status mit Quellverweisen. Das Modul bleibt das führende System. + Projektion + Personal-Check-in + Ressourcenübersicht + Qualifikationen + Führungsübersicht diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index 5a4ba04c..ee0f9afc 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -1064,4 +1064,255 @@ Επιβεβαιώθηκε στις Η Προηγμένη Προστασία Δεδομένων επιβάλλεται: οι προστατευμένες υποβολές θα αποτυγχάνουν μέχρι να καταγραφεί αυτή η επιβεβαίωση. Αναγνωριστικό εγγραφής + Προσθήκη γραμμής + Αφαίρεση γραμμής + Παρακρατήθηκε + Συντεταγμένες (γ. πλάτος, γ. μήκος) ή αναφορά τοποθεσίας + Υπογράφω αυτήν την εγγραφή ως συντάκτης + Ναι + Όχι + Επεξεργασία + Ενεργοποιημένο + Προεπισκόπηση + Αυτή η εγγραφή ακολουθεί έναν ορισμό του τμήματος. Τα πεδία με κλειδαριά είναι περιορισμένα· κανόνες μπορεί να εμφανίζουν ή να απαιτούν πεδία καθώς πληκτρολογείτε. + Ορισμοί + Οι ορισμοί εγγραφών περιγράφουν τις φόρμες του τμήματός σας: ενότητες, πεδία, κανόνες, αρίθμηση και ποιος ελέγχει ή εγκρίνει. Η δημοσίευση παγώνει μια έκδοση· οι εγγραφές δεσμεύονται στην έκδοση με την οποία γράφτηκαν. + Νέος ορισμός + Δημιουργία ορισμού + Έναρξη από πρότυπο + Κλωνοποίηση υπάρχοντος ορισμού + Κενός ορισμός + Περιήγηση προτύπων + Τα πακέτα προτύπων είναι ελεγμένα σημεία εκκίνησης. Καθένα δείχνει τις πηγές και την ημερομηνία ελέγχου του· τα πακέτα προεπισκόπησης είναι χρησιμοποιήσιμα αλλά ακόμη επικυρώνονται με τμήματα. + Ελέγχθηκε + Πηγές + Ενότητες + Πεδία + Χρήση αυτού του προτύπου + Προφίλ δικαιοδοσίας + Τοπικές ρυθμίσεις + Προεπισκόπηση προτύπου + Πεζά γράμματα, ψηφία, τελείες και παύλες. Δεν αλλάζει μετά τη δημιουργία. + Όνομα + Κατηγορία + Περιγραφή + Κάτοχος + Προϊόν (κλειδωμένο) + Δημοσιευμένη έκδοση + Πρόχειρη έκδοση + Προεπιλογή κύκλου ζωής + Δυνατότητα πελάτη + Αποσυρμένος + Εμφάνιση αποσυρμένων ορισμών + Απόκρυψη αποσυρμένων ορισμών + Ιστορικό + Έκδοση + Κατάσταση + Δημοσιεύθηκε + Σημειώσεις αλλαγών + Ρυθμίσεις + Επιτρεπόμενα θέματα + Ρόλοι ελεγκτή + Ρόλοι εγκρίνοντος + Προθεσμία ελέγχου (ώρες) + Προθεσμία έγκρισης (ώρες) + Απαίτηση βεβαίωσης συντάκτη κατά την οριστικοποίηση + Αρίθμηση + Πρόθεμα αριθμού + Πλάτος ακολουθίας + Ανάθεση αριθμού + Ακολουθία ανά σταθμό + Επαναφορά ετησίως + Ταξινόμηση + Διατήρηση (έτη) + Επιφάνειες πελάτη + Επιτρέπεται εκτός σύνδεσης + Επιτρέπονται συνημμένα + Χάρτης μετάβασης (JSON) + Σχήμα (JSON) + Οι ενότητες περιέχουν πεδία· κάθε πεδίο έχει κλειδί, ετικέτα, τύπο και προαιρετικούς κανόνες. Επικυρώστε πριν τη δημοσίευση· ο πίνακας πεδίων αντικατοπτρίζει το τελευταίο αποθηκευμένο προσχέδιο. + Πεδία + Επαναλαμβανόμενη + Κανόνες + Κλειδί + Ετικέτα + Τύπος πεδίου + Υποχρεωτικό + Ταξινόμηση + Σημάνσεις + Επικύρωση + Αποθήκευση προσχεδίου + Έλεγχος επίπτωσης & δημοσίευση + Άνοιγμα νέου προσχεδίου + Διαγραφή προσχεδίου + Διαγραφή αυτής της πρόχειρης έκδοσης; Οι δημοσιευμένες εκδόσεις δεν επηρεάζονται. + Αυτή η έκδοση είναι δημοσιευμένη και μόνο για ανάγνωση. Ανοίξτε νέο προσχέδιο για αλλαγές. + Ζητήματα επικύρωσης + Διαφορές από το πρότυπο από το οποίο δημιουργήθηκε αυτός ο ορισμός + Επίπτωση δημοσίευσης + Η δημοσίευση παγώνει αυτή την έκδοση. Τα υπάρχοντα προσχέδια κρατούν την έκδοσή τους μέχρι τη μετάβαση· οι οριστικοποιημένες εγγραφές δεν αλλάζουν ποτέ. + Ανοιχτά προσχέδια στην τρέχουσα έκδοση + Οριστικοποιημένες εγγραφές σε προηγούμενες εκδόσεις + Ασύμβατη αλλαγή + Ετοιμότητα πελατών + Αλλαγές + Σύγκριση από + έως + Σύγκριση + Δεν υπάρχουν διαφορές μεταξύ αυτών των εκδόσεων. + Δημοσίευση αυτής της έκδοσης; Γίνεται μόνο για ανάγνωση και οι νέες εγγραφές θα τη χρησιμοποιούν. + Δημοσίευση + Η δημοσίευση απαιτεί το δικαίωμα διαχείρισης ορισμών εγγραφών. + Απόσυρση + Λόγος απόσυρσης αυτού του ορισμού + Μετάβαση ανοιχτών προσχεδίων + Προεπισκόπηση μετάβασης + Μετάβαση τώρα + Μεταφορά όλων των ανοιχτών προσχεδίων στη νεότερη έκδοση; Οι μη αντιστοιχισμένες τιμές απορρίπτονται. + Ο ορισμός δημιουργήθηκε. Επεξεργαστείτε το σχήμα, επικυρώστε και έπειτα δημοσιεύστε. + Το προσχέδιο αποθηκεύτηκε. + Ο ορισμός είναι έγκυρος. + Ο ορισμός έχει σφάλματα. Διορθώστε τα πριν τη δημοσίευση. + Η πρόχειρη έκδοση διαγράφηκε. + Η έκδοση {0} δημοσιεύθηκε. + Ο ορισμός αποσύρθηκε. Οι υπάρχουσες εγγραφές παραμένουν αναγνώσιμες· δεν μπορούν να δημιουργηθούν νέες. + Αποθηκευμένες αναφορές + Οι αποθηκευμένες αναφορές ερωτούν τις εγγραφές ενός ορισμού ανά πεδίο, τις ομαδοποιούν και συγκεντρώνουν και εξάγουν σε CSV. Τα αποτελέσματα σέβονται την ορατότητα και τα περιορισμένα πεδία. + Νέα αναφορά + Όνομα αναφοράς + Ρυθμίσεις αναφοράς + Στήλες + Στήλες εγγραφής + Επιλέξτε ορισμό για να διαλέξετε στήλες. + Ταξινόμηση κατά + Φθίνουσα + Ομαδοποίηση κατά + Συγκεντρωτικά (JSON) + Count, Sum, Average, Min, Max σε συγκεντρώσιμα πεδία + Φίλτρα (JSON) + Φιλτραρίσιμα πεδία + Αντιστοιχίσεις εκδόσεων (JSON) + Μέγ. γραμμές + Συμπερίληψη προσχεδίων + Συμπερίληψη περιορισμένων πεδίων + Επικύρωση + Εκτέλεση + Τελευταία εκτέλεση + Διαγραφή αυτής της αποθηκευμένης αναφοράς; + Γραμμές + Ομάδες + Πλήθος + Τα αποτελέσματα περικόπηκαν στο όριο γραμμών· περιορίστε τα φίλτρα ή εξάγετε σε CSV. + Εγγραφές σε μη αντιστοιχισμένες εκδόσεις ορισμού συμπεριλήφθηκαν με κενά κελιά + Η αναφορά αποθηκεύτηκε. + Η αναφορά διαγράφηκε. + Η αναφορά είναι έγκυρη. + Η αναφορά έχει σφάλματα. + Αποστολές + Εξωτερικές παραγγελίες πόρων που καλύπτει το τμήμα σας. Το σύστημα παραγγελίας παραμένει έγκυρο: τα τεχνουργήματα αποθηκεύονται ως αμετάβλητα στιγμιότυπα και τίποτα δεν γράφεται πίσω. + Νέα αποστολή + Οι αποστολές είναι λειτουργία προεπισκόπησης: τα πρότυπα βασίστηκαν σε δημοσιευμένη τεκμηρίωση IROC, CIFFC και EMAC, όχι σε ζωντανές ενοποιήσεις. Επαληθεύστε με το σύστημα παραγγελίας σας. + Παραγγελία + Αριθμός παραγγελίας + Συμβάν + Χώρα / υποδιαίρεση + Γραφεία + Γραφείο παραγγελίας + Γραφείο αποστολής + Υπηρεσίες + Αιτούσα υπηρεσία + Παραλαμβάνουσα υπηρεσία + Αποστέλλουσα υπηρεσία + Ρόλος τμήματος + Καλύπτει την παραγγελία + Ζητά πόρους + Φιλοξενεί το συμβάν + Κόστος + Κωδικός κόστους + Αναφορά συμφωνίας + Νόμισμα / μονάδες / ζώνη ώρας + Έγγραφο παραγγελίας + Ανεβάστε το έγγραφο παραγγελίας από το σύστημα προέλευσης (PDF, JSON ή CSV). Αποθηκεύεται ως αμετάβλητο στιγμιότυπο με άθροισμα ελέγχου. + Προέλευση + Σύστημα προέλευσης + Έκδοση προέλευσης + Άνοιγμα στο σύστημα προέλευσης + Κατάσταση + Εμφάνιση κλειστών αποστολών + Απόκρυψη κλειστών αποστολών + Κινητοποιήθηκε + Απελευθερώθηκε + Έκλεισε + Καταγραφή νέου στιγμιοτύπου + Αποθήκευση στιγμιοτύπου + Κλείσιμο αποστολής + Σημειώσεις κλεισίματος + Κλείσιμο αυτής της αποστολής; Οι καλύψεις δεν θα μπορούν πλέον να αλλάξουν. + Κάθε αποδεκτή κάλυψη πρέπει να έχει Επιστρέψει πριν το κλείσιμο. + Καλύψεις + Πρώτη κάλυψη (προαιρετικό) + Προσθέστε το πρώτο αίτημα/κάλυψη τώρα ή από τη σελίδα αποστολής. + Προσθήκη κάλυψης + Αριθμός αιτήματος + Γονικό αίτημα + Πόρος + Θέση + Εκπαιδευόμενος + Ανατεθειμένο άτομο + Μονάδα προέλευσης + Υπηρεσία υποδοχής + Απαιτείται στις + Εγγραφή αποστολής + Δημιουργία αποστολής + Λόγος απόρριψης αυτού του αιτήματος + Η αποστολή δημιουργήθηκε. + Η κάλυψη προστέθηκε. + Η κάλυψη ενημερώθηκε. + Επιλέξτε το έγγραφο παραγγελίας για αποθήκευση ως στιγμιότυπο. + Το στιγμιότυπο αποθηκεύτηκε. Το προηγούμενο διατηρείται και αντικαθίσταται. + Η αποστολή έκλεισε. + Διάταξη εκτύπωσης + Ταξινομήστε, μετονομάστε ή αποκρύψτε ενότητες και πεδία για εκτύπωση και PDF, προσθέστε αλλαγές σελίδας και τοποθετήστε το μπλοκ υπογραφής και τη λίστα συνημμένων. Κάθε αποθήκευση δημιουργεί νέα έκδοση διάταξης που σφραγίζεται στο υποσέλιδο προέλευσης. + Έκδοση διάταξης + Προεπιλογή τμήματος + Σειρά + Εναλλακτική επικεφαλίδα + Ορατό + Αλλαγή σελίδας πριν + Πεδία + Ισχύει για την έκδοση + Όλες οι εκδόσεις + Μπλοκ υπογραφής + Στο τέλος + Μέσα στην ενότητά του + Κρυφό + Λίστα συνημμένων + Πίνακας + Απλή λίστα + Κρυφή + Παράκαμψη της επωνυμίας του τμήματος για αυτόν τον ορισμό + Επιστολόχαρτο + Η διάταξη εκτύπωσης αποθηκεύτηκε. + επιλεγμένα + Επιλογή όλων σε αυτήν τη σελίδα + Ελεγκτής… + Αιτία + Ανάθεση για έλεγχο + Τίτλος πακέτου + Σκοπός (ελέγχεται) + Αποστολή πακέτου με e-mail σε (προαιρετικό) + Συγκεντρωτικό PDF + Πακέτο zip + Έως {0} εγγραφές ανά πακέτο. Χωρίς μαζική ακύρωση ή διαγραφή. + Επιλέξτε τουλάχιστον μία εγγραφή. + Ανατέθηκαν {0} εγγραφές για έλεγχο· {1} παραλείφθηκαν. + Το πακέτο συντάχθηκε από {0} εγγραφές· {1} παραλείφθηκαν. Διαθέσιμο για λήψη για 30 ημέρες. + Στάλθηκε με e-mail. + Προβολή ενότητας + Συνθέτει ένα οριοθετημένο στιγμιότυπο από την ενότητα-κάτοχο: μόνο αναγνωριστικά, κωδικούς, πλήθη και καταστάσεις με αναφορές πηγής. Η ενότητα παραμένει το σύστημα αναφοράς. + Προβολή + Καταγραφή προσωπικού + Σύνοψη πόρων + Προσόντα + Σύνοψη διοίκησης diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index 591816a3..7958c84e 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -1064,4 +1064,255 @@ Acknowledged on Advanced Data Protection is enforced: protected submissions will fail until this acknowledgement is recorded. Record ID + Add row + Remove row + Withheld + Coordinates (lat, lon) or place reference + I sign this record as the author + Yes + No + Edit + Enabled + Preview + This record follows a department definition. Fields marked with a lock are restricted; rules may show or require fields as you type. + Definitions + Record definitions describe the forms your department fills out: sections, fields, rules, numbering and who reviews or approves. Publishing freezes a version; records pin the version they were written on. + New definition + Create definition + Start from template + Clone existing definition + Blank definition + Browse templates + Template packs are reviewed starting points. Each shows its sources and review date; Preview packs are usable but still being validated with departments. + Reviewed + Sources + Sections + Fields + Use this template + Jurisdiction profile + Locale + Template preview + Lower-case letters, digits, dots and dashes. Cannot change after creation. + Name + Category + Description + Owner + Product (locked) + Published version + Draft version + Lifecycle preset + Client capability + Retired + Show retired definitions + Hide retired definitions + History + Version + State + Published on + Change notes + Settings + Permitted subjects + Reviewer roles + Approver roles + Review due (hours) + Approval due (hours) + Require author attestation at finalize + Numbering + Number prefix + Sequence width + Assign number + Sequence per station + Reset yearly + Classification + Retention (years) + Client surfaces + Allow offline + Allow attachments + Migration map (JSON) + Schema (JSON) + Sections hold fields; each field has a key, label, type and optional rules. Validate before publishing; the field table below reflects the last saved draft. + Fields + Repeating + Rules + Key + Label + Field type + Required + Classification + Flags + Validate + Save draft + Review impact & publish + Open new draft + Delete draft + Delete this draft version? Published versions are not affected. + This version is published and read-only. Open a new draft to make changes. + Validation issues + Differences from the template this definition was created from + Publish impact + Publishing freezes this version. Existing drafts keep their version until migrated; finalized records never change. + Open drafts on current version + Finalized records on earlier versions + Breaking change + Client readiness + Changes + Compare from + to + Compare + No differences between these versions. + Publish this version? It becomes read-only and new records will use it. + Publish + Publishing requires the manage record definitions permission. + Retire + Reason for retiring this definition + Migrate open drafts + Preview migration + Migrate now + Move all open drafts to the newer version? Unmapped values are dropped. + Definition created. Edit the schema, validate, then publish. + Draft saved. + The definition is valid. + The definition has errors. Fix them before publishing. + Draft version deleted. + Version {0} published. + Definition retired. Existing records stay readable; no new records can be created. + Saved reports + Saved reports query one definition's records by field, group and aggregate them, and export to CSV. Results respect record visibility and restricted fields. + New report + Report name + Report settings + Columns + Record columns + Pick a definition to choose columns. + Sort by + Descending + Group by + Aggregates (JSON) + Count, Sum, Average, Min, Max on aggregatable fields + Filters (JSON) + Filterable fields + Version mappings (JSON) + Max rows + Include drafts + Include restricted fields + Validate + Run + Last run + Delete this saved report? + Rows + Groups + Count + Results were truncated at the row limit; narrow the filters or export to CSV. + Records on unmapped definition versions were included with blank cells + Report saved. + Report deleted. + The report is valid. + The report has errors. + Deployments + External resource orders your department is filling. The ordering system stays authoritative: artifacts are stored as immutable snapshots and nothing is written back. + New deployment + Deployments are a Preview feature: fixtures were built from published IROC, CIFFC and EMAC documentation, not from live integrations. Verify against your ordering system. + Order + Order number + Incident + Country / subdivision + Offices + Ordering office + Dispatch office + Agencies + Requesting agency + Receiving agency + Sending agency + Department role + Filling the order + Requesting resources + Hosting incident + Cost + Cost code + Agreement reference + Currency / units / time zone + Order artifact + Upload the order document from the source system (PDF, JSON or CSV). It is stored as an immutable, checksummed snapshot. + Source + Source system + Source version + Open in source system + Status + Show closed-out deployments + Hide closed-out deployments + Mobilized + Released + Closed out + Record a new snapshot + Store snapshot + Close out deployment + Closeout notes + Close out this deployment? Fills can no longer change. + Every accepted fill must be Returned before closeout. + Fills + First fill (optional) + Add the first request/fill now or add fills from the deployment page. + Add fill + Request number + Parent request + Resource + Position + Trainee + Assigned person + Home unit + Host agency + Needed on + Deployment record + Create deployment + Reason for declining this request + Deployment created. + Fill added. + Fill updated. + Choose the order document to store as a snapshot. + Snapshot stored. The previous snapshot is kept and superseded. + Deployment closed out. + Print layout + Order, rename or hide sections and fields for print and PDF, add page breaks, and place the signature block and attachment list. Every save is a new layout version stamped in the provenance footer. + Layout version + Department default + Order + Heading override + Visible + Page break before + Fields + Applies to version + All versions + Signature block + At the end + Inline with its section + Hidden + Attachment list + Table + Simple list + Hidden + Override the department branding for this definition + Letterhead + Print layout saved. + selected + Select all on this page + Reviewer… + Reason + Assign for review + Packet title + Purpose (audited) + Email packet to (optional) + Compiled PDF + Zip bundle + Up to {0} records per packet. No bulk void or delete. + Select at least one record. + Assigned {0} record(s) for review; {1} skipped. + Packet compiled from {0} record(s); {1} skipped. It stays downloadable for 30 days. + Emailed. + Module projection + Compose a bounded snapshot from the owning module: identifiers, codes, counts and statuses only, with source references. The module stays the system of record. + Projection + Personnel check-in + Resource summary + Qualifications + Command summary diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index 49aee750..c8f61428 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -1064,4 +1064,255 @@ Reconocido el La Protección Avanzada de Datos está aplicada: los envíos protegidos fallarán hasta que se registre este reconocimiento. ID de registro + Añadir fila + Quitar fila + Retenido + Coordenadas (lat, lon) o referencia de lugar + Firmo este registro como autor + + No + Editar + Habilitado + Vista previa + Este registro sigue una definición del departamento. Los campos con candado están restringidos; las reglas pueden mostrar o exigir campos mientras escribe. + Definiciones + Las definiciones de registro describen los formularios de su departamento: secciones, campos, reglas, numeración y quién revisa o aprueba. Publicar congela una versión; los registros fijan la versión con la que se escribieron. + Nueva definición + Crear definición + Empezar desde plantilla + Clonar definición existente + Definición en blanco + Explorar plantillas + Los paquetes de plantillas son puntos de partida revisados. Cada uno muestra sus fuentes y fecha de revisión; los paquetes en vista previa son utilizables pero aún se validan con departamentos. + Revisado el + Fuentes + Secciones + Campos + Usar esta plantilla + Perfil de jurisdicción + Configuración regional + Vista previa de la plantilla + Letras minúsculas, dígitos, puntos y guiones. No se puede cambiar tras la creación. + Nombre + Categoría + Descripción + Propietario + Producto (bloqueado) + Versión publicada + Versión borrador + Preajuste de ciclo de vida + Capacidad del cliente + Retirada + Mostrar definiciones retiradas + Ocultar definiciones retiradas + Historial + Versión + Estado + Publicado el + Notas de cambio + Configuración + Sujetos permitidos + Roles de revisor + Roles de aprobador + Plazo de revisión (horas) + Plazo de aprobación (horas) + Exigir atestación del autor al finalizar + Numeración + Prefijo de número + Ancho de secuencia + Asignar número + Secuencia por estación + Reiniciar anualmente + Clasificación + Retención (años) + Superficies de cliente + Permitir sin conexión + Permitir adjuntos + Mapa de migración (JSON) + Esquema (JSON) + Las secciones contienen campos; cada campo tiene clave, etiqueta, tipo y reglas opcionales. Valide antes de publicar; la tabla de campos refleja el último borrador guardado. + Campos + Repetible + Reglas + Clave + Etiqueta + Tipo de campo + Obligatorio + Clasificación + Indicadores + Validar + Guardar borrador + Revisar impacto y publicar + Abrir nuevo borrador + Eliminar borrador + ¿Eliminar esta versión borrador? Las versiones publicadas no se ven afectadas. + Esta versión está publicada y es de solo lectura. Abra un nuevo borrador para hacer cambios. + Problemas de validación + Diferencias respecto a la plantilla de origen de esta definición + Impacto de la publicación + Publicar congela esta versión. Los borradores existentes conservan su versión hasta migrarse; los registros finalizados nunca cambian. + Borradores abiertos en la versión actual + Registros finalizados en versiones anteriores + Cambio incompatible + Preparación de clientes + Cambios + Comparar desde + hasta + Comparar + No hay diferencias entre estas versiones. + ¿Publicar esta versión? Pasará a solo lectura y los nuevos registros la usarán. + Publicar + Publicar requiere el permiso de gestionar definiciones de registro. + Retirar + Motivo para retirar esta definición + Migrar borradores abiertos + Previsualizar migración + Migrar ahora + ¿Mover todos los borradores abiertos a la versión más reciente? Los valores sin mapear se descartan. + Definición creada. Edite el esquema, valide y luego publique. + Borrador guardado. + La definición es válida. + La definición tiene errores. Corríjalos antes de publicar. + Versión borrador eliminada. + Versión {0} publicada. + Definición retirada. Los registros existentes siguen legibles; no se pueden crear nuevos. + Informes guardados + Los informes guardados consultan los registros de una definición por campo, los agrupan y agregan, y exportan a CSV. Los resultados respetan la visibilidad y los campos restringidos. + Nuevo informe + Nombre del informe + Configuración del informe + Columnas + Columnas del registro + Elija una definición para seleccionar columnas. + Ordenar por + Descendente + Agrupar por + Agregados (JSON) + Count, Sum, Average, Min, Max sobre campos agregables + Filtros (JSON) + Campos filtrables + Mapeos de versión (JSON) + Filas máx. + Incluir borradores + Incluir campos restringidos + Validar + Ejecutar + Última ejecución + ¿Eliminar este informe guardado? + Filas + Grupos + Cantidad + Los resultados se truncaron en el límite de filas; acote los filtros o exporte a CSV. + Se incluyeron registros de versiones de definición sin mapear con celdas vacías + Informe guardado. + Informe eliminado. + El informe es válido. + El informe tiene errores. + Despliegues + Órdenes de recursos externas que su departamento está cubriendo. El sistema emisor sigue siendo la autoridad: los artefactos se guardan como instantáneas inmutables y nada se escribe de vuelta. + Nuevo despliegue + Los despliegues son una función en vista previa: las plantillas se crearon a partir de documentación publicada de IROC, CIFFC y EMAC, no de integraciones en vivo. Verifique contra su sistema emisor. + Orden + Número de orden + Incidente + País / subdivisión + Oficinas + Oficina emisora + Oficina de despacho + Agencias + Agencia solicitante + Agencia receptora + Agencia emisora + Rol del departamento + Cubre la orden + Solicita recursos + Anfitrión del incidente + Costo + Código de costo + Referencia del acuerdo + Moneda / unidades / zona horaria + Documento de la orden + Suba el documento de la orden desde el sistema de origen (PDF, JSON o CSV). Se guarda como instantánea inmutable con suma de verificación. + Origen + Sistema de origen + Versión de origen + Abrir en el sistema de origen + Estado + Mostrar despliegues cerrados + Ocultar despliegues cerrados + Movilizado + Liberado + Cerrado + Registrar nueva instantánea + Guardar instantánea + Cerrar despliegue + Notas de cierre + ¿Cerrar este despliegue? Las asignaciones ya no podrán cambiar. + Cada asignación aceptada debe estar Devuelta antes del cierre. + Asignaciones + Primera asignación (opcional) + Añada la primera solicitud/asignación ahora o desde la página del despliegue. + Añadir asignación + Número de solicitud + Solicitud principal + Recurso + Posición + Aprendiz + Persona asignada + Unidad de origen + Agencia anfitriona + Necesario el + Registro del despliegue + Crear despliegue + Motivo para rechazar esta solicitud + Despliegue creado. + Asignación añadida. + Asignación actualizada. + Elija el documento de la orden para guardarlo como instantánea. + Instantánea guardada. La anterior se conserva y queda reemplazada. + Despliegue cerrado. + Diseño de impresión + Ordene, renombre u oculte secciones y campos para impresión y PDF, añada saltos de página y ubique el bloque de firma y la lista de adjuntos. Cada guardado crea una nueva versión de diseño registrada en el pie de procedencia. + Versión del diseño + Predeterminado del departamento + Orden + Encabezado alternativo + Visible + Salto de página antes + Campos + Se aplica a la versión + Todas las versiones + Bloque de firma + Al final + En su sección + Oculto + Lista de adjuntos + Tabla + Lista simple + Oculta + Anular la identidad del departamento para esta definición + Membrete + Diseño de impresión guardado. + seleccionados + Seleccionar todos en esta página + Revisor… + Motivo + Asignar para revisión + Título del paquete + Propósito (auditado) + Enviar paquete por correo a (opcional) + PDF compilado + Paquete zip + Hasta {0} registros por paquete. Sin anulación ni eliminación masiva. + Seleccione al menos un registro. + {0} registro(s) asignado(s) para revisión; {1} omitido(s). + Paquete compilado con {0} registro(s); {1} omitido(s). Descargable durante 30 días. + Enviado por correo. + Proyección de módulo + Compone una instantánea acotada del módulo propietario: solo identificadores, códigos, recuentos y estados, con referencias de origen. El módulo sigue siendo el sistema de registro. + Proyección + Registro de personal + Resumen de recursos + Cualificaciones + Resumen de mando diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index 9a21b2da..cbcc50cc 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -1064,4 +1064,255 @@ Confirmé le La protection avancée des données est appliquée : les envois protégés échoueront tant que cette confirmation n'est pas enregistrée. Identifiant d'enregistrement + Ajouter une ligne + Supprimer la ligne + Retenu + Coordonnées (lat, lon) ou référence de lieu + Je signe cet enregistrement en tant qu'auteur + Oui + Non + Modifier + Activé + Aperçu + Cet enregistrement suit une définition du service. Les champs marqués d'un cadenas sont restreints ; des règles peuvent afficher ou exiger des champs pendant la saisie. + Définitions + Les définitions d'enregistrement décrivent les formulaires de votre service : sections, champs, règles, numérotation et qui révise ou approuve. La publication fige une version ; les enregistrements conservent la version avec laquelle ils ont été rédigés. + Nouvelle définition + Créer une définition + Partir d'un modèle + Cloner une définition existante + Définition vierge + Parcourir les modèles + Les packs de modèles sont des points de départ révisés. Chacun indique ses sources et sa date de révision ; les packs en aperçu sont utilisables mais encore en validation avec des services. + Révisé le + Sources + Sections + Champs + Utiliser ce modèle + Profil de juridiction + Paramètres régionaux + Aperçu du modèle + Lettres minuscules, chiffres, points et tirets. Non modifiable après la création. + Nom + Catégorie + Description + Propriétaire + Produit (verrouillé) + Version publiée + Version brouillon + Préréglage de cycle de vie + Capacité du client + Retirée + Afficher les définitions retirées + Masquer les définitions retirées + Historique + Version + État + Publié le + Notes de modification + Paramètres + Sujets autorisés + Rôles de réviseur + Rôles d'approbateur + Délai de révision (heures) + Délai d'approbation (heures) + Exiger l'attestation de l'auteur à la finalisation + Numérotation + Préfixe de numéro + Largeur de séquence + Attribuer le numéro + Séquence par caserne + Réinitialiser chaque année + Classification + Conservation (années) + Surfaces client + Autoriser hors ligne + Autoriser les pièces jointes + Table de migration (JSON) + Schéma (JSON) + Les sections contiennent des champs ; chaque champ a une clé, un libellé, un type et des règles facultatives. Validez avant de publier ; le tableau des champs reflète le dernier brouillon enregistré. + Champs + Répétable + Règles + Clé + Libellé + Type de champ + Obligatoire + Classification + Indicateurs + Valider + Enregistrer le brouillon + Vérifier l'impact et publier + Ouvrir un nouveau brouillon + Supprimer le brouillon + Supprimer cette version brouillon ? Les versions publiées ne sont pas affectées. + Cette version est publiée et en lecture seule. Ouvrez un nouveau brouillon pour la modifier. + Problèmes de validation + Différences par rapport au modèle d'origine de cette définition + Impact de la publication + La publication fige cette version. Les brouillons existants conservent leur version jusqu'à migration ; les enregistrements finalisés ne changent jamais. + Brouillons ouverts sur la version actuelle + Enregistrements finalisés sur des versions antérieures + Changement incompatible + Préparation des clients + Modifications + Comparer depuis + à + Comparer + Aucune différence entre ces versions. + Publier cette version ? Elle devient en lecture seule et les nouveaux enregistrements l'utiliseront. + Publier + La publication nécessite l'autorisation de gérer les définitions d'enregistrement. + Retirer + Motif du retrait de cette définition + Migrer les brouillons ouverts + Aperçu de la migration + Migrer maintenant + Déplacer tous les brouillons ouverts vers la version plus récente ? Les valeurs non mappées sont perdues. + Définition créée. Modifiez le schéma, validez, puis publiez. + Brouillon enregistré. + La définition est valide. + La définition contient des erreurs. Corrigez-les avant de publier. + Version brouillon supprimée. + Version {0} publiée. + Définition retirée. Les enregistrements existants restent lisibles ; aucun nouveau ne peut être créé. + Rapports enregistrés + Les rapports enregistrés interrogent les enregistrements d'une définition par champ, les regroupent et les agrègent, puis exportent en CSV. Les résultats respectent la visibilité et les champs restreints. + Nouveau rapport + Nom du rapport + Paramètres du rapport + Colonnes + Colonnes de l'enregistrement + Choisissez une définition pour sélectionner les colonnes. + Trier par + Décroissant + Grouper par + Agrégats (JSON) + Count, Sum, Average, Min, Max sur les champs agrégeables + Filtres (JSON) + Champs filtrables + Correspondances de version (JSON) + Lignes max. + Inclure les brouillons + Inclure les champs restreints + Valider + Exécuter + Dernière exécution + Supprimer ce rapport enregistré ? + Lignes + Groupes + Nombre + Les résultats ont été tronqués à la limite de lignes ; affinez les filtres ou exportez en CSV. + Les enregistrements sur des versions de définition non mappées ont été inclus avec des cellules vides + Rapport enregistré. + Rapport supprimé. + Le rapport est valide. + Le rapport contient des erreurs. + Déploiements + Commandes de ressources externes que votre service pourvoit. Le système émetteur reste la référence : les artefacts sont stockés en instantanés immuables et rien n'est réécrit. + Nouveau déploiement + Les déploiements sont une fonctionnalité en aperçu : les gabarits proviennent de la documentation publiée IROC, CIFFC et EMAC, pas d'intégrations en direct. Vérifiez avec votre système de commande. + Commande + Numéro de commande + Incident + Pays / subdivision + Bureaux + Bureau émetteur + Bureau de répartition + Organismes + Organisme demandeur + Organisme destinataire + Organisme expéditeur + Rôle du service + Pourvoit la commande + Demande des ressources + Hôte de l'incident + Coût + Code de coût + Référence de l'accord + Devise / unités / fuseau horaire + Document de commande + Téléversez le document de commande du système source (PDF, JSON ou CSV). Il est stocké en instantané immuable avec somme de contrôle. + Source + Système source + Version source + Ouvrir dans le système source + Statut + Afficher les déploiements clôturés + Masquer les déploiements clôturés + Mobilisé + Libéré + Clôturé + Enregistrer un nouvel instantané + Stocker l'instantané + Clôturer le déploiement + Notes de clôture + Clôturer ce déploiement ? Les affectations ne pourront plus changer. + Chaque affectation acceptée doit être Retournée avant la clôture. + Affectations + Première affectation (facultatif) + Ajoutez la première demande/affectation maintenant ou depuis la page du déploiement. + Ajouter une affectation + Numéro de demande + Demande parente + Ressource + Poste + Stagiaire + Personne affectée + Unité d'origine + Organisme hôte + Requis le + Enregistrement du déploiement + Créer le déploiement + Motif du refus de cette demande + Déploiement créé. + Affectation ajoutée. + Affectation mise à jour. + Choisissez le document de commande à stocker en instantané. + Instantané stocké. Le précédent est conservé et remplacé. + Déploiement clôturé. + Mise en page d'impression + Ordonnez, renommez ou masquez des sections et des champs pour l'impression et le PDF, ajoutez des sauts de page et placez le bloc de signature et la liste des pièces jointes. Chaque enregistrement crée une nouvelle version de mise en page inscrite dans le pied de provenance. + Version de mise en page + Valeur par défaut du service + Ordre + Titre de remplacement + Visible + Saut de page avant + Champs + S'applique à la version + Toutes les versions + Bloc de signature + À la fin + Dans sa section + Masqué + Liste des pièces jointes + Tableau + Liste simple + Masquée + Remplacer l'identité visuelle du service pour cette définition + En-tête + Mise en page d'impression enregistrée. + sélectionnés + Tout sélectionner sur cette page + Réviseur… + Motif + Attribuer pour révision + Titre du dossier + Objet (audité) + Envoyer le dossier par e-mail à (facultatif) + PDF compilé + Archive zip + Jusqu'à {0} enregistrements par dossier. Pas d'annulation ni de suppression en masse. + Sélectionnez au moins un enregistrement. + {0} enregistrement(s) attribué(s) pour révision ; {1} ignoré(s). + Dossier compilé à partir de {0} enregistrement(s) ; {1} ignoré(s). Téléchargeable pendant 30 jours. + Envoyé par e-mail. + Projection de module + Compose un instantané borné à partir du module propriétaire : identifiants, codes, comptes et statuts uniquement, avec références sources. Le module reste le système de référence. + Projection + Pointage du personnel + Synthèse des ressources + Qualifications + Synthèse du commandement diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index b91c5517..0f30540d 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -1064,4 +1064,255 @@ Confermato il La Protezione avanzata dei dati è applicata: gli invii protetti falliranno finché non viene registrata questa conferma. ID record + Aggiungi riga + Rimuovi riga + Trattenuto + Coordinate (lat, lon) o riferimento del luogo + Firmo questo record come autore + + No + Modifica + Abilitato + Anteprima + Questo record segue una definizione del dipartimento. I campi con il lucchetto sono riservati; le regole possono mostrare o richiedere campi durante la digitazione. + Definizioni + Le definizioni dei record descrivono i moduli del dipartimento: sezioni, campi, regole, numerazione e chi revisiona o approva. La pubblicazione congela una versione; i record restano legati alla versione con cui sono stati scritti. + Nuova definizione + Crea definizione + Parti da un modello + Clona definizione esistente + Definizione vuota + Sfoglia modelli + I pacchetti di modelli sono punti di partenza revisionati. Ognuno mostra fonti e data di revisione; i pacchetti in anteprima sono utilizzabili ma ancora in validazione con i dipartimenti. + Revisionato il + Fonti + Sezioni + Campi + Usa questo modello + Profilo di giurisdizione + Impostazioni locali + Anteprima del modello + Lettere minuscole, cifre, punti e trattini. Non modificabile dopo la creazione. + Nome + Categoria + Descrizione + Proprietario + Prodotto (bloccato) + Versione pubblicata + Versione bozza + Preimpostazione del ciclo di vita + Capacità del client + Ritirata + Mostra definizioni ritirate + Nascondi definizioni ritirate + Cronologia + Versione + Stato + Pubblicato il + Note di modifica + Impostazioni + Soggetti consentiti + Ruoli revisore + Ruoli approvatore + Scadenza revisione (ore) + Scadenza approvazione (ore) + Richiedi attestazione dell'autore alla finalizzazione + Numerazione + Prefisso numero + Larghezza sequenza + Assegna numero + Sequenza per stazione + Azzera ogni anno + Classificazione + Conservazione (anni) + Superfici client + Consenti offline + Consenti allegati + Mappa di migrazione (JSON) + Schema (JSON) + Le sezioni contengono campi; ogni campo ha chiave, etichetta, tipo e regole facoltative. Convalida prima di pubblicare; la tabella dei campi riflette l'ultima bozza salvata. + Campi + Ripetibile + Regole + Chiave + Etichetta + Tipo di campo + Obbligatorio + Classificazione + Contrassegni + Convalida + Salva bozza + Verifica impatto e pubblica + Apri nuova bozza + Elimina bozza + Eliminare questa versione bozza? Le versioni pubblicate non vengono toccate. + Questa versione è pubblicata ed è di sola lettura. Apri una nuova bozza per modificarla. + Problemi di convalida + Differenze rispetto al modello da cui è stata creata questa definizione + Impatto della pubblicazione + La pubblicazione congela questa versione. Le bozze esistenti mantengono la loro versione finché non vengono migrate; i record finalizzati non cambiano mai. + Bozze aperte sulla versione corrente + Record finalizzati su versioni precedenti + Modifica non compatibile + Prontezza dei client + Modifiche + Confronta da + a + Confronta + Nessuna differenza tra queste versioni. + Pubblicare questa versione? Diventa di sola lettura e i nuovi record la useranno. + Pubblica + La pubblicazione richiede il permesso di gestire le definizioni dei record. + Ritira + Motivo del ritiro di questa definizione + Migra bozze aperte + Anteprima migrazione + Migra ora + Spostare tutte le bozze aperte alla versione più recente? I valori non mappati vengono scartati. + Definizione creata. Modifica lo schema, convalida e poi pubblica. + Bozza salvata. + La definizione è valida. + La definizione contiene errori. Correggili prima di pubblicare. + Versione bozza eliminata. + Versione {0} pubblicata. + Definizione ritirata. I record esistenti restano leggibili; non se ne possono creare di nuovi. + Report salvati + I report salvati interrogano i record di una definizione per campo, li raggruppano e aggregano ed esportano in CSV. I risultati rispettano la visibilità e i campi riservati. + Nuovo report + Nome del report + Impostazioni del report + Colonne + Colonne del record + Scegli una definizione per selezionare le colonne. + Ordina per + Decrescente + Raggruppa per + Aggregati (JSON) + Count, Sum, Average, Min, Max sui campi aggregabili + Filtri (JSON) + Campi filtrabili + Mappature di versione (JSON) + Righe max + Includi bozze + Includi campi riservati + Convalida + Esegui + Ultima esecuzione + Eliminare questo report salvato? + Righe + Gruppi + Conteggio + I risultati sono stati troncati al limite di righe; restringi i filtri o esporta in CSV. + I record su versioni di definizione non mappate sono stati inclusi con celle vuote + Report salvato. + Report eliminato. + Il report è valido. + Il report contiene errori. + Dispiegamenti + Ordini di risorse esterni che il dipartimento sta evadendo. Il sistema ordinante resta autoritativo: gli artefatti sono salvati come snapshot immutabili e nulla viene riscritto. + Nuovo dispiegamento + I dispiegamenti sono una funzione in anteprima: i modelli derivano dalla documentazione pubblicata IROC, CIFFC ed EMAC, non da integrazioni live. Verifica con il tuo sistema ordinante. + Ordine + Numero ordine + Incidente + Paese / suddivisione + Uffici + Ufficio ordinante + Ufficio di dispaccio + Agenzie + Agenzia richiedente + Agenzia ricevente + Agenzia inviante + Ruolo del dipartimento + Evade l'ordine + Richiede risorse + Ospita l'incidente + Costo + Codice costo + Riferimento accordo + Valuta / unità / fuso orario + Documento dell'ordine + Carica il documento dell'ordine dal sistema di origine (PDF, JSON o CSV). Viene salvato come snapshot immutabile con checksum. + Origine + Sistema di origine + Versione di origine + Apri nel sistema di origine + Stato + Mostra dispiegamenti chiusi + Nascondi dispiegamenti chiusi + Mobilitato + Rilasciato + Chiuso + Registra un nuovo snapshot + Salva snapshot + Chiudi dispiegamento + Note di chiusura + Chiudere questo dispiegamento? Le assegnazioni non potranno più cambiare. + Ogni assegnazione accettata deve essere Rientrata prima della chiusura. + Assegnazioni + Prima assegnazione (facoltativa) + Aggiungi ora la prima richiesta/assegnazione oppure dalla pagina del dispiegamento. + Aggiungi assegnazione + Numero richiesta + Richiesta principale + Risorsa + Posizione + Tirocinante + Persona assegnata + Unità di appartenenza + Agenzia ospitante + Necessario il + Record del dispiegamento + Crea dispiegamento + Motivo del rifiuto di questa richiesta + Dispiegamento creato. + Assegnazione aggiunta. + Assegnazione aggiornata. + Scegli il documento dell'ordine da salvare come snapshot. + Snapshot salvato. Il precedente resta conservato e viene sostituito. + Dispiegamento chiuso. + Layout di stampa + Ordina, rinomina o nascondi sezioni e campi per stampa e PDF, aggiungi interruzioni di pagina e posiziona il blocco firma e l'elenco allegati. Ogni salvataggio crea una nuova versione del layout riportata nel piè di pagina di provenienza. + Versione del layout + Predefinito del dipartimento + Ordine + Intestazione alternativa + Visibile + Interruzione di pagina prima + Campi + Si applica alla versione + Tutte le versioni + Blocco firma + Alla fine + Nella sua sezione + Nascosto + Elenco allegati + Tabella + Elenco semplice + Nascosto + Sostituisci il branding del dipartimento per questa definizione + Intestazione + Layout di stampa salvato. + selezionati + Seleziona tutto in questa pagina + Revisore… + Motivo + Assegna per revisione + Titolo del pacchetto + Scopo (verificato) + Invia il pacchetto via e-mail a (facoltativo) + PDF compilato + Pacchetto zip + Fino a {0} record per pacchetto. Nessun annullamento o eliminazione in blocco. + Seleziona almeno un record. + {0} record assegnati per la revisione; {1} ignorati. + Pacchetto compilato da {0} record; {1} ignorati. Scaricabile per 30 giorni. + Inviato via e-mail. + Proiezione del modulo + Compone uno snapshot delimitato dal modulo proprietario: solo identificativi, codici, conteggi e stati, con riferimenti alle fonti. Il modulo resta il sistema di riferimento. + Proiezione + Check-in del personale + Riepilogo risorse + Qualifiche + Riepilogo del comando diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index e67a5aa2..a20f0bba 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -1064,4 +1064,255 @@ Potwierdzono dnia Zaawansowana ochrona danych jest wymuszona: chronione wysyłki będą kończyć się błędem do czasu zapisania tego potwierdzenia. Identyfikator rekordu + Dodaj wiersz + Usuń wiersz + Wstrzymane + Współrzędne (szer., dł.) lub odniesienie do miejsca + Podpisuję ten rekord jako autor + Tak + Nie + Edytuj + Włączone + Podgląd + Ten rekord jest oparty na definicji jednostki. Pola oznaczone kłódką są zastrzeżone; reguły mogą pokazywać lub wymagać pól podczas wpisywania. + Definicje + Definicje rekordów opisują formularze jednostki: sekcje, pola, reguły, numerację oraz kto weryfikuje lub zatwierdza. Publikacja zamraża wersję; rekordy pozostają przypięte do wersji, w której powstały. + Nowa definicja + Utwórz definicję + Zacznij od szablonu + Sklonuj istniejącą definicję + Pusta definicja + Przeglądaj szablony + Pakiety szablonów to zweryfikowane punkty wyjścia. Każdy pokazuje źródła i datę przeglądu; pakiety w podglądzie są użyteczne, ale wciąż walidowane z jednostkami. + Zweryfikowano + Źródła + Sekcje + Pola + Użyj tego szablonu + Profil jurysdykcji + Ustawienia regionalne + Podgląd szablonu + Małe litery, cyfry, kropki i myślniki. Nie można zmienić po utworzeniu. + Nazwa + Kategoria + Opis + Właściciel + Produkt (zablokowany) + Opublikowana wersja + Wersja robocza + Ustawienie cyklu życia + Zdolność klienta + Wycofana + Pokaż wycofane definicje + Ukryj wycofane definicje + Historia + Wersja + Stan + Opublikowano + Uwagi o zmianach + Ustawienia + Dozwolone podmioty + Role weryfikatora + Role zatwierdzającego + Termin weryfikacji (godz.) + Termin zatwierdzenia (godz.) + Wymagaj poświadczenia autora przy finalizacji + Numeracja + Prefiks numeru + Szerokość sekwencji + Przydziel numer + Sekwencja na stację + Resetuj co rok + Klasyfikacja + Przechowywanie (lata) + Powierzchnie klienta + Zezwól offline + Zezwól na załączniki + Mapa migracji (JSON) + Schemat (JSON) + Sekcje zawierają pola; każde pole ma klucz, etykietę, typ i opcjonalne reguły. Zweryfikuj przed publikacją; tabela pól odzwierciedla ostatnio zapisany szkic. + Pola + Powtarzalna + Reguły + Klucz + Etykieta + Typ pola + Wymagane + Klasyfikacja + Flagi + Zweryfikuj + Zapisz szkic + Sprawdź wpływ i opublikuj + Otwórz nowy szkic + Usuń szkic + Usunąć tę wersję roboczą? Opublikowane wersje pozostają bez zmian. + Ta wersja jest opublikowana i tylko do odczytu. Otwórz nowy szkic, aby wprowadzić zmiany. + Problemy walidacji + Różnice względem szablonu, z którego utworzono tę definicję + Wpływ publikacji + Publikacja zamraża tę wersję. Istniejące szkice zachowują swoją wersję do czasu migracji; sfinalizowane rekordy nigdy się nie zmieniają. + Otwarte szkice w bieżącej wersji + Sfinalizowane rekordy we wcześniejszych wersjach + Zmiana niezgodna + Gotowość klientów + Zmiany + Porównaj od + do + Porównaj + Brak różnic między tymi wersjami. + Opublikować tę wersję? Stanie się tylko do odczytu i będą jej używać nowe rekordy. + Opublikuj + Publikacja wymaga uprawnienia do zarządzania definicjami rekordów. + Wycofaj + Powód wycofania tej definicji + Migruj otwarte szkice + Podgląd migracji + Migruj teraz + Przenieść wszystkie otwarte szkice do nowszej wersji? Niezmapowane wartości zostaną odrzucone. + Utworzono definicję. Edytuj schemat, zweryfikuj, a następnie opublikuj. + Szkic zapisany. + Definicja jest poprawna. + Definicja zawiera błędy. Popraw je przed publikacją. + Wersja robocza usunięta. + Opublikowano wersję {0}. + Definicja wycofana. Istniejące rekordy pozostają czytelne; nie można tworzyć nowych. + Zapisane raporty + Zapisane raporty odpytują rekordy jednej definicji według pól, grupują je i agregują oraz eksportują do CSV. Wyniki respektują widoczność i pola zastrzeżone. + Nowy raport + Nazwa raportu + Ustawienia raportu + Kolumny + Kolumny rekordu + Wybierz definicję, aby wybrać kolumny. + Sortuj według + Malejąco + Grupuj według + Agregaty (JSON) + Count, Sum, Average, Min, Max na polach agregowalnych + Filtry (JSON) + Pola filtrowalne + Mapowania wersji (JSON) + Maks. wierszy + Uwzględnij szkice + Uwzględnij pola zastrzeżone + Zweryfikuj + Uruchom + Ostatnie uruchomienie + Usunąć ten zapisany raport? + Wiersze + Grupy + Liczba + Wyniki zostały obcięte do limitu wierszy; zawęź filtry lub wyeksportuj do CSV. + Rekordy w niezmapowanych wersjach definicji uwzględniono z pustymi komórkami + Raport zapisany. + Raport usunięty. + Raport jest poprawny. + Raport zawiera błędy. + Oddelegowania + Zewnętrzne zamówienia zasobów realizowane przez jednostkę. System zamawiający pozostaje wiążący: artefakty są przechowywane jako niezmienne migawki i nic nie jest zapisywane zwrotnie. + Nowe oddelegowanie + Oddelegowania to funkcja w podglądzie: wzorce zbudowano z opublikowanej dokumentacji IROC, CIFFC i EMAC, nie z integracji na żywo. Zweryfikuj z systemem zamawiającym. + Zamówienie + Numer zamówienia + Zdarzenie + Kraj / region + Biura + Biuro zamawiające + Biuro dyspozytorskie + Agencje + Agencja wnioskująca + Agencja przyjmująca + Agencja wysyłająca + Rola jednostki + Realizuje zamówienie + Wnioskuje o zasoby + Gospodarz zdarzenia + Koszt + Kod kosztu + Odniesienie do umowy + Waluta / jednostki / strefa czasowa + Dokument zamówienia + Prześlij dokument zamówienia z systemu źródłowego (PDF, JSON lub CSV). Jest zapisywany jako niezmienna migawka z sumą kontrolną. + Źródło + System źródłowy + Wersja źródłowa + Otwórz w systemie źródłowym + Status + Pokaż zamknięte oddelegowania + Ukryj zamknięte oddelegowania + Zmobilizowano + Zwolniono + Zamknięto + Zapisz nową migawkę + Zapisz migawkę + Zamknij oddelegowanie + Uwagi zamknięcia + Zamknąć to oddelegowanie? Obsady nie będą mogły się już zmieniać. + Każda zaakceptowana obsada musi mieć status Powrót przed zamknięciem. + Obsady + Pierwsza obsada (opcjonalnie) + Dodaj pierwszą prośbę/obsadę teraz lub ze strony oddelegowania. + Dodaj obsadę + Numer prośby + Prośba nadrzędna + Zasób + Stanowisko + Stażysta + Przypisana osoba + Jednostka macierzysta + Agencja goszcząca + Potrzebne na + Rekord oddelegowania + Utwórz oddelegowanie + Powód odrzucenia tej prośby + Utworzono oddelegowanie. + Dodano obsadę. + Zaktualizowano obsadę. + Wybierz dokument zamówienia do zapisania jako migawka. + Zapisano migawkę. Poprzednia jest zachowana i zastąpiona. + Oddelegowanie zamknięte. + Układ wydruku + Porządkuj, zmieniaj nazwy lub ukrywaj sekcje i pola na wydruku i w PDF, dodawaj podziały stron oraz umieszczaj blok podpisu i listę załączników. Każdy zapis tworzy nową wersję układu zapisywaną w stopce pochodzenia. + Wersja układu + Domyślny jednostki + Kolejność + Nagłówek zastępczy + Widoczne + Podział strony przed + Pola + Dotyczy wersji + Wszystkie wersje + Blok podpisu + Na końcu + W swojej sekcji + Ukryty + Lista załączników + Tabela + Prosta lista + Ukryta + Zastąp branding jednostki dla tej definicji + Nagłówek firmowy + Zapisano układ wydruku. + zaznaczono + Zaznacz wszystko na tej stronie + Weryfikator… + Powód + Przypisz do weryfikacji + Tytuł pakietu + Cel (audytowany) + Wyślij pakiet e-mailem do (opcjonalnie) + Scalony PDF + Paczka zip + Do {0} rekordów na pakiet. Brak masowego unieważniania lub usuwania. + Zaznacz co najmniej jeden rekord. + Przypisano {0} rekord(ów) do weryfikacji; pominięto {1}. + Pakiet utworzono z {0} rekord(ów); pominięto {1}. Dostępny do pobrania przez 30 dni. + Wysłano e-mailem. + Projekcja modułu + Tworzy ograniczoną migawkę z modułu źródłowego: tylko identyfikatory, kody, liczby i statusy z odniesieniami do źródła. Moduł pozostaje systemem źródłowym. + Projekcja + Odprawa personelu + Podsumowanie zasobów + Kwalifikacje + Podsumowanie dowodzenia diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index c56a12c6..50a1ab19 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -1064,4 +1064,255 @@ Godkänt den Avancerat dataskydd tillämpas: skyddade sändningar misslyckas tills detta godkännande registreras. Post-ID + Lägg till rad + Ta bort rad + Undanhållet + Koordinater (lat, lon) eller platsreferens + Jag signerar denna post som författare + Ja + Nej + Redigera + Aktiverad + Förhandsvisning + Denna post följer en avdelningsdefinition. Fält med hänglås är begränsade; regler kan visa eller kräva fält medan du skriver. + Definitioner + Postdefinitioner beskriver avdelningens formulär: sektioner, fält, regler, numrering och vem som granskar eller godkänner. Publicering fryser en version; poster låses till den version de skrevs med. + Ny definition + Skapa definition + Utgå från mall + Klona befintlig definition + Tom definition + Bläddra bland mallar + Mallpaket är granskade utgångspunkter. Varje paket visar sina källor och granskningsdatum; förhandsvisningspaket går att använda men valideras fortfarande med avdelningar. + Granskad + Källor + Sektioner + Fält + Använd denna mall + Jurisdiktionsprofil + Språkinställning + Förhandsvisning av mall + Små bokstäver, siffror, punkter och bindestreck. Kan inte ändras efter skapandet. + Namn + Kategori + Beskrivning + Ägare + Produkt (låst) + Publicerad version + Utkastversion + Livscykelmall + Klientkapacitet + Avvecklad + Visa avvecklade definitioner + Dölj avvecklade definitioner + Historik + Version + Tillstånd + Publicerad + Ändringsanteckningar + Inställningar + Tillåtna ämnen + Granskarroller + Godkännarroller + Granskning senast (timmar) + Godkännande senast (timmar) + Kräv författarens intyg vid slutförande + Numrering + Nummerprefix + Sekvensbredd + Tilldela nummer + Sekvens per station + Återställ årligen + Klassificering + Bevarande (år) + Klientytor + Tillåt offline + Tillåt bilagor + Migreringskarta (JSON) + Schema (JSON) + Sektioner innehåller fält; varje fält har nyckel, etikett, typ och valfria regler. Validera innan publicering; fälttabellen nedan speglar det senast sparade utkastet. + Fält + Upprepande + Regler + Nyckel + Etikett + Fälttyp + Obligatoriskt + Klassificering + Flaggor + Validera + Spara utkast + Granska påverkan & publicera + Öppna nytt utkast + Ta bort utkast + Ta bort denna utkastversion? Publicerade versioner påverkas inte. + Denna version är publicerad och skrivskyddad. Öppna ett nytt utkast för att göra ändringar. + Valideringsproblem + Skillnader mot mallen som denna definition skapades från + Publiceringspåverkan + Publicering fryser denna version. Befintliga utkast behåller sin version tills de migreras; slutförda poster ändras aldrig. + Öppna utkast på nuvarande version + Slutförda poster på tidigare versioner + Brytande ändring + Klientberedskap + Ändringar + Jämför från + till + Jämför + Inga skillnader mellan dessa versioner. + Publicera denna version? Den blir skrivskyddad och nya poster använder den. + Publicera + Publicering kräver behörigheten att hantera postdefinitioner. + Avveckla + Orsak till att avveckla denna definition + Migrera öppna utkast + Förhandsgranska migrering + Migrera nu + Flytta alla öppna utkast till den nyare versionen? Omappade värden förloras. + Definition skapad. Redigera schemat, validera och publicera sedan. + Utkast sparat. + Definitionen är giltig. + Definitionen har fel. Åtgärda dem innan publicering. + Utkastversion borttagen. + Version {0} publicerad. + Definition avvecklad. Befintliga poster kan fortfarande läsas; inga nya kan skapas. + Sparade rapporter + Sparade rapporter frågar en definitions poster per fält, grupperar och aggregerar dem och exporterar till CSV. Resultaten respekterar synlighet och begränsade fält. + Ny rapport + Rapportnamn + Rapportinställningar + Kolumner + Postkolumner + Välj en definition för att välja kolumner. + Sortera efter + Fallande + Gruppera efter + Aggregat (JSON) + Count, Sum, Average, Min, Max på aggregerbara fält + Filter (JSON) + Filtrerbara fält + Versionsmappningar (JSON) + Max rader + Inkludera utkast + Inkludera begränsade fält + Validera + Kör + Senaste körning + Ta bort denna sparade rapport? + Rader + Grupper + Antal + Resultaten kapades vid radgränsen; begränsa filtren eller exportera till CSV. + Poster på omappade definitionsversioner inkluderades med tomma celler + Rapport sparad. + Rapport borttagen. + Rapporten är giltig. + Rapporten har fel. + Insatser + Externa resursbeställningar som din avdelning fyller. Beställningssystemet är fortsatt auktoritativt: artefakter lagras som oföränderliga ögonblicksbilder och inget skrivs tillbaka. + Ny insats + Insatser är en förhandsvisningsfunktion: mallarna byggdes från publicerad IROC-, CIFFC- och EMAC-dokumentation, inte från liveintegrationer. Verifiera mot ditt beställningssystem. + Beställning + Beställningsnummer + Händelse + Land / region + Kontor + Beställande kontor + Larmcentral + Myndigheter + Begärande myndighet + Mottagande myndighet + Sändande myndighet + Avdelningens roll + Fyller beställningen + Begär resurser + Värd för händelsen + Kostnad + Kostnadskod + Avtalsreferens + Valuta / enheter / tidszon + Beställningsdokument + Ladda upp beställningsdokumentet från källsystemet (PDF, JSON eller CSV). Det lagras som en oföränderlig ögonblicksbild med kontrollsumma. + Källa + Källsystem + Källversion + Öppna i källsystemet + Status + Visa avslutade insatser + Dölj avslutade insatser + Mobiliserad + Frisläppt + Avslutad + Registrera ny ögonblicksbild + Spara ögonblicksbild + Avsluta insats + Avslutningsanteckningar + Avsluta denna insats? Tillsättningar kan inte längre ändras. + Varje accepterad tillsättning måste vara Återvänd före avslut. + Tillsättningar + Första tillsättning (valfritt) + Lägg till första begäran/tillsättning nu eller från insatssidan. + Lägg till tillsättning + Begärannummer + Överordnad begäran + Resurs + Befattning + Praktikant + Tilldelad person + Hemmaenhet + Värdmyndighet + Behövs den + Insatspost + Skapa insats + Orsak till att avböja denna begäran + Insats skapad. + Tillsättning tillagd. + Tillsättning uppdaterad. + Välj beställningsdokumentet som ska sparas som ögonblicksbild. + Ögonblicksbild sparad. Den tidigare behålls och ersätts. + Insats avslutad. + Utskriftslayout + Ordna, byt namn på eller dölj sektioner och fält för utskrift och PDF, lägg till sidbrytningar och placera signaturblock och bilagelista. Varje sparning skapar en ny layoutversion som stämplas i proveniensfoten. + Layoutversion + Avdelningsstandard + Ordning + Alternativ rubrik + Synlig + Sidbrytning före + Fält + Gäller version + Alla versioner + Signaturblock + I slutet + I sin sektion + Dold + Bilagelista + Tabell + Enkel lista + Dold + Åsidosätt avdelningens profil för denna definition + Brevhuvud + Utskriftslayout sparad. + valda + Markera alla på denna sida + Granskare… + Orsak + Tilldela för granskning + Pakettitel + Syfte (granskas) + E-posta paketet till (valfritt) + Sammanställd PDF + Zip-paket + Upp till {0} poster per paket. Ingen massannullering eller -radering. + Välj minst en post. + {0} post(er) tilldelade för granskning; {1} hoppades över. + Paket sammanställt från {0} post(er); {1} hoppades över. Nedladdningsbart i 30 dagar. + Skickat via e-post. + Modulprojektion + Sätter samman en avgränsad ögonblicksbild från ägarmodulen: endast identifierare, koder, antal och statusar med källreferenser. Modulen förblir systemet som gäller. + Projektion + Personalincheckning + Resurssammanfattning + Kvalifikationer + Ledningssammanfattning diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index 86e67e5e..27b59712 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -1064,4 +1064,255 @@ Підтверджено Застосовано Розширений захист даних: захищені надсилання не працюватимуть, доки не зафіксовано це підтвердження. Ідентифікатор запису + Додати рядок + Видалити рядок + Приховано + Координати (шир., довг.) або посилання на місце + Я підписую цей запис як автор + Так + Ні + Редагувати + Увімкнено + Попередній перегляд + Цей запис відповідає визначенню підрозділу. Поля з замком обмежені; правила можуть показувати або вимагати поля під час введення. + Визначення + Визначення записів описують форми вашого підрозділу: розділи, поля, правила, нумерацію та хто перевіряє чи затверджує. Публікація заморожує версію; записи закріплюються за версією, в якій їх створено. + Нове визначення + Створити визначення + Почати з шаблону + Клонувати наявне визначення + Порожнє визначення + Переглянути шаблони + Пакети шаблонів є перевіреними відправними точками. Кожен показує джерела та дату перегляду; пакети попереднього перегляду можна використовувати, але вони ще перевіряються з підрозділами. + Переглянуто + Джерела + Розділи + Поля + Використати цей шаблон + Профіль юрисдикції + Локаль + Попередній перегляд шаблону + Малі літери, цифри, крапки та дефіси. Не можна змінити після створення. + Назва + Категорія + Опис + Власник + Продукт (заблоковано) + Опублікована версія + Чернетка версії + Шаблон життєвого циклу + Можливість клієнта + Виведено з ужитку + Показати виведені визначення + Приховати виведені визначення + Історія + Версія + Стан + Опубліковано + Примітки до змін + Налаштування + Дозволені суб'єкти + Ролі рецензентів + Ролі затверджувачів + Термін перевірки (год.) + Термін затвердження (год.) + Вимагати підтвердження автора під час завершення + Нумерація + Префікс номера + Ширина послідовності + Призначити номер + Послідовність на станцію + Скидати щороку + Класифікація + Зберігання (роки) + Клієнтські поверхні + Дозволити офлайн + Дозволити вкладення + Карта міграції (JSON) + Схема (JSON) + Розділи містять поля; кожне поле має ключ, підпис, тип і необов'язкові правила. Перевірте перед публікацією; таблиця полів нижче відображає останню збережену чернетку. + Поля + Повторювана + Правила + Ключ + Підпис + Тип поля + Обов'язкове + Класифікація + Прапорці + Перевірити + Зберегти чернетку + Переглянути вплив і опублікувати + Відкрити нову чернетку + Видалити чернетку + Видалити цю чернетку версії? Опубліковані версії не зачіпаються. + Ця версія опублікована й доступна лише для читання. Відкрийте нову чернетку для змін. + Проблеми перевірки + Відмінності від шаблону, з якого створено це визначення + Вплив публікації + Публікація заморожує цю версію. Наявні чернетки зберігають свою версію до міграції; завершені записи ніколи не змінюються. + Відкриті чернетки поточної версії + Завершені записи в попередніх версіях + Несумісна зміна + Готовність клієнтів + Зміни + Порівняти від + до + Порівняти + Між цими версіями немає відмінностей. + Опублікувати цю версію? Вона стане лише для читання, і нові записи використовуватимуть її. + Опублікувати + Публікація потребує дозволу на керування визначеннями записів. + Вивести з ужитку + Причина виведення цього визначення + Мігрувати відкриті чернетки + Попередній перегляд міграції + Мігрувати зараз + Перенести всі відкриті чернетки на новішу версію? Незіставлені значення буде відкинуто. + Визначення створено. Відредагуйте схему, перевірте, потім опублікуйте. + Чернетку збережено. + Визначення дійсне. + Визначення містить помилки. Виправте їх перед публікацією. + Чернетку версії видалено. + Версію {0} опубліковано. + Визначення виведено з ужитку. Наявні записи залишаються доступними; нові створити не можна. + Збережені звіти + Збережені звіти запитують записи одного визначення за полями, групують і агрегують їх та експортують у CSV. Результати враховують видимість і обмежені поля. + Новий звіт + Назва звіту + Налаштування звіту + Стовпці + Стовпці запису + Виберіть визначення, щоб обрати стовпці. + Сортувати за + За спаданням + Групувати за + Агрегати (JSON) + Count, Sum, Average, Min, Max для агрегованих полів + Фільтри (JSON) + Поля для фільтрації + Зіставлення версій (JSON) + Макс. рядків + Включити чернетки + Включити обмежені поля + Перевірити + Запустити + Останній запуск + Видалити цей збережений звіт? + Рядки + Групи + Кількість + Результати обрізано за лімітом рядків; звузьте фільтри або експортуйте в CSV. + Записи на незіставлених версіях визначення включено з порожніми комірками + Звіт збережено. + Звіт видалено. + Звіт дійсний. + Звіт містить помилки. + Розгортання + Зовнішні замовлення ресурсів, які виконує ваш підрозділ. Система замовлення залишається авторитетною: артефакти зберігаються як незмінні знімки, і нічого не записується назад. + Нове розгортання + Розгортання є функцією попереднього перегляду: шаблони побудовано з опублікованої документації IROC, CIFFC та EMAC, а не з живих інтеграцій. Перевірте у своїй системі замовлення. + Замовлення + Номер замовлення + Інцидент + Країна / регіон + Офіси + Офіс замовлення + Диспетчерський офіс + Агенції + Агенція-замовник + Агенція-отримувач + Агенція-відправник + Роль підрозділу + Виконує замовлення + Запитує ресурси + Приймає інцидент + Вартість + Код витрат + Посилання на угоду + Валюта / одиниці / часовий пояс + Документ замовлення + Завантажте документ замовлення з вихідної системи (PDF, JSON або CSV). Він зберігається як незмінний знімок із контрольною сумою. + Джерело + Вихідна система + Версія джерела + Відкрити у вихідній системі + Статус + Показати закриті розгортання + Приховати закриті розгортання + Мобілізовано + Звільнено + Закрито + Записати новий знімок + Зберегти знімок + Закрити розгортання + Примітки закриття + Закрити це розгортання? Заповнення більше не можна змінювати. + Кожне прийняте заповнення має бути повернуте перед закриттям. + Заповнення + Перше заповнення (необов'язково) + Додайте перший запит/заповнення зараз або зі сторінки розгортання. + Додати заповнення + Номер запиту + Батьківський запит + Ресурс + Посада + Стажер + Призначена особа + Домашній підрозділ + Приймаюча агенція + Потрібно на + Запис розгортання + Створити розгортання + Причина відхилення цього запиту + Розгортання створено. + Заповнення додано. + Заповнення оновлено. + Виберіть документ замовлення для збереження як знімок. + Знімок збережено. Попередній збережено та замінено. + Розгортання закрито. + Макет друку + Упорядковуйте, перейменовуйте або приховуйте розділи та поля для друку і PDF, додавайте розриви сторінок і розміщуйте блок підпису та список вкладень. Кожне збереження створює нову версію макета, зазначену в нижньому колонтитулі походження. + Версія макета + Типовий для підрозділу + Порядок + Заміна заголовка + Видимий + Розрив сторінки перед + Поля + Застосовується до версії + Усі версії + Блок підпису + Наприкінці + У своєму розділі + Приховано + Список вкладень + Таблиця + Простий список + Приховано + Перевизначити брендинг підрозділу для цього визначення + Бланк + Макет друку збережено. + вибрано + Вибрати всі на цій сторінці + Рецензент… + Причина + Призначити на перевірку + Назва пакета + Мета (аудит) + Надіслати пакет на e-mail (необов'язково) + Зведений PDF + Zip-пакет + До {0} записів у пакеті. Без масового анулювання чи видалення. + Виберіть принаймні один запис. + Призначено {0} запис(ів) на перевірку; пропущено {1}. + Пакет зібрано з {0} запис(ів); пропущено {1}. Доступний для завантаження 30 днів. + Надіслано на e-mail. + Проєкція модуля + Складає обмежений знімок з модуля-власника: лише ідентифікатори, коди, кількості та статуси з посиланнями на джерело. Модуль залишається системою обліку. + Проєкція + Реєстрація особового складу + Зведення ресурсів + Кваліфікації + Зведення командування diff --git a/Core/Resgrid.Model/AdpTableBinding.cs b/Core/Resgrid.Model/AdpTableBinding.cs index 5b8d213b..4d929be8 100644 --- a/Core/Resgrid.Model/AdpTableBinding.cs +++ b/Core/Resgrid.Model/AdpTableBinding.cs @@ -60,6 +60,18 @@ public static AdpTableBinding ViaParent(string tableName, string pkColumn, bool /// Row-level protection marker column ("IsProtected"), when the table has one (companion pattern). public string ProtectedMarkerColumn { get; init; } + + /// + /// Non-cataloged sibling columns a PackedJson column packs and clears (ADP catalog v11). They are read and + /// written with the row but never counted as residue on their own. + /// + public IReadOnlyList CarrierColumns { get; init; } = Array.Empty(); + + /// + /// Optional boolean column that scopes the sweep to rows needing protection (RmsRecordValues.ProtectionRequired); + /// null sweeps every row of the table. + /// + public string RowFilterColumn { get; init; } } /// One cataloged column inside a binding. diff --git a/Core/Resgrid.Model/ProtectedFieldStorageKind.cs b/Core/Resgrid.Model/ProtectedFieldStorageKind.cs index 20914233..782b03fb 100644 --- a/Core/Resgrid.Model/ProtectedFieldStorageKind.cs +++ b/Core/Resgrid.Model/ProtectedFieldStorageKind.cs @@ -16,6 +16,14 @@ public enum ProtectedFieldStorageKind /// while protected and a Protected{Name}Envelope companion column carries the value /// (Appendix B pattern). /// - CompanionColumn = 3 + CompanionColumn = 3, + + /// + /// A JSON pack of a row's sibling typed columns carried as a text envelope in a dedicated column + /// (RmsRecordValues.ProtectedEnvelope, ADP catalog v11). While sealed the siblings are null, so the row still + /// populates exactly one column group; the binding names the siblings as carrier columns and a boolean row + /// filter scopes the sweep to rows whose field is Protected-classified. + /// + PackedJson = 4 } } diff --git a/Core/Resgrid.Model/Records/FieldRecordsContracts.cs b/Core/Resgrid.Model/Records/FieldRecordsContracts.cs new file mode 100644 index 00000000..4bed5ab2 --- /dev/null +++ b/Core/Resgrid.Model/Records/FieldRecordsContracts.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Resgrid.Model +{ + /// + /// The versioned Field Records client contract (RMS plan RMS-1D "FieldRecordCatalogV1"): what a field app may + /// assume the server speaks. Everything a client renders, validates, queues or syncs is named here so the shared + /// golden fixtures can pin it; a client that reports an unknown control or rule is refused for authoring and may + /// still read. Constants only; the catalog itself is computed per request by . + /// + public static class FieldRecordCatalogV1 + { + public const string ContractVersion = "field-catalog.v1"; + public const string SyncContractVersion = "field-sync.v1"; + public const string PrefillContractVersion = "field-prefill.v1"; + + /// Launch contexts a definition surface may name; "none" is the app's Field Records home. + public static class LaunchContexts + { + public const string None = "none"; + public const string Call = "call"; + public const string Unit = "unit"; + public const string Contact = "contact"; + public const string Checklist = "checklist"; + public const string WorkOrder = "workorder"; + public const string Command = "command"; + public static readonly IReadOnlyList All = new[] { None, Call, Unit, Contact, Checklist, WorkOrder, Command }; + } + + /// Why a definition is withheld from a catalog. Codes only: never a reason a forged client could act on. + public static class ExclusionReasons + { + public const string OriginNotField = "origin_not_field"; + public const string ModuleDisabled = "module_disabled"; + public const string RecordsNotUsable = "records_not_usable"; + public const string AppDisabled = "app_disabled"; + public const string AppVersionTooOld = "app_version_too_old"; + public const string NotMember = "not_member"; + public const string SurfaceNotEnabled = "surface_not_enabled"; + public const string ContextNotAllowed = "context_not_allowed"; + public const string ContextNotVerified = "context_not_verified"; + public const string CapabilityUnsupported = "capability_unsupported"; + public const string Retired = "retired"; + public const string NotPublished = "not_published"; + public const string ProtectedDataUnavailable = "protected_data_unavailable"; + public static readonly IReadOnlyList All = new[] + { + OriginNotField, ModuleDisabled, RecordsNotUsable, AppDisabled, AppVersionTooOld, NotMember, SurfaceNotEnabled, ContextNotAllowed, ContextNotVerified, + CapabilityUnsupported, Retired, NotPublished, ProtectedDataUnavailable + }; + } + + /// Controls the contract expects every adapter to render (the RMS-1B/1C field catalog). + public static readonly IReadOnlyList SupportedControls = Enum.GetNames(typeof(RmsFieldType)); + + /// Rule effects and operators an adapter must evaluate client-side exactly as the server does. + public static readonly IReadOnlyList SupportedRuleEffects = Enum.GetNames(typeof(RmsRuleEffect)); + public static readonly IReadOnlyList SupportedRuleOperators = Enum.GetNames(typeof(RmsRuleOperator)); + + /// Lifecycle actions a field client may issue. Amendment, void and approval stay Web-first (plan RMS-1D). + public static readonly IReadOnlyList LifecycleActions = new[] { "create", "save", "submit-for-review", "finalize", "cancel", "acknowledge-assignment", "complete-assignment" }; + + /// Sync states a client reports and the server can answer with. + public static readonly IReadOnlyList SyncStates = new[] { "fresh", "delta", "reset-required", "conflict" }; + + /// Conflict kinds a client must present explicitly rather than replay silently. + public static readonly IReadOnlyList ConflictKinds = new[] { "etag", "permission", "scope", "definition-retired", "protected-data", "app-version" }; + + /// Prefill sources a definition surface's prefill map may name (server-calculated, provenance-stamped). + public static readonly IReadOnlyList PrefillSources = new[] + { + "call.id", "call.number", "call.name", "call.nature", "call.address", "call.geolocation", "call.logged_on", + "unit.id", "unit.name", "group.id", "group.name", "user.id", "command.name", "command.commander", "now" + }; + + /// + /// Locked system definitions a field app may start without a department surface (the "Field-ready starter + /// allowlist"): Responder authors self/assignment records, Unit authors apparatus activity, IC and Dispatch + /// author Call-bound run records. A department definition reaches an app only through its client surface. + /// + public static IReadOnlyList LockedStarterAllowlist(RmsOriginClient origin) + { + switch (origin) + { + case RmsOriginClient.Responder: return new[] { RmsDefinitionKeys.Training, RmsDefinitionKeys.Meeting, RmsDefinitionKeys.Work }; + case RmsOriginClient.Unit: return new[] { RmsDefinitionKeys.UnitActivity, RmsDefinitionKeys.Run }; + case RmsOriginClient.IncidentCommand: return new[] { RmsDefinitionKeys.Run }; + case RmsOriginClient.Dispatch: return new[] { RmsDefinitionKeys.Callback, RmsDefinitionKeys.Run }; + default: return Array.Empty(); + } + } + + /// The launch contexts a locked starter definition accepts. + public static IReadOnlyList LockedLaunchContexts(string definitionKey) + { + switch (definitionKey) + { + case RmsDefinitionKeys.Run: return new[] { LaunchContexts.Call, LaunchContexts.Command }; + case RmsDefinitionKeys.UnitActivity: return new[] { LaunchContexts.Unit, LaunchContexts.Call, LaunchContexts.None }; + case RmsDefinitionKeys.Callback: return new[] { LaunchContexts.Call, LaunchContexts.None }; + default: return new[] { LaunchContexts.None }; + } + } + + public static bool IsFieldOrigin(RmsOriginClient origin) + => origin == RmsOriginClient.Responder || origin == RmsOriginClient.Unit || origin == RmsOriginClient.IncidentCommand || origin == RmsOriginClient.Dispatch; + + /// Numeric dotted-version compare ("1.2.10" > "1.2.9"); a null or blank side sorts lowest; non-numeric segments compare ordinally. + public static int CompareVersions(string left, string right) + { + var a = Segments(left); + var b = Segments(right); + for (var i = 0; i < Math.Max(a.Count, b.Count); i++) + { + var x = i < a.Count ? a[i] : "0"; + var y = i < b.Count ? b[i] : "0"; + if (int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var xi) && int.TryParse(y, NumberStyles.Integer, CultureInfo.InvariantCulture, out var yi)) + { + if (xi != yi) return xi.CompareTo(yi); + continue; + } + var ordinal = string.CompareOrdinal(x, y); + if (ordinal != 0) return ordinal; + } + return 0; + } + + /// True when the app version meets the minimum; an unset minimum passes, an unset app version never does. + public static bool MeetsMinimum(string appVersion, string minimum) + { + if (string.IsNullOrWhiteSpace(minimum)) return true; + if (string.IsNullOrWhiteSpace(appVersion)) return false; + return CompareVersions(appVersion, minimum) >= 0; + } + + private static List Segments(string version) + { + var text = (version ?? string.Empty).Trim(); + if (text.StartsWith("v", StringComparison.OrdinalIgnoreCase)) text = text.Substring(1); + var plus = text.IndexOf('+'); + if (plus >= 0) text = text.Substring(0, plus); + var dash = text.IndexOf('-'); + if (dash >= 0) text = text.Substring(0, dash); + return text.Length == 0 ? new List() : text.Split('.').Select(s => s.Trim()).ToList(); + } + } + + /// The verified field context a request runs in: identifiers only, every one checked server-side. + public class FieldRecordContext + { + public int? CallId { get; set; } + public int? UnitId { get; set; } + public int? GroupId { get; set; } + /// IC app: the command role the user claims on the Call; verified against the active command. + public string CommandRole { get; set; } + public int? ContactId { get; set; } + + public bool IsEmpty => !CallId.HasValue && !UnitId.HasValue && !GroupId.HasValue && string.IsNullOrWhiteSpace(CommandRole) && !ContactId.HasValue; + + /// The launch context kind this context represents for catalog filtering. + public string Kind + { + get + { + if (!string.IsNullOrWhiteSpace(CommandRole)) return FieldRecordCatalogV1.LaunchContexts.Command; + if (UnitId.HasValue) return FieldRecordCatalogV1.LaunchContexts.Unit; + if (CallId.HasValue) return FieldRecordCatalogV1.LaunchContexts.Call; + if (ContactId.HasValue) return FieldRecordCatalogV1.LaunchContexts.Contact; + return FieldRecordCatalogV1.LaunchContexts.None; + } + } + } + + /// Outcome of server-side context authorization. + public class FieldRecordContextVerification + { + public bool Ok { get; set; } + public List Reasons { get; set; } = new List(); + public string CallNumber { get; set; } + public string UnitName { get; set; } + public string GroupName { get; set; } + public string CommandName { get; set; } + public bool StaffedOnUnit { get; set; } + public bool HoldsCommandRole { get; set; } + public static FieldRecordContextVerification Allowed() => new FieldRecordContextVerification { Ok = true }; + public static FieldRecordContextVerification Denied(string reason) => new FieldRecordContextVerification { Ok = false, Reasons = { reason } }; + } + + public class FieldRecordCatalogRequest + { + public RmsOriginClient Origin { get; set; } + public string AppVersion { get; set; } + /// The renderer capability the client reports (records.v1 / v1b / v1c); unknown is treated as records.v1. + public string ClientCapability { get; set; } + public FieldRecordContext Context { get; set; } = new FieldRecordContext(); + } + + /// Minimum-version / flag preflight a field app runs before showing Records at all. + public class FieldRecordPreflight + { + public string ContractVersion { get; set; } = FieldRecordCatalogV1.ContractVersion; + public string SyncContractVersion { get; set; } = FieldRecordCatalogV1.SyncContractVersion; + public RmsOriginClient Origin { get; set; } + public bool Ok { get; set; } + public List Reasons { get; set; } = new List(); + public bool ModuleEnabled { get; set; } + public bool RecordsUsable { get; set; } + public bool AppEnabled { get; set; } + public string MinimumAppVersion { get; set; } + public string AppVersion { get; set; } + public string ClientCapability { get; set; } + public string ProtectionState { get; set; } + public long ServerTimestampMs { get; set; } + } + + /// One definition a field app may start, with what it needs to decide offline/attachment/protected behavior. + public class FieldRecordCatalogEntry + { + public string DefinitionKey { get; set; } + public int Version { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public bool Locked { get; set; } + public int? RecordType { get; set; } + public string LifecyclePreset { get; set; } + public List LaunchContexts { get; set; } = new List(); + public bool AllowOffline { get; set; } + public bool AllowAttachments { get; set; } + public string MinimumAppVersion { get; set; } + public string MinimumClientCapability { get; set; } + public bool Restricted { get; set; } + /// The definition carries Protected fields: authoring needs a live grant and never caches plaintext offline. + public bool RequiresProtectedGrant { get; set; } + public string SchemaChecksum { get; set; } + public int PrefillVersion { get; set; } + public bool SupportsPrefill { get; set; } + } + + public class FieldRecordCatalogExclusion + { + public string DefinitionKey { get; set; } + public string Reason { get; set; } + } + + /// The manifest, filtered server-side; a client cannot widen it by changing a query value. + public class FieldRecordCatalog + { + public string ContractVersion { get; set; } = FieldRecordCatalogV1.ContractVersion; + public RmsOriginClient Origin { get; set; } + public bool Ok { get; set; } + public List Reasons { get; set; } = new List(); + public string ContextKind { get; set; } + public bool ContextVerified { get; set; } + public string ProtectionState { get; set; } + public string ScopeStamp { get; set; } + public List Definitions { get; set; } = new List(); + public List Exclusions { get; set; } = new List(); + public long ServerTimestampMs { get; set; } + + public bool Includes(string definitionKey, int version) => Definitions.Any(d => string.Equals(d.DefinitionKey, definitionKey, StringComparison.OrdinalIgnoreCase) && d.Version == version); + } + + /// Where one prefilled value came from. + public class FieldRecordPrefillProvenance + { + public string FieldKey { get; set; } + public string Source { get; set; } + public string SourceId { get; set; } + public DateTime CapturedOn { get; set; } + } + + /// Server-calculated prefill for a definition version in a verified context. + public class FieldRecordPrefill + { + public string ContractVersion { get; set; } = FieldRecordCatalogV1.PrefillContractVersion; + public string DefinitionKey { get; set; } + public int Version { get; set; } + public int PrefillVersion { get; set; } + public int? CallId { get; set; } + public int? UnitId { get; set; } + public int? StationGroupId { get; set; } + public List Values { get; set; } = new List(); + public List Provenance { get; set; } = new List(); + public List SuggestedParticipantUserIds { get; set; } = new List(); + public List SuggestedUnitIds { get; set; } = new List(); + public DateTime CalculatedOn { get; set; } + } + + public class FieldRecordSyncRequest + { + public RmsOriginClient Origin { get; set; } + public string AppVersion { get; set; } + public string ClientCapability { get; set; } + public FieldRecordContext Context { get; set; } = new FieldRecordContext(); + public long Since { get; set; } + public string SinceId { get; set; } + public string ScopeStamp { get; set; } + public int Take { get; set; } = 200; + public bool IncludeCatalog { get; set; } = true; + } + + /// + /// A bounded Field Records sync bundle: catalog, authorized change delta with tombstones, the caller's own + /// drafts and returned Records, and the caller's open work assignments. Never a bulk RMS export: every row + /// is re-authorized at read time and a scope change resets the client. + /// + public class FieldRecordSyncBundle + { + public string ContractVersion { get; set; } = FieldRecordCatalogV1.SyncContractVersion; + public bool Ok { get; set; } + public List Reasons { get; set; } = new List(); + public string ScopeStamp { get; set; } + public bool ResetRequired { get; set; } + public long Since { get; set; } + public long ServerTimestampMs { get; set; } + public string ServerCursorId { get; set; } + public bool HasMore { get; set; } + public FieldRecordCatalog Catalog { get; set; } + public List Changes { get; set; } = new List(); + /// Record ids in the caller may no longer read; the client evicts them. + public List Tombstones { get; set; } = new List(); + public List Drafts { get; set; } = new List(); + public List Assignments { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/Records/IncidentReportContracts.cs b/Core/Resgrid.Model/Records/IncidentReportContracts.cs index e733c9a0..ed0e4358 100644 --- a/Core/Resgrid.Model/Records/IncidentReportContracts.cs +++ b/Core/Resgrid.Model/Records/IncidentReportContracts.cs @@ -146,6 +146,12 @@ public class IncidentReportDraftInput /// One conditional section instance being saved; is the contract-shaped body. public class IncidentModuleInput { + /// + /// The stored row this input replaces, or null for a new section. Identity has to travel with the row: + /// matching by list position would hand one section's id, ProtectionId and sealed envelopes to another + /// section's content as soon as a client reorders or removes an entry. + /// + public string ModuleId { get; set; } public RmsIncidentModuleKind Kind { get; set; } public string PrimaryCode { get; set; } public string SecondaryCode { get; set; } @@ -157,6 +163,8 @@ public class IncidentModuleInput public class IncidentResourceInput { + /// The stored row this input replaces, or null for a new resource. See . + public string ResourceId { get; set; } public string ResourceCode { get; set; } public int? Quantity { get; set; } public string Detail { get; set; } @@ -200,6 +208,8 @@ public class IncidentCasualtyRescueInput public class IncidentExposureInput { + /// The stored row this input replaces, or null for a new exposure. See . + public string ExposureId { get; set; } public string LocationKind { get; set; } public string ItemType { get; set; } public string DamageType { get; set; } diff --git a/Core/Resgrid.Model/Records/RecordsBulkContracts.cs b/Core/Resgrid.Model/Records/RecordsBulkContracts.cs new file mode 100644 index 00000000..3f5a810c --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsBulkContracts.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// How a bulk packet is compiled (RMS plan section 4.7, "Bulk operations and packets"). + public enum RecordsBulkPacketMode + { + /// One compiled PDF: cover page with manifest, then every record in selection order. + CompiledPdf = 1, + /// A zip bundle of per-record PDFs plus a manifest.json. + Bundle = 2 + } + + /// A bounded bulk print/export over an authorized selection; never bulk void or bulk delete. + public class RecordsBulkPacketRequest + { + /// Most records one packet may compile; a packet is a deliverable, not an export of the archive. + public const int MaxRecords = 200; + + public List RecordIds { get; set; } = new List(); + public RecordsBulkPacketMode Mode { get; set; } = RecordsBulkPacketMode.CompiledPdf; + public string Title { get; set; } + /// Why the packet is produced (accreditation, insurance, discovery, board packet); audited per record. + public string Purpose { get; set; } + /// Optional delivery through the scheduled-report email path; the stored run stays downloadable either way. + public string DeliverToEmail { get; set; } + public RmsOriginClient OriginClient { get; set; } = RmsOriginClient.Web; + } + + public class RecordsBulkAssignRequest + { + public List RecordIds { get; set; } = new List(); + public string ReviewerUserId { get; set; } + public string Reason { get; set; } + } + + public class RecordsBulkResult + { + public int Processed { get; set; } + public int Skipped { get; set; } + public List Skips { get; set; } = new List(); + /// The stored run for a packet (download through the export runs surface); null for an assignment. + public RmsExportRun Run { get; set; } + public bool Delivered { get; set; } + } + + public class RecordsBulkSkip + { + public string RecordId { get; set; } + public string Reason { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordsContracts.cs b/Core/Resgrid.Model/Records/RecordsContracts.cs index 8d487330..ee7910e0 100644 --- a/Core/Resgrid.Model/Records/RecordsContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsContracts.cs @@ -158,6 +158,8 @@ public class RecordNewCallInput public class RecordDraftInput { public RecordUdfInput CustomFields { get; set; } + /// Typed values for a department definition (RMS-1B, plan section 5.3); ignored for locked system definitions. + public List Values { get; set; } = new List(); /// One of ; required on create. public string DefinitionKey { get; set; } public int? CallId { get; set; } @@ -185,6 +187,11 @@ public class RecordAggregate public ProtectedReadResult Protection { get; set; } public RecordUdfSection CustomFields { get; set; } + /// Typed values rendered against the pinned definition version (department definitions only). + public RecordValueSet Values { get; set; } + /// The pinned definition version for a department definition; null for locked system definitions. + [Newtonsoft.Json.JsonIgnore] + public RmsRecordDefinitionVersion DefinitionVersionRow { get; set; } public RmsOperationalRecord Record { get; set; } public RmsOperationalRecordDetail Details { get; set; } public List Participants { get; set; } = new List(); @@ -201,6 +208,8 @@ public class RecordAggregate public class RecordSnapshot { public RecordUdfSection CustomFields { get; set; } + /// Department-definition typed values pinned to the version's labels: section label -> field label -> display, or a row array for repeating sections. + public Dictionary Values { get; set; } public int SnapshotVersion { get; set; } = 1; public List Evidence { get; set; } = new List(); public string RecordId { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs b/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs index 9f981f4b..f9caaad9 100644 --- a/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs +++ b/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs @@ -30,7 +30,10 @@ public enum RmsEvidenceKind InventoryUsage = 5, /// Participant certification/qualification validity at the incident time. - CertificationSnapshot = 6 + CertificationSnapshot = 6, + + /// An operational pack's module projection (RMS-1C): personnel check-in, resource summary, qualifications or command summary composed from the owning module with source identifiers. + ModuleProjection = 7 } /// diff --git a/Core/Resgrid.Model/Records/RmsExportTemplate.cs b/Core/Resgrid.Model/Records/RmsExportTemplate.cs index ebca6dc2..587666de 100644 --- a/Core/Resgrid.Model/Records/RmsExportTemplate.cs +++ b/Core/Resgrid.Model/Records/RmsExportTemplate.cs @@ -226,6 +226,8 @@ public enum RmsExportTrigger { Scheduled = 1, Record = 2, - Manual = 3 + Manual = 3, + /// A bulk packet compiled over an authorized selection (RMS plan section 4.7). + Bulk = 4 } } diff --git a/Core/Resgrid.Model/Records/RmsExternalOrders.cs b/Core/Resgrid.Model/Records/RmsExternalOrders.cs new file mode 100644 index 00000000..7efaaf9f --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsExternalOrders.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// Mutual-aid ordering profiles (RMS plan section 4.1 "external-order fill contract"). + public static class RmsDeploymentProfiles + { + public const string Generic = "generic"; + public const string UsWildland = "us-wildland"; + public const string CaWildland = "ca-wildland"; + public const string CrossBorder = "us-ca-crossborder"; + public const string Compact = "emac-compact"; + public const string LocalMutualAid = "local-mutual-aid"; + + public static readonly IReadOnlyList All = new[] { Generic, UsWildland, CaWildland, CrossBorder, Compact, LocalMutualAid }; + public static bool IsKnown(string key) => key != null && All.Contains(key, StringComparer.OrdinalIgnoreCase); + } + + /// The fill lifecycle a supplied resource walks (RMS plan section 4.1 steps 3-5). Return is decided by the department, never inferred from the external system. + public enum RmsDeploymentFillStatus + { + Requested = 1, + Accepted = 2, + Declined = 3, + Mobilized = 4, + CheckedIn = 5, + Assigned = 6, + Released = 7, + Demobilized = 8, + Returned = 9 + } + + public enum RmsExternalOrderStatus + { + Open = 1, + Mobilized = 2, + Released = 3, + ClosedOut = 4 + } + + /// + /// One external resource order/request the department is filling (registry M0163). The source system stays + /// authoritative: the order artifact is stored as an immutable, checksummed snapshot, later imports arrive as new + /// snapshots, and nothing here writes back to IROC, CIFFC or any member agency. + /// + public class RmsExternalOrder : IEntity + { + public string RmsExternalOrderId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + /// The deployment Record (pack.mutual-aid.deployment definition) this order rides on. + public string RecordId { get; set; } + public string ProfileKey { get; set; } + public int ProfileVersion { get; set; } + /// Cross-border deployments retain both sides (RMS plan section 4.1). + public string HomeProfileKey { get; set; } + public string HostProfileKey { get; set; } + /// Opaque identifier scheme of the ordering system (iroc, ciffc, agency:, local). + public string SourceScheme { get; set; } + public string SourceSystem { get; set; } + public string OrderNumber { get; set; } + public string IncidentName { get; set; } + public string IncidentNumber { get; set; } + public string IncidentCountry { get; set; } + public string IncidentSubdivision { get; set; } + public string OrderingOffice { get; set; } + public string DispatchOffice { get; set; } + public string RequestingAgency { get; set; } + public string ReceivingAgency { get; set; } + public string SendingAgency { get; set; } + /// filling | sending | both + public string DepartmentRole { get; set; } + public string CostCode { get; set; } + public string AgreementReference { get; set; } + public string CurrencyCode { get; set; } + public string MeasurementSystem { get; set; } + public string TimeZoneId { get; set; } + public int? CapturedOffsetMinutes { get; set; } + public DateTime? SourceCapturedOn { get; set; } + public string SourceVersion { get; set; } + public string ArtifactFileName { get; set; } + public string ArtifactContentType { get; set; } + public string ArtifactChecksum { get; set; } + public byte[] ArtifactData { get; set; } + public string ArtifactSafeUrl { get; set; } + public int Status { get; set; } + public DateTime? MobilizedOn { get; set; } + public DateTime? ReleasedOn { get; set; } + public DateTime? ClosedOutOn { get; set; } + public string ClosedOutByUserId { get; set; } + public string CloseoutNotes { 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; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsExternalOrderId; } + set { RmsExternalOrderId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsExternalOrders"; + [NotMapped] public string IdName => "RmsExternalOrderId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// One request/fill assignment on an external order: the supplied resource linked to its exact request number. + public class RmsExternalOrderFill : IEntity + { + public string RmsExternalOrderFillId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsExternalOrderId { get; set; } + public string RecordId { get; set; } + public string RequestNumber { get; set; } + public string ParentRequestNumber { get; set; } + /// overhead | crew | equipment | aircraft-support | supply | other + public string RequestCategory { get; set; } + public string FillNumber { get; set; } + public string ResourceKind { get; set; } + public string ResourceType { get; set; } + public string ResourceTypeScheme { get; set; } + public string Position { get; set; } + public string PositionScheme { get; set; } + public bool IsTrainee { get; set; } + public string HomeUnit { get; set; } + public string HostAgency { get; set; } + public string AgencyUnitId { get; set; } + public string PointOfHire { get; set; } + public string CostCode { get; set; } + public string AgreementReference { get; set; } + public string AssignedUserId { get; set; } + public int? AssignedUnitId { get; set; } + /// Qualifications asserted by the Certifications module at capture; a snapshot, never a declaration of equivalence. + public string QualificationsJson { get; set; } + public string RosterJson { get; set; } + public string TravelJson { get; set; } + public int Status { get; set; } + public string DeclineReason { get; set; } + public DateTime? RequestedOn { get; set; } + public DateTime? NeededOn { get; set; } + public DateTime? FilledOn { get; set; } + public DateTime? MobilizedOn { get; set; } + public DateTime? CheckedInOn { get; set; } + public DateTime? AssignedOn { get; set; } + public DateTime? ReleasedOn { get; set; } + public DateTime? DemobilizedOn { get; set; } + public DateTime? ReturnedOn { get; set; } + public int? CapturedOffsetMinutes { get; set; } + public string Notes { 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; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsExternalOrderFillId; } + set { RmsExternalOrderFillId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsExternalOrderFills"; + [NotMapped] public string IdName => "RmsExternalOrderFillId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + // ------------------------------------------------------------------------------------------------------ + // Service contracts + // ------------------------------------------------------------------------------------------------------ + + public class RecordDeploymentCreateInput + { + public string ProfileKey { get; set; } + public string HomeProfileKey { get; set; } + public string HostProfileKey { get; set; } + public string SourceScheme { get; set; } + public string SourceSystem { get; set; } + public string OrderNumber { get; set; } + public string IncidentName { get; set; } + public string IncidentNumber { get; set; } + public string IncidentCountry { get; set; } + public string IncidentSubdivision { get; set; } + public string OrderingOffice { get; set; } + public string DispatchOffice { get; set; } + public string RequestingAgency { get; set; } + public string ReceivingAgency { get; set; } + public string SendingAgency { get; set; } + public string DepartmentRole { get; set; } = "filling"; + public string CostCode { get; set; } + public string AgreementReference { get; set; } + public string CurrencyCode { get; set; } + public string MeasurementSystem { get; set; } + public string TimeZoneId { get; set; } + public int? CapturedOffsetMinutes { get; set; } + public DateTime? SourceCapturedOn { get; set; } + public string SourceVersion { get; set; } + public string ArtifactFileName { get; set; } + public string ArtifactContentType { get; set; } + public byte[] ArtifactData { get; set; } + public string ArtifactSafeUrl { get; set; } + public int? StationGroupId { get; set; } + public string IdempotencyKey { get; set; } + public RmsOriginClient OriginClient { get; set; } = RmsOriginClient.Web; + public List Fills { get; set; } = new List(); + } + + public class RecordDeploymentFillInput + { + public string RequestNumber { get; set; } + public string ParentRequestNumber { get; set; } + public string RequestCategory { get; set; } + public string FillNumber { get; set; } + public string ResourceKind { get; set; } + public string ResourceType { get; set; } + public string ResourceTypeScheme { get; set; } + public string Position { get; set; } + public string PositionScheme { get; set; } + public bool IsTrainee { get; set; } + public string HomeUnit { get; set; } + public string HostAgency { get; set; } + public string AgencyUnitId { get; set; } + public string PointOfHire { get; set; } + public string CostCode { get; set; } + public string AgreementReference { get; set; } + public string AssignedUserId { get; set; } + public int? AssignedUnitId { get; set; } + public DateTime? RequestedOn { get; set; } + public DateTime? NeededOn { get; set; } + public DateTime? FilledOn { get; set; } + public int? CapturedOffsetMinutes { get; set; } + public string Notes { get; set; } + } + + /// A lifecycle step on one fill (accept/decline/mobilize/check-in/assign/release/demobilize/return). + public class RecordDeploymentFillTransitionInput + { + public RmsDeploymentFillStatus Status { get; set; } + public DateTime? OccurredOn { get; set; } + public int? CapturedOffsetMinutes { get; set; } + public string Reason { get; set; } + public string Notes { get; set; } + public string RosterJson { get; set; } + public string TravelJson { get; set; } + } + + public class RecordDeploymentAggregate + { + public RmsExternalOrder Order { get; set; } + public List Fills { get; set; } = new List(); + public RecordAggregate Record { get; set; } + public RmsJurisdictionProfileVersion Profile { get; set; } + public RmsJurisdictionProfileVersion HomeProfile { get; set; } + public RmsJurisdictionProfileVersion HostProfile { get; set; } + public bool IsPreview => true; + /// A deployment is returned only when every accepted fill reached Returned; the external release flag alone never closes it. + public bool AllReturned => Fills.Where(f => f.Status != (int)RmsDeploymentFillStatus.Declined).All(f => f.Status == (int)RmsDeploymentFillStatus.Returned) && Fills.Any(); + } +} diff --git a/Core/Resgrid.Model/Records/RmsIncidentReport.cs b/Core/Resgrid.Model/Records/RmsIncidentReport.cs index 172aed8c..2752175d 100644 --- a/Core/Resgrid.Model/Records/RmsIncidentReport.cs +++ b/Core/Resgrid.Model/Records/RmsIncidentReport.cs @@ -432,6 +432,10 @@ public class RmsLocation : IEntity public string ProtectedLongitudeEnvelope { get; set; } public string Jurisdiction { get; set; } public int SourceKind { get; set; } + /// ADP marker (catalog v10, M0176). RmsLocations is bound with ProtectedMarkerColumn "IsProtected", + /// so the catalog-upgrade sweep reads these two columns to tell a sealed row from an unenrolled one. + 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; } diff --git a/Core/Resgrid.Model/Records/RmsProtectedFields.cs b/Core/Resgrid.Model/Records/RmsProtectedFields.cs index 6f52a701..59eeb0f8 100644 --- a/Core/Resgrid.Model/Records/RmsProtectedFields.cs +++ b/Core/Resgrid.Model/Records/RmsProtectedFields.cs @@ -168,6 +168,23 @@ private static string TableOf() where T : IEntity public static readonly string AttachmentDataFieldId = FieldId("RmsRecordAttachments", "Data"); + /// + /// Typed values of department definitions (catalog v11): one virtual field per row. The accessor packs the + /// typed sibling columns to seal and unpacks them to reveal, so the seam's generic text path needs nothing new. + /// A REDACTED sentinel leaves a sealed row exactly as stored (its siblings are already null), which is what + /// RecordTypedValuesService.Shape renders as the withheld cell. + /// + public static readonly IReadOnlyDictionary Get, Action Set)> Values = Map( + ("ProtectedEnvelope", + v => !string.IsNullOrEmpty(v.ProtectedEnvelope) ? v.ProtectedEnvelope : (v.ProtectionRequired ? RmsRecordValuePack.Pack(v) : null), + (v, text) => + { + if (ProtectedDataEnvelope.HasEnvelopePrefix(text)) { v.ProtectedEnvelope = text; RmsRecordValuePack.Clear(v); } + else if (text != null && text != ProtectedDataEnvelope.RedactionValue) { RmsRecordValuePack.Unpack(v, text); v.ProtectedEnvelope = null; } + })); + + public static readonly string ValueFieldId = FieldId("RmsRecordValues", "ProtectedEnvelope"); + /// The rendered export artifact (RmsExportRuns.Data) is a generated copy of record content. public static readonly string ExportRunDataFieldId = FieldId("RmsExportRuns", "Data"); @@ -196,6 +213,7 @@ public static IEnumerable AllFieldIds() foreach (var k in Attachments.Keys) yield return k; yield return AttachmentDataFieldId; yield return ExportRunDataFieldId; + foreach (var k in Values.Keys) yield return k; } } } diff --git a/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs b/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs new file mode 100644 index 00000000..eab6a534 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs @@ -0,0 +1,659 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// Who owns a Record definition (RMS plan section 4.1): locked product content or a department clone/draft. + public enum RmsDefinitionOwner + { + System = 1, + Department = 2 + } + + /// Definition version lifecycle: Draft -> Published -> Retired. Only an unused draft can be deleted. + public enum RmsDefinitionVersionState + { + Draft = 1, + Published = 2, + Retired = 3 + } + + /// + /// The controlled field catalog (RMS plan section 4.1). 1-18 ship in RMS-1B (the launch templates need them); + /// 19-24 are the RMS-1C additions the operational packs need. Values are stable and pinned by the client contract. + /// + public enum RmsFieldType + { + ShortText = 1, + LongText = 2, + Integer = 3, + Decimal = 4, + Boolean = 5, + Date = 6, + DateTime = 7, + Duration = 8, + SingleSelect = 9, + MultiSelect = 10, + Address = 11, + Person = 12, + Unit = 13, + Group = 14, + Contact = 15, + Attachment = 16, + Signature = 17, + ExternalReference = 18, + // RMS-1C + Currency = 19, + Quantity = 20, + CountrySubdivision = 21, + CallReference = 22, + InventoryReference = 23, + ChecklistWorkOrderReference = 24 + } + + /// Field classification (RMS plan section 5.9.2). Restricted gates on RecordRestricted_View; Protected is sealed under ADP once cataloged. + public enum RmsFieldClassification + { + Standard = 0, + Restricted = 1, + Protected = 2 + } + + /// Bounded rule operators (RMS plan section 4.1). Nothing here produces a value; rules only control visibility and requiredness. + public enum RmsRuleOperator + { + Equals = 1, + NotEquals = 2, + InSet = 3, + NotInSet = 4, + IsEmpty = 5, + IsNotEmpty = 6, + InRange = 7, + And = 20, + Or = 21 + } + + public enum RmsRuleEffect + { + /// The section/field is shown only while the condition holds. + Show = 1, + /// The field is required (at finalize) while the condition holds. + Require = 2 + } + + /// Stable definition identity (RMS plan section 5.2, registry M0158). One row per department definition key. + public class RmsRecordDefinition : IEntity + { + public string RmsRecordDefinitionId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + /// Stable key; department keys never use the reserved "system." prefix. + public string DefinitionKey { get; set; } + public int Owner { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string Description { get; set; } + /// Product template this definition was cloned from, when any (template lineage never mutates the clone). + public string TemplateKey { get; set; } + public int? TemplatePackVersion { get; set; } + /// Locked jurisdiction overlay applied at clone time (RMS-1C); null for the generic base. + public string JurisdictionProfileKey { get; set; } + /// Comma-separated subject/reference types a Record on this definition may link (call, unit, contact, person, checklist, workorder). + public string PermittedSubjectTypes { get; set; } + /// The version new Records start on; null until first publish. + public int? CurrentPublishedVersion { get; set; } + public int LatestVersion { get; set; } + public bool IsRetired { get; set; } + public DateTime? RetiredOn { get; set; } + public string RetiredByUserId { get; set; } + public string RetiredReason { 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; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordDefinitionId; } + set { RmsRecordDefinitionId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsRecordDefinitions"; + [NotMapped] public string IdName => "RmsRecordDefinitionId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// One immutable-once-published schema version (RMS plan section 5.2). The authored document lives in SchemaJson; + /// publishing freezes it, computes its checksum and capability floor, and materializes the section/field rows. + /// + public class RmsRecordDefinitionVersion : IEntity + { + public string RmsRecordDefinitionVersionId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsRecordDefinitionId { get; set; } + public string DefinitionKey { get; set; } + public int Version { get; set; } + public int State { get; set; } + public int LifecyclePreset { get; set; } + /// 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. + public string ApproverRoleIds { get; set; } + public int? ReviewDueHours { get; set; } + public int? ApproveDueHours { get; set; } + public bool RequireAuthorAttestation { get; set; } + /// Serialized . + public string NumberingJson { get; set; } + /// Retention years for Records on this version; null = class default; 0 = permanent. + public int? RetentionYears { get; set; } + /// Whole-definition classification floor (RmsFieldClassification); a field can be stricter, never looser. + public int Classification { get; set; } + /// Serialized . + public string SchemaJson { get; set; } + public string SchemaChecksum { get; set; } + /// Derived at publish from the field and rule types actually used (RMS plan section 5.4). + public string MinimumClientCapability { get; set; } + /// Serialized : which field apps may author on this version. + public string ClientSurfaceJson { get; set; } + /// Explicit field mapping from the previous published version (RecordDefinitionFieldMapping list), for draft migration. + public string MigrationMapJson { get; set; } + public string ChangeNotes { get; set; } + public DateTime? PublishedOn { get; set; } + public string PublishedByUserId { get; set; } + public DateTime? RetiredOn { get; set; } + public string RetiredByUserId { 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; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordDefinitionVersionId; } + set { RmsRecordDefinitionVersionId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsRecordDefinitionVersions"; + [NotMapped] public string IdName => "RmsRecordDefinitionVersionId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Schema", "Numbering", "ClientSurface" }; + + private RecordDefinitionSchema _schema; + [NotMapped] + [JsonIgnore] + public RecordDefinitionSchema Schema + { + get { return _schema ??= RecordDefinitionSchema.Parse(SchemaJson); } + set { _schema = value; SchemaJson = value == null ? null : RecordDefinitionSchema.Serialize(value); } + } + + [NotMapped] + [JsonIgnore] + public RecordDefinitionNumbering Numbering + { + get { return RecordDefinitionNumbering.Parse(NumberingJson); } + set { NumberingJson = value == null ? null : JsonConvert.SerializeObject(value); } + } + + [NotMapped] + [JsonIgnore] + public RecordDefinitionClientSurface ClientSurface + { + get { return RecordDefinitionClientSurface.Parse(ClientSurfaceJson); } + set { ClientSurfaceJson = value == null ? null : JsonConvert.SerializeObject(value); } + } + + public bool IsPublished => State == (int)RmsDefinitionVersionState.Published; + public bool IsDraft => State == (int)RmsDefinitionVersionState.Draft; + } + + /// Materialized at publish (RMS plan section 5.2): ordered section identity for one published version. + public class RmsRecordSectionDefinition : IEntity + { + public string RmsRecordSectionDefinitionId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsRecordDefinitionVersionId { get; set; } + public string DefinitionKey { get; set; } + public int DefinitionVersion { get; set; } + public string SectionKey { get; set; } + public string Label { get; set; } + public string Help { get; set; } + public int Ordinal { get; set; } + public bool IsRepeating { get; set; } + public int? MinRows { get; set; } + public int? MaxRows { get; set; } + public string RulesJson { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordSectionDefinitionId; } + set { RmsRecordSectionDefinitionId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsRecordSectionDefinitions"; + [NotMapped] public string IdName => "RmsRecordSectionDefinitionId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Materialized at publish: stable field identity, type, classification and capability flags for one published version. + public class RmsRecordFieldDefinition : IEntity + { + public string RmsRecordFieldDefinitionId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RmsRecordDefinitionVersionId { get; set; } + public string DefinitionKey { get; set; } + public int DefinitionVersion { get; set; } + public string SectionKey { get; set; } + public string FieldKey { get; set; } + public string Label { get; set; } + public int DataType { get; set; } + public int Ordinal { get; set; } + public bool Required { get; set; } + public bool RequiredToFinalize { get; set; } + public int Classification { get; set; } + public string ReferenceType { get; set; } + public bool Searchable { get; set; } + public bool Filterable { get; set; } + public bool Sortable { get; set; } + public bool Groupable { get; set; } + public bool Aggregatable { get; set; } + public bool WorkflowExposed { get; set; } + public bool Exportable { get; set; } + public string ConstraintsJson { get; set; } + public string RulesJson { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordFieldDefinitionId; } + set { RmsRecordFieldDefinitionId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsRecordFieldDefinitions"; + [NotMapped] public string IdName => "RmsRecordFieldDefinitionId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + // ------------------------------------------------------------------------------------------------------ + // Authored schema document (what the designer edits and what a published version freezes) + // ------------------------------------------------------------------------------------------------------ + + public class RecordDefinitionSchema + { + public const int CurrentSchemaVersion = 1; + public const int MaxSections = 40; + public const int MaxFieldsPerSection = 60; + public const int MaxOptions = 200; + public const int MaxRuleDepth = 6; + + public int SchemaVersion { get; set; } = CurrentSchemaVersion; + public List Sections { get; set; } = new List(); + + private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, Formatting = Formatting.None }; + + public static RecordDefinitionSchema Parse(string json) => string.IsNullOrWhiteSpace(json) ? new RecordDefinitionSchema() : JsonConvert.DeserializeObject(json, Settings) ?? new RecordDefinitionSchema(); + public static string Serialize(RecordDefinitionSchema schema) => JsonConvert.SerializeObject(schema ?? new RecordDefinitionSchema(), Settings); + + /// Canonical form: sections and fields ordered, keys trimmed and lower-cased. Checksums are taken over this. + public string Canonical() + { + var clone = JsonConvert.DeserializeObject(Serialize(this), Settings); + foreach (var section in clone.Sections) + { + section.Key = RecordDefinitionKeys.NormalizeKey(section.Key); + foreach (var field in section.Fields) + field.Key = RecordDefinitionKeys.NormalizeKey(field.Key); + } + return JsonConvert.SerializeObject(clone, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, Formatting = Formatting.None, ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver { NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy() } }); + } + + public IEnumerable AllFields() => Sections.SelectMany(s => s.Fields); + + public RecordFieldSchema FindField(string fieldKey) => AllFields().FirstOrDefault(f => string.Equals(f.Key, fieldKey, StringComparison.OrdinalIgnoreCase)); + + public RecordSectionSchema FindSection(string sectionKey) => Sections.FirstOrDefault(s => string.Equals(s.Key, sectionKey, StringComparison.OrdinalIgnoreCase)); + + public RecordSectionSchema SectionOf(string fieldKey) => Sections.FirstOrDefault(s => s.Fields.Any(f => string.Equals(f.Key, fieldKey, StringComparison.OrdinalIgnoreCase))); + } + + public class RecordSectionSchema + { + public string Key { get; set; } + public string Label { get; set; } + public string Help { get; set; } + public bool Repeating { get; set; } + public int? MinRows { get; set; } + public int? MaxRows { get; set; } + /// Visibility rules (Show) over fields of the same version. + public List Rules { get; set; } = new List(); + public List Fields { get; set; } = new List(); + } + + public class RecordFieldSchema + { + public string Key { get; set; } + public string Label { get; set; } + public string Help { get; set; } + public RmsFieldType Type { get; set; } + /// Required whenever visible (checked at finalize; drafts may be incomplete). + public bool Required { get; set; } + public bool RequiredToFinalize { get; set; } + public RmsFieldClassification Classification { get; set; } + public List Options { get; set; } = new List(); + public decimal? Min { get; set; } + public decimal? Max { get; set; } + public int? MaxLength { get; set; } + /// Reference/attachment target (user, unit, group, contact, attachment, call, inventory-item, checklist, workorder) or the external scheme. + public string ReferenceType { get; set; } + /// Quantity fields: the unit family (length, volume, area, temperature, mass) that bounds the accepted units. + public string UnitFamily { get; set; } + /// Quantity fields: the unit the value is canonicalized to for comparison; Decimal fields: a display-only unit label. + public string DefaultUnit { get; set; } + public string FixedUnitLabel { get; set; } + public string DefaultCurrency { get; set; } + public bool Searchable { get; set; } + public bool Filterable { get; set; } + public bool Sortable { get; set; } + public bool Groupable { get; set; } + public bool Aggregatable { get; set; } + public bool WorkflowExposed { get; set; } + public bool Exportable { get; set; } = true; + public List Rules { get; set; } = new List(); + } + + public class RecordOptionSchema + { + public string Key { get; set; } + public string Label { get; set; } + /// Locale overrides supplied by a jurisdiction overlay (RMS-1C): "fr-CA" -> label. + public Dictionary Labels { get; set; } + } + + public class RecordRuleSchema + { + public RmsRuleEffect Effect { get; set; } + public RecordConditionSchema Condition { get; set; } + } + + public class RecordConditionSchema + { + public RmsRuleOperator Operator { get; set; } + public string FieldKey { get; set; } + public string Value { get; set; } + public List Values { get; set; } + public decimal? Min { get; set; } + public decimal? Max { get; set; } + public DateTime? MinDate { get; set; } + public DateTime? MaxDate { get; set; } + public List Conditions { get; set; } + + public IEnumerable ReferencedFieldKeys() + { + if (!string.IsNullOrWhiteSpace(FieldKey)) + yield return FieldKey; + foreach (var child in Conditions ?? new List()) + foreach (var key in child.ReferencedFieldKeys()) + yield return key; + } + } + + /// Definition-specific numbering (RMS plan section 4.1 "Numbering"). Server-enforced; clients never invent a number. + public class RecordDefinitionNumbering + { + public string Prefix { get; set; } + public RmsNumberAssignment Assignment { get; set; } = RmsNumberAssignment.OnFinalize; + public bool PerGroupSequence { get; set; } + public bool ResetYearly { get; set; } = true; + public int SequenceWidth { get; set; } = 4; + + public static RecordDefinitionNumbering Parse(string json) => string.IsNullOrWhiteSpace(json) ? new RecordDefinitionNumbering() : JsonConvert.DeserializeObject(json) ?? new RecordDefinitionNumbering(); + } + + /// Per-version eligibility for the field apps (RMS plan section 5.2 RmsDefinitionClientSurface), kept as a document on the version. + public class RecordDefinitionClientSurface + { + public bool Responder { get; set; } + public bool Unit { get; set; } + public bool IncidentCommand { get; set; } + public bool Dispatch { get; set; } + /// Launch contexts: call, unit, contact, checklist, workorder, none. + public List LaunchContexts { get; set; } = new List(); + public bool AllowOffline { get; set; } + public bool AllowAttachments { get; set; } = true; + public string MinimumAppVersion { get; set; } + + public static RecordDefinitionClientSurface Parse(string json) => string.IsNullOrWhiteSpace(json) ? new RecordDefinitionClientSurface() : JsonConvert.DeserializeObject(json) ?? new RecordDefinitionClientSurface(); + } + + public class RecordDefinitionFieldMapping + { + public string FromFieldKey { get; set; } + public string ToFieldKey { get; set; } + } + + /// Key rules shared by the designer, the packs and the value seam. + public static class RecordDefinitionKeys + { + public const int MaxKeyLength = 64; + public const string TemplatePrefix = "template."; + public const string PackPrefix = "pack."; + + public static string NormalizeKey(string key) => (key ?? string.Empty).Trim().ToLowerInvariant(); + + /// Lower-case letters, digits, dots and dashes; must start with a letter; no reserved prefix. + public static bool IsValidDefinitionKey(string key) + { + if (string.IsNullOrWhiteSpace(key) || key.Length > MaxKeyLength) return false; + if (RmsDefinitionKeys.IsSystemKey(key)) return false; + if (!char.IsLetter(key[0])) return false; + return key.All(c => char.IsLetterOrDigit(c) && !char.IsUpper(c) || c == '.' || c == '-' || c == '_'); + } + + /// Section/field keys: letters, digits, underscore and dash; start with a letter. + public static bool IsValidMemberKey(string key) + { + if (string.IsNullOrWhiteSpace(key) || key.Length > MaxKeyLength) return false; + if (!char.IsLetter(key[0])) return false; + return key.All(c => char.IsLetterOrDigit(c) && !char.IsUpper(c) || c == '_' || c == '-'); + } + + public static bool IsTemplateKey(string key) => key != null && (key.StartsWith(TemplatePrefix, StringComparison.Ordinal) || key.StartsWith(PackPrefix, StringComparison.Ordinal)); + } + + /// Client capability floors (RMS plan section 5.4). Derived from the types a version uses, never hand-entered. + public static class RecordsClientCapabilities + { + /// Locked Logs-parity definitions only. + public const string Locked = "records.v1"; + /// RMS-1B controlled catalog: the 18 launch field types, repeating groups and the bounded rules. + public const string Configurable = "records.v1b"; + /// RMS-1C additions: currency, measured quantity, country/subdivision, Call/Inventory/Checklist references. + public const string Packs = "records.v1c"; + + private static readonly string[] Order = { Locked, Configurable, Packs }; + + public static int Rank(string capability) + { + var index = Array.IndexOf(Order, capability ?? string.Empty); + return index < 0 ? -1 : index; + } + + /// True when a client reporting can author on a version needing . + public static bool Satisfies(string clientCapability, string required) => Rank(clientCapability) >= Rank(required) && Rank(required) >= 0; + + public static bool IsPackType(RmsFieldType type) => (int)type >= (int)RmsFieldType.Currency; + + public static string Derive(RecordDefinitionSchema schema) + { + if (schema == null) return Configurable; + return schema.AllFields().Any(f => IsPackType(f.Type)) ? Packs : Configurable; + } + } + + // ------------------------------------------------------------------------------------------------------ + // Service contracts + // ------------------------------------------------------------------------------------------------------ + + public class RecordDefinitionIssue + { + public string Severity { get; set; } = "error"; + public string Path { get; set; } + public string Code { get; set; } + public string Message { get; set; } + + public static RecordDefinitionIssue Error(string path, string code, string message) => new RecordDefinitionIssue { Severity = "error", Path = path, Code = code, Message = message }; + public static RecordDefinitionIssue Warning(string path, string code, string message) => new RecordDefinitionIssue { Severity = "warning", Path = path, Code = code, Message = message }; + } + + public class RecordDefinitionValidation + { + public List Issues { get; set; } = new List(); + public string MinimumClientCapability { get; set; } + public bool IsValid => Issues.All(i => i.Severity != "error"); + } + + public class RecordDefinitionDraftInput + { + public string Name { get; set; } + public string Category { get; set; } + public string Description { get; set; } + public string PermittedSubjectTypes { get; set; } + public RmsLifecyclePreset LifecyclePreset { get; set; } = RmsLifecyclePreset.QuickEntry; + public List ReviewerRoleIds { get; set; } = new List(); + public List ApproverRoleIds { get; set; } = new List(); + public int? ReviewDueHours { get; set; } + public int? ApproveDueHours { get; set; } + public bool RequireAuthorAttestation { get; set; } + public RecordDefinitionNumbering Numbering { get; set; } = new RecordDefinitionNumbering(); + public int? RetentionYears { get; set; } + public RmsFieldClassification Classification { get; set; } + public RecordDefinitionSchema Schema { get; set; } = new RecordDefinitionSchema(); + public RecordDefinitionClientSurface ClientSurface { get; set; } = new RecordDefinitionClientSurface(); + public List MigrationMap { get; set; } = new List(); + public string ChangeNotes { get; set; } + } + + public class RecordDefinitionCreateInput + { + public string DefinitionKey { get; set; } + public string Name { get; set; } + public string Category { get; set; } + /// Product template/pack definition key to clone (template.* or pack.*); exclusive with CloneFromDefinitionKey. + public string TemplateKey { get; set; } + /// Existing department definition to clone as a new definition. + public string CloneFromDefinitionKey { get; set; } + /// Jurisdiction overlay to apply when cloning a pack (RMS-1C): generic, us, ca. + public string JurisdictionProfileKey { get; set; } + /// Locale for overlay labels (en-US, en-CA, fr-CA). + public string Locale { get; set; } + } + + /// One department definition with its versions, for lists and the designer. + public class RecordDefinitionAggregate + { + public RmsRecordDefinition Definition { get; set; } + public List Versions { get; set; } = new List(); + public RmsRecordDefinitionVersion Published => Versions.FirstOrDefault(v => v.IsPublished && Definition?.CurrentPublishedVersion == v.Version) ?? Versions.Where(v => v.IsPublished).OrderByDescending(v => v.Version).FirstOrDefault(); + public RmsRecordDefinitionVersion Draft => Versions.Where(v => v.IsDraft).OrderByDescending(v => v.Version).FirstOrDefault(); + public RmsRecordDefinitionVersion Latest => Versions.OrderByDescending(v => v.Version).FirstOrDefault(); + } + + public class RecordDefinitionImpactPreview + { + public string DefinitionKey { get; set; } + public int Version { get; set; } + public string MinimumClientCapability { get; set; } + public List FieldTypesUsed { get; set; } = new List(); + public bool UsesRepeatingGroups { get; set; } + public int? CurrentPublishedVersion { get; set; } + /// Draft Records on the currently published version that a mapping could migrate. + public int OpenDraftsOnCurrentVersion { get; set; } + /// Finalized Records on earlier versions; they never migrate and keep rendering against their version. + public int FinalizedRecordsOnEarlierVersions { get; set; } + /// Field apps enabled for this department that cannot render this version (from the client surface and capability floor). + public List Clients { get; set; } = new List(); + public List Issues { get; set; } = new List(); + public bool BreakingChange { get; set; } + } + + public class RecordDefinitionClientImpact + { + public string App { get; set; } + public bool Enabled { get; set; } + public bool EligibleOnSurface { get; set; } + public string RequiredCapability { get; set; } + /// Active clients of this app below the floor; null when the platform has no telemetry for it. + public int? ClientsBelowFloor { get; set; } + public string Message { get; set; } + } + + public class RecordDefinitionDiff + { + public string DefinitionKey { get; set; } + public int FromVersion { get; set; } + public int ToVersion { get; set; } + public List Entries { get; set; } = new List(); + public bool Breaking => Entries.Any(e => e.Breaking); + } + + public class RecordDefinitionDiffEntry + { + public string Kind { get; set; } // section | field | policy + public string Change { get; set; } // added | removed | changed + public string Key { get; set; } + public string Detail { get; set; } + public bool Breaking { get; set; } + } + + public class RecordDefinitionMigrationResult + { + public int Migrated { get; set; } + public int Skipped { get; set; } + public List SkippedRecordIds { get; set; } = new List(); + public List UnmappedFieldKeys { get; set; } = new List(); + } + + /// What the client contract advertises for one definition (locked or department), RMS plan section 5.4. + public class RecordDefinitionSummary + { + public string Key { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string Owner { get; set; } + public bool Locked { get; set; } + public int? PublishedVersion { get; set; } + public int? DraftVersion { get; set; } + public bool Retired { get; set; } + public string LifecyclePreset { get; set; } + public string MinimumClientCapability { get; set; } + public string TemplateKey { get; set; } + public string JurisdictionProfileKey { get; set; } + public string ArtifactStatus { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RmsRecordPrintLayout.cs b/Core/Resgrid.Model/Records/RmsRecordPrintLayout.cs index 9237d40d..23a6004a 100644 --- a/Core/Resgrid.Model/Records/RmsRecordPrintLayout.cs +++ b/Core/Resgrid.Model/Records/RmsRecordPrintLayout.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -45,6 +46,64 @@ public static string NormalizePageSize(string value) /// Versioned print layout row (migration M0160). Only the DepartmentDefault scope is written in RMS-1. [Table("RmsRecordPrintLayouts")] + /// + /// The Definition-scope print layout (RMS plan section 4.10.1): presentation over a department definition's approved + /// sections and fields — order, visibility, headings, page breaks, signature-block placement, attachment-list style + /// and an optional branding-block override. Presentation only: a hidden-by-layout field is still exported by data + /// exports per its flags, and no layout bypasses restricted/protected/group rules or the provenance footer. + /// + public class RecordsDefinitionLayoutConfig + { + public const string SignatureAtEnd = "end"; + public const string SignatureInline = "inline"; + public const string SignatureNone = "none"; + public static readonly string[] SignaturePlacements = { SignatureAtEnd, SignatureInline, SignatureNone }; + + public const string AttachmentsTable = "table"; + public const string AttachmentsList = "list"; + public const string AttachmentsNone = "none"; + public static readonly string[] AttachmentStyles = { AttachmentsTable, AttachmentsList, AttachmentsNone }; + + /// Null applies to every version of the definition; otherwise only Records pinned to this version use it. + public int? AppliesToVersion { get; set; } + public List SectionOrder { get; set; } = new List(); + public List HiddenSectionKeys { get; set; } = new List(); + public List HiddenFieldKeys { get; set; } = new List(); + public Dictionary SectionHeadings { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public List PageBreakBeforeSectionKeys { get; set; } = new List(); + public string SignatureBlockPlacement { get; set; } = SignatureAtEnd; + public string AttachmentListStyle { get; set; } = AttachmentsTable; + /// Null keeps the department default branding block; otherwise these values replace it for this definition. + public RecordsPrintLayoutConfig BrandingOverrides { get; set; } + + public static RecordsDefinitionLayoutConfig Default() => new RecordsDefinitionLayoutConfig(); + + public bool AppliesTo(int definitionVersion) => !AppliesToVersion.HasValue || AppliesToVersion.Value == definitionVersion; + public bool IsSectionVisible(string sectionKey) => !HiddenSectionKeys.Contains(sectionKey ?? string.Empty, StringComparer.OrdinalIgnoreCase); + public bool IsFieldVisible(string fieldKey) => !HiddenFieldKeys.Contains(fieldKey ?? string.Empty, StringComparer.OrdinalIgnoreCase); + public bool PageBreakBefore(string sectionKey) => PageBreakBeforeSectionKeys.Contains(sectionKey ?? string.Empty, StringComparer.OrdinalIgnoreCase); + public string HeadingFor(string sectionKey, string fallback) => SectionHeadings.TryGetValue(sectionKey ?? string.Empty, out var heading) && !string.IsNullOrWhiteSpace(heading) ? heading : fallback; + + /// Section keys in layout order: listed keys first in their order, then any the layout does not mention, hidden ones dropped. + public List OrderedSectionKeys(IEnumerable schemaSectionKeys) + { + var all = (schemaSectionKeys ?? Enumerable.Empty()).ToList(); + var ordered = SectionOrder.Where(k => all.Contains(k, StringComparer.OrdinalIgnoreCase)).ToList(); + ordered.AddRange(all.Where(k => !ordered.Contains(k, StringComparer.OrdinalIgnoreCase))); + return ordered.Where(IsSectionVisible).ToList(); + } + } + + /// What print resolves for one Record: the branding block, the definition layout (if any) and the composite layout version stamped on the footer. + public class RecordsResolvedPrintLayout + { + public RecordsPrintLayoutConfig Branding { get; set; } = RecordsPrintLayoutConfig.Default(); + public string BrandingLayoutVersion { get; set; } = RmsRecordPrintLayout.GeneratedLayoutVersion; + public RecordsDefinitionLayoutConfig Definition { get; set; } + public string DefinitionLayoutVersion { get; set; } + public string LayoutVersion => Definition == null ? BrandingLayoutVersion : DefinitionLayoutVersion + "+" + BrandingLayoutVersion; + } + public class RmsRecordPrintLayout : IEntity { public const string GeneratedLayoutVersion = "system-default/1"; @@ -85,6 +144,10 @@ public class RmsRecordPrintLayout : IEntity [NotMapped] public RecordsPrintLayoutConfig Config { get; set; } + /// Parsed Definition-scope config; null on a DepartmentDefault row. + [NotMapped] + public RecordsDefinitionLayoutConfig DefinitionConfig { get; set; } + [NotMapped] public object IdValue { @@ -102,6 +165,6 @@ public object IdValue public int IdType => 1; [NotMapped] - public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "LayoutVersion", "Config" }; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "LayoutVersion", "Config", "DefinitionConfig" }; } } diff --git a/Core/Resgrid.Model/Records/RmsRecordValues.cs b/Core/Resgrid.Model/Records/RmsRecordValues.cs new file mode 100644 index 00000000..ce421b1f --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsRecordValues.cs @@ -0,0 +1,499 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Globalization; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// Stable row identity for one repeating-section row of a draft or revision (RMS plan section 5.2, registry M0159). + public class RmsRecordValueGroup : IEntity + { + public string RmsRecordValueGroupId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RecordId { get; set; } + public int RecordKind { get; set; } + /// Null for the working draft; the revision id once copied into an immutable revision. + public string RevisionId { get; set; } + public string RmsRecordDefinitionVersionId { get; set; } + public string SectionKey { get; set; } + public int Ordinal { get; set; } + /// Client-supplied row key kept so autosave round-trips keep row identity across saves. + public string ClientRowKey { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordValueGroupId; } + set { RmsRecordValueGroupId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsRecordValueGroups"; + [NotMapped] public string IdName => "RmsRecordValueGroupId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// One typed value (RMS plan section 5.3): one row per scalar, per repeating-group cell, per selected multi-select + /// option. Exactly one scalar/reference column group is populated per row; the service guard and the per-dialect + /// check constraint both enforce it. Locked system definitions never use this table. + /// + public class RmsRecordValue : IEntity + { + public string RmsRecordValueId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RecordId { get; set; } + public int RecordKind { get; set; } + public string RevisionId { get; set; } + public string RmsRecordDefinitionVersionId { get; set; } + public string FieldKey { get; set; } + public string RmsRecordValueGroupId { get; set; } + public int Ordinal { get; set; } + /// Mirrors RmsRecordFieldDefinition.DataType; a mismatch against the pinned version is rejected. + public int ValueType { get; set; } + public string TextValue { get; set; } + public string LongTextValue { get; set; } + public decimal? NumberValue { get; set; } + public bool? BoolValue { get; set; } + public DateTime? DateTimeValue { get; set; } + public int? DateTimeOffsetMinutes { get; set; } + public long? DurationSeconds { get; set; } + public string UnitCode { get; set; } + public decimal? CanonicalNumberValue { get; set; } + public string CanonicalUnitCode { get; set; } + public string CurrencyCode { get; set; } + public string ReferenceType { get; set; } + public string ReferenceId { get; set; } + /// Bounded, server-authored display snapshot of a reference; never client-authored, never a second source of truth. + public string ReferenceSnapshotJson { get; set; } + public string OptionKey { get; set; } + public bool IsProtected { get; set; } + public string ProtectedEnvelope { get; set; } + public int ProtectedCatalogVersion { get; set; } + /// True when the field is Protected-classified in its definition version: the ADP seam seals this row (catalog v11) and the sweep targets it. + public bool ProtectionRequired { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsRecordValueId; } + set { RmsRecordValueId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsRecordValues"; + [NotMapped] public string IdName => "RmsRecordValueId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + + /// Sealed under ADP (catalog v11): the envelope holds the packed typed columns and the siblings are null. + [NotMapped] + [JsonIgnore] + public bool IsSealed => ProtectedDataEnvelope.HasEnvelopePrefix(ProtectedEnvelope); + + /// The service-layer half of the "exactly one column group" rule. + public int PopulatedColumnGroups() + { + var count = 0; + if (TextValue != null) count++; + if (LongTextValue != null) count++; + if (NumberValue.HasValue) count++; + if (BoolValue.HasValue) count++; + if (DateTimeValue.HasValue) count++; + if (DurationSeconds.HasValue) count++; + if (ReferenceId != null) count++; + if (OptionKey != null) count++; + return count; + } + } + + // ------------------------------------------------------------------------------------------------------ + // Input and hydrated forms + // ------------------------------------------------------------------------------------------------------ + + /// One posted value. Everything is a string on the wire; the server parses against the pinned field type. + /// + /// The packed representation of a typed value row under ADP (catalog v11): the sibling typed columns as one JSON + /// document, sealed into RmsRecordValues.ProtectedEnvelope while the siblings are null. Shared by the write/read seam + /// (RmsProtectedFields.Values) and the migration engine's PackedJson column kind so both agree byte for byte. + /// + public static class RmsRecordValuePack + { + public static readonly IReadOnlyList CarrierColumns = new[] + { + "TextValue", "LongTextValue", "NumberValue", "BoolValue", "DateTimeValue", "DateTimeOffsetMinutes", "DurationSeconds", + "UnitCode", "CanonicalNumberValue", "CanonicalUnitCode", "CurrencyCode", "ReferenceType", "ReferenceId", "ReferenceSnapshotJson", "OptionKey" + }; + + public static IReadOnlyDictionary Columns(RmsRecordValue row) => new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["TextValue"] = row.TextValue, ["LongTextValue"] = row.LongTextValue, ["NumberValue"] = row.NumberValue, ["BoolValue"] = row.BoolValue, + ["DateTimeValue"] = row.DateTimeValue, ["DateTimeOffsetMinutes"] = row.DateTimeOffsetMinutes, ["DurationSeconds"] = row.DurationSeconds, + ["UnitCode"] = row.UnitCode, ["CanonicalNumberValue"] = row.CanonicalNumberValue, ["CanonicalUnitCode"] = row.CanonicalUnitCode, + ["CurrencyCode"] = row.CurrencyCode, ["ReferenceType"] = row.ReferenceType, ["ReferenceId"] = row.ReferenceId, + ["ReferenceSnapshotJson"] = row.ReferenceSnapshotJson, ["OptionKey"] = row.OptionKey + }; + + /// The row's typed columns as JSON (null when nothing is populated). Numbers and dates are strings in invariant form so scale and kind survive. + public static string Pack(RmsRecordValue row) => row == null ? null : PackColumns(Columns(row)); + + public static string PackColumns(IReadOnlyDictionary columns) + { + if (columns == null) return null; + var packed = new Dictionary(StringComparer.Ordinal); + foreach (var column in CarrierColumns) + { + var key = columns.Keys.FirstOrDefault(k => string.Equals(k, column, StringComparison.OrdinalIgnoreCase)); + var value = key == null ? null : columns[key]; + if (value == null || value is DBNull) continue; + packed[column] = value switch + { + decimal d => d.ToString(CultureInfo.InvariantCulture), + double d => ((decimal)d).ToString(CultureInfo.InvariantCulture), + float f => ((decimal)f).ToString(CultureInfo.InvariantCulture), + bool b => b ? "true" : "false", + DateTime dt => DateTime.SpecifyKind(dt, DateTimeKind.Utc).ToString("O", CultureInfo.InvariantCulture), + DateTimeOffset dto => dto.UtcDateTime.ToString("O", CultureInfo.InvariantCulture), + int i => i.ToString(CultureInfo.InvariantCulture), + long l => l.ToString(CultureInfo.InvariantCulture), + short sh => sh.ToString(CultureInfo.InvariantCulture), + _ => value.ToString() + }; + } + return packed.Count == 0 ? null : JsonConvert.SerializeObject(packed); + } + + /// Typed CLR values per carrier column (null for anything the pack does not carry), ready for an entity or a Dapper update. + public static Dictionary UnpackColumns(string json) + { + var packed = string.IsNullOrWhiteSpace(json) ? new Dictionary() : JsonConvert.DeserializeObject>(json) ?? new Dictionary(); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var column in CarrierColumns) + { + packed.TryGetValue(column, out var text); + result[column] = text == null ? null : column switch + { + "NumberValue" or "CanonicalNumberValue" => decimal.Parse(text, NumberStyles.Number, CultureInfo.InvariantCulture), + "BoolValue" => (object)(text == "true"), + "DateTimeValue" => DateTime.SpecifyKind(DateTime.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal), DateTimeKind.Utc), + "DateTimeOffsetMinutes" => int.Parse(text, CultureInfo.InvariantCulture), + "DurationSeconds" => long.Parse(text, CultureInfo.InvariantCulture), + _ => text + }; + } + return result; + } + + public static void Unpack(RmsRecordValue row, string json) + { + if (row == null) return; + var columns = UnpackColumns(json); + row.TextValue = (string)columns["TextValue"]; row.LongTextValue = (string)columns["LongTextValue"]; + row.NumberValue = (decimal?)columns["NumberValue"]; row.BoolValue = (bool?)columns["BoolValue"]; + row.DateTimeValue = (DateTime?)columns["DateTimeValue"]; row.DateTimeOffsetMinutes = (int?)columns["DateTimeOffsetMinutes"]; + row.DurationSeconds = (long?)columns["DurationSeconds"]; row.UnitCode = (string)columns["UnitCode"]; + row.CanonicalNumberValue = (decimal?)columns["CanonicalNumberValue"]; row.CanonicalUnitCode = (string)columns["CanonicalUnitCode"]; + row.CurrencyCode = (string)columns["CurrencyCode"]; row.ReferenceType = (string)columns["ReferenceType"]; row.ReferenceId = (string)columns["ReferenceId"]; + row.ReferenceSnapshotJson = (string)columns["ReferenceSnapshotJson"]; row.OptionKey = (string)columns["OptionKey"]; + } + + public static void Clear(RmsRecordValue row) + { + if (row == null) return; + row.TextValue = null; row.LongTextValue = null; row.NumberValue = null; row.BoolValue = null; row.DateTimeValue = null; row.DateTimeOffsetMinutes = null; + row.DurationSeconds = null; row.UnitCode = null; row.CanonicalNumberValue = null; row.CanonicalUnitCode = null; row.CurrencyCode = null; + row.ReferenceType = null; row.ReferenceId = null; row.ReferenceSnapshotJson = null; row.OptionKey = null; + } + } + + public class RecordValueInput + { + public string SectionKey { get; set; } + public string FieldKey { get; set; } + /// Repeating sections: the client's row key (stable across autosaves) and the row's ordinal. + public string RowKey { get; set; } + public int Ordinal { get; set; } + public string Value { get; set; } + /// Multi-select option keys. + public List Values { get; set; } + public string ReferenceType { get; set; } + public string ReferenceId { get; set; } + public string UnitCode { get; set; } + public string CurrencyCode { get; set; } + public int? OffsetMinutes { get; set; } + } + + /// A hydrated cell: the stored row(s) for one field in one row, plus the display text rendered against the pinned schema. + public class RecordValueCell + { + public string SectionKey { get; set; } + public string FieldKey { get; set; } + public string Label { get; set; } + public RmsFieldType Type { get; set; } + public RmsFieldClassification Classification { get; set; } + public string GroupId { get; set; } + public string RowKey { get; set; } + public int Ordinal { get; set; } + /// Display text (never markup). Null when nothing is stored, "REDACTED" when withheld. + public string Display { get; set; } + /// Wire form the client can post back unchanged (RecordValueInput.Value). + public string Value { get; set; } + public List Values { get; set; } + public string ReferenceType { get; set; } + public string ReferenceId { get; set; } + public string UnitCode { get; set; } + public string CurrencyCode { get; set; } + public int? OffsetMinutes { get; set; } + public decimal? Number { get; set; } + public decimal? CanonicalNumber { get; set; } + public string CanonicalUnitCode { get; set; } + public bool Withheld { get; set; } + } + + public class RecordValueRow + { + public string GroupId { get; set; } + public string RowKey { get; set; } + public int Ordinal { get; set; } + public List Cells { get; set; } = new List(); + public RecordValueCell Cell(string fieldKey) => Cells.FirstOrDefault(c => string.Equals(c.FieldKey, fieldKey, StringComparison.OrdinalIgnoreCase)); + } + + public class RecordValueSectionSet + { + public string SectionKey { get; set; } + public string Label { get; set; } + public bool Repeating { get; set; } + public List Rows { get; set; } = new List(); + } + + /// Everything stored for one Record (draft or revision), shaped by the pinned definition version. + public class RecordValueSet + { + public string DefinitionKey { get; set; } + public int DefinitionVersion { get; set; } + public string DefinitionVersionId { get; set; } + public List Sections { get; set; } = new List(); + public List WithheldFieldKeys { get; set; } = new List(); + + public RecordValueCell Scalar(string fieldKey) => Sections.Where(s => !s.Repeating).SelectMany(s => s.Rows).SelectMany(r => r.Cells).FirstOrDefault(c => string.Equals(c.FieldKey, fieldKey, StringComparison.OrdinalIgnoreCase)); + public RecordValueSectionSet Section(string sectionKey) => Sections.FirstOrDefault(s => string.Equals(s.SectionKey, sectionKey, StringComparison.OrdinalIgnoreCase)); + public IEnumerable AllCells() => Sections.SelectMany(s => s.Rows).SelectMany(r => r.Cells); + public bool IsEmpty => !AllCells().Any(c => c.Display != null); + + /// Round-trips the set as inputs (what a client posts back on the next save). + public List ToInputs() + { + var inputs = new List(); + foreach (var section in Sections) + foreach (var row in section.Rows) + foreach (var cell in row.Cells.Where(c => !c.Withheld && (c.Value != null || c.Values != null || c.ReferenceId != null))) + inputs.Add(new RecordValueInput + { + SectionKey = section.SectionKey, FieldKey = cell.FieldKey, RowKey = section.Repeating ? row.RowKey ?? row.GroupId : null, Ordinal = row.Ordinal, + Value = cell.Value, Values = cell.Values, ReferenceType = cell.ReferenceType, ReferenceId = cell.ReferenceId, UnitCode = cell.UnitCode, CurrencyCode = cell.CurrencyCode, OffsetMinutes = cell.OffsetMinutes + }); + return inputs; + } + } + + public class RecordValueIssue + { + public string Severity { get; set; } = "error"; + public string SectionKey { get; set; } + public string FieldKey { get; set; } + public string RowKey { get; set; } + public string Code { get; set; } + public string Message { get; set; } + } + + public class RecordValueValidation + { + public List Issues { get; set; } = new List(); + public bool IsValid => Issues.All(i => i.Severity != "error"); + } + + /// The outcome of evaluating a version's rules over a value set: what is visible, what is required. + public class RecordRuleEvaluation + { + public HashSet HiddenSectionKeys { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + /// Fields hidden regardless of row: scalar fields, and repeating-section fields whose rules only look at scalars. + public HashSet HiddenFieldKeys { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + public HashSet RequiredFieldKeys { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + /// Per-row outcomes for repeating sections (a rule may look at its own row); keyed "section|rowKey". + public Dictionary Rows { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public static string RowKey(string sectionKey, string rowKey) => (sectionKey ?? string.Empty) + "|" + (rowKey ?? string.Empty); + + public RecordRowRuleEvaluation Row(string sectionKey, string rowKey) + { + var key = RowKey(sectionKey, rowKey); + if (!Rows.TryGetValue(key, out var row)) Rows[key] = row = new RecordRowRuleEvaluation { SectionKey = sectionKey, RowKey = rowKey }; + return row; + } + + public bool IsHidden(string sectionKey, string rowKey, string fieldKey) => + HiddenSectionKeys.Contains(sectionKey ?? string.Empty) || HiddenFieldKeys.Contains(fieldKey) + || rowKey != null && Rows.TryGetValue(RowKey(sectionKey, rowKey), out var row) && row.HiddenFieldKeys.Contains(fieldKey); + + public bool IsRequired(string sectionKey, string rowKey, string fieldKey) => + !IsHidden(sectionKey, rowKey, fieldKey) && (RequiredFieldKeys.Contains(fieldKey) + || rowKey != null && Rows.TryGetValue(RowKey(sectionKey, rowKey), out var row) && row.RequiredFieldKeys.Contains(fieldKey)); + } + + public class RecordRowRuleEvaluation + { + public string SectionKey { get; set; } + public string RowKey { get; set; } + public HashSet HiddenFieldKeys { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + public HashSet RequiredFieldKeys { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + // ------------------------------------------------------------------------------------------------------ + // Units, currencies, countries (RMS-1C, decision 22: originals round-trip, canonical values derive) + // ------------------------------------------------------------------------------------------------------ + + public sealed class RmsUnit + { + public RmsUnit(string code, string family, string label, string system, decimal toCanonical, decimal offset = 0m) + { + Code = code; Family = family; Label = label; System = system; ToCanonical = toCanonical; Offset = offset; + } + + public string Code { get; } + public string Family { get; } + public string Label { get; } + /// metric | customary | both + public string System { get; } + /// Multiplier into the family's canonical unit (after the offset for temperature). + public decimal ToCanonical { get; } + public decimal Offset { get; } + } + + /// + /// Bounded unit catalog. Canonical units are metric (m, L, ha, kg, C); U.S. customary values are stored as entered + /// with their unit and a derived canonical value for comparison, never silently converted (RMS plan section 4.1). + /// + public static class RmsUnits + { + public static readonly IReadOnlyList All = new List + { + new RmsUnit("m", "length", "metres", "metric", 1m), + new RmsUnit("km", "length", "kilometres", "metric", 1000m), + new RmsUnit("ft", "length", "feet", "customary", 0.3048m), + new RmsUnit("mi", "length", "miles", "customary", 1609.344m), + new RmsUnit("L", "volume", "litres", "metric", 1m), + new RmsUnit("gal", "volume", "U.S. gallons", "customary", 3.785411784m), + new RmsUnit("ha", "area", "hectares", "metric", 1m), + new RmsUnit("ac", "area", "acres", "customary", 0.40468564224m), + new RmsUnit("kg", "mass", "kilograms", "metric", 1m), + new RmsUnit("lb", "mass", "pounds", "customary", 0.45359237m), + new RmsUnit("C", "temperature", "degrees Celsius", "metric", 1m), + new RmsUnit("F", "temperature", "degrees Fahrenheit", "customary", 5m / 9m, -32m), + new RmsUnit("h", "time", "hours", "both", 60m), + new RmsUnit("min", "time", "minutes", "both", 1m), + new RmsUnit("count", "count", "count", "both", 1m) + }; + + public static readonly IReadOnlyDictionary CanonicalByFamily = new Dictionary(StringComparer.Ordinal) + { + ["length"] = "m", ["volume"] = "L", ["area"] = "ha", ["mass"] = "kg", ["temperature"] = "C", ["time"] = "min", ["count"] = "count" + }; + + public static RmsUnit Find(string code) => All.FirstOrDefault(u => string.Equals(u.Code, code, StringComparison.Ordinal)); + + public static IEnumerable ForFamily(string family) => All.Where(u => string.Equals(u.Family, family, StringComparison.OrdinalIgnoreCase)); + + /// Converts into the family's canonical unit; null when the unit is unknown. + public static (decimal Value, string Unit)? Canonicalize(decimal value, string unitCode) + { + var unit = Find(unitCode); + if (unit == null) return null; + var canonical = CanonicalByFamily[unit.Family]; + if (unit.Code == canonical) return (value, canonical); + return (decimal.Round((value + unit.Offset) * unit.ToCanonical, 6), canonical); + } + + /// Converts a canonical value back into (display in the reader's system). + public static decimal? FromCanonical(decimal canonicalValue, string unitCode) + { + var unit = Find(unitCode); + if (unit == null) return null; + var canonical = CanonicalByFamily[unit.Family]; + if (unit.Code == canonical) return canonicalValue; + return decimal.Round(canonicalValue / unit.ToCanonical - unit.Offset, 6); + } + + /// The unit a measurement system prefers for a family (customary for U.S., metric elsewhere). + public static string PreferredUnit(string family, string measurementSystem) + { + var customary = string.Equals(measurementSystem, "customary", StringComparison.OrdinalIgnoreCase); + var candidate = ForFamily(family).FirstOrDefault(u => customary ? u.System == "customary" : u.System == "metric"); + return candidate?.Code ?? (CanonicalByFamily.TryGetValue(family ?? string.Empty, out var c) ? c : null); + } + } + + public static class RmsCurrencies + { + /// ISO 4217 codes accepted at minimum (RMS plan section 4.1). No foreign-exchange conversion anywhere. + public static readonly IReadOnlyList Supported = new List { "USD", "CAD", "EUR", "GBP", "AUD", "MXN" }; + public static bool IsSupported(string code) => code != null && Supported.Contains(code.ToUpperInvariant()); + public static string Format(decimal amount, string code) => amount.ToString("N2", CultureInfo.InvariantCulture) + " " + (code ?? string.Empty).ToUpperInvariant(); + } + + /// ISO 3166-2 style country/subdivision codes for the U.S. and Canada (RMS-1C profile-aware addresses). + public static class RmsCountrySubdivisions + { + public static readonly IReadOnlyDictionary UnitedStates = new Dictionary(StringComparer.Ordinal) + { + ["AL"] = "Alabama", ["AK"] = "Alaska", ["AZ"] = "Arizona", ["AR"] = "Arkansas", ["CA"] = "California", ["CO"] = "Colorado", ["CT"] = "Connecticut", ["DE"] = "Delaware", + ["DC"] = "District of Columbia", ["FL"] = "Florida", ["GA"] = "Georgia", ["HI"] = "Hawaii", ["ID"] = "Idaho", ["IL"] = "Illinois", ["IN"] = "Indiana", ["IA"] = "Iowa", + ["KS"] = "Kansas", ["KY"] = "Kentucky", ["LA"] = "Louisiana", ["ME"] = "Maine", ["MD"] = "Maryland", ["MA"] = "Massachusetts", ["MI"] = "Michigan", ["MN"] = "Minnesota", + ["MS"] = "Mississippi", ["MO"] = "Missouri", ["MT"] = "Montana", ["NE"] = "Nebraska", ["NV"] = "Nevada", ["NH"] = "New Hampshire", ["NJ"] = "New Jersey", ["NM"] = "New Mexico", + ["NY"] = "New York", ["NC"] = "North Carolina", ["ND"] = "North Dakota", ["OH"] = "Ohio", ["OK"] = "Oklahoma", ["OR"] = "Oregon", ["PA"] = "Pennsylvania", ["RI"] = "Rhode Island", + ["SC"] = "South Carolina", ["SD"] = "South Dakota", ["TN"] = "Tennessee", ["TX"] = "Texas", ["UT"] = "Utah", ["VT"] = "Vermont", ["VA"] = "Virginia", ["WA"] = "Washington", + ["WV"] = "West Virginia", ["WI"] = "Wisconsin", ["WY"] = "Wyoming", ["PR"] = "Puerto Rico", ["GU"] = "Guam", ["VI"] = "U.S. Virgin Islands", ["AS"] = "American Samoa", ["MP"] = "Northern Mariana Islands" + }; + + public static readonly IReadOnlyDictionary Canada = new Dictionary(StringComparer.Ordinal) + { + ["AB"] = "Alberta", ["BC"] = "British Columbia", ["MB"] = "Manitoba", ["NB"] = "New Brunswick", ["NL"] = "Newfoundland and Labrador", ["NS"] = "Nova Scotia", + ["NT"] = "Northwest Territories", ["NU"] = "Nunavut", ["ON"] = "Ontario", ["PE"] = "Prince Edward Island", ["QC"] = "Quebec", ["SK"] = "Saskatchewan", ["YT"] = "Yukon" + }; + + public static readonly IReadOnlyDictionary> ByCountry = new Dictionary>(StringComparer.Ordinal) + { + ["US"] = UnitedStates, ["CA"] = Canada + }; + + /// "US-CA" -> valid; a country with no bounded list ("MX-...") is accepted as an opaque code of the form CC-XXX. + public static bool IsValid(string code) + { + if (string.IsNullOrWhiteSpace(code)) return false; + var parts = code.Split('-'); + if (parts.Length == 1) return parts[0].Length == 2 && parts[0].All(char.IsLetter); + if (parts.Length != 2 || parts[0].Length != 2) return false; + return ByCountry.TryGetValue(parts[0], out var list) ? list.ContainsKey(parts[1]) : parts[1].Length >= 1 && parts[1].Length <= 3; + } + + public static string Label(string code) + { + if (string.IsNullOrWhiteSpace(code)) return null; + var parts = code.Split('-'); + var country = parts[0] == "US" ? "United States" : parts[0] == "CA" ? "Canada" : parts[0]; + if (parts.Length == 1) return country; + return ByCountry.TryGetValue(parts[0], out var list) && list.TryGetValue(parts[1], out var name) ? name + ", " + country : parts[1] + ", " + country; + } + } +} diff --git a/Core/Resgrid.Model/Records/RmsRecordWorkAssignment.cs b/Core/Resgrid.Model/Records/RmsRecordWorkAssignment.cs new file mode 100644 index 00000000..6e127f86 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsRecordWorkAssignment.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Resgrid.Model +{ + /// Who a work assignment names (RMS plan section 5.2 RmsRecordWorkAssignment). + public enum RmsWorkAssigneeKind + { + Person = 1, + Unit = 2, + Group = 3, + /// A role on the active command structure of the Record's Call (IC app). + CommandRole = 4, + /// A dispatch-console role (Dispatch app). + DispatchRole = 5 + } + + public enum RmsWorkAssignmentState + { + Open = 1, + Acknowledged = 2, + Completed = 3, + Cancelled = 4 + } + + /// Why the work exists; a closed set so queues and dashboards can count it. + public static class RmsWorkAssignmentPurposes + { + public const string Complete = "complete"; + public const string Review = "review"; + public const string Correct = "correct"; + public const string Attach = "attach"; + public const string Acknowledge = "acknowledge"; + public static readonly IReadOnlyList All = new[] { Complete, Review, Correct, Attach, Acknowledge }; + public static bool IsKnown(string purpose) => purpose != null && Array.IndexOf((string[])All, purpose.Trim().ToLowerInvariant()) >= 0; + } + + /// + /// An optional person, unit/team, command-role or dispatch-role assignment on a Record with purpose, due / + /// acknowledged / completed state, source context and audit (RMS plan section 5.2, RMS-1D). It narrows a + /// field work queue; it never replaces live authorization, so a queue row the assignee may no longer read + /// is withheld at read time rather than trusted because it was assigned. + /// + public class RmsRecordWorkAssignment : IEntity + { + public string RmsRecordWorkAssignmentId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public string RecordId { get; set; } + + /// . + public int AssigneeKind { get; set; } + + public string AssigneeUserId { get; set; } + + public int? AssigneeUnitId { get; set; } + + public int? AssigneeGroupId { get; set; } + + /// Command or dispatch role name for the role kinds; null otherwise. + public string AssigneeRole { get; set; } + + /// . + public string Purpose { get; set; } + + public string Note { get; set; } + + /// Safe source context (call / unit / group / command identifiers only) the assignment was made in. + public string SourceContextJson { get; set; } + + public DateTime? DueOn { get; set; } + + /// . + public int State { get; set; } + + public DateTime? AcknowledgedOn { get; set; } + + public string AcknowledgedByUserId { get; set; } + + public DateTime? CompletedOn { get; set; } + + public string CompletedByUserId { get; set; } + + public DateTime? CancelledOn { get; set; } + + public string CancelledByUserId { get; set; } + + public string CancelReason { get; set; } + + /// of the client that created the assignment (safe audit metadata). + public int OriginClient { get; set; } + + public DateTime CreatedOn { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime ModifiedOn { get; set; } + + public string ModifiedByUserId { get; set; } + + [Key] + [Required] + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + [NotMapped] + public bool IsOpen => State == (int)RmsWorkAssignmentState.Open || State == (int)RmsWorkAssignmentState.Acknowledged; + + [NotMapped] + public object IdValue + { + get { return RmsRecordWorkAssignmentId; } + set { RmsRecordWorkAssignmentId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsRecordWorkAssignments"; + + [NotMapped] + public string IdName => "RmsRecordWorkAssignmentId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "IsOpen" }; + } + + /// Input for creating a work assignment. + public class RecordWorkAssignmentInput + { + public string RecordId { get; set; } + public RmsWorkAssigneeKind AssigneeKind { get; set; } = RmsWorkAssigneeKind.Person; + public string AssigneeUserId { get; set; } + public int? AssigneeUnitId { get; set; } + public int? AssigneeGroupId { get; set; } + public string AssigneeRole { get; set; } + public string Purpose { get; set; } = RmsWorkAssignmentPurposes.Complete; + public string Note { get; set; } + public DateTime? DueOn { get; set; } + public FieldRecordContext SourceContext { get; set; } + public RmsOriginClient OriginClient { get; set; } = RmsOriginClient.Web; + } +} diff --git a/Core/Resgrid.Model/Records/RmsSavedReport.cs b/Core/Resgrid.Model/Records/RmsSavedReport.cs new file mode 100644 index 00000000..c0a39489 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsSavedReport.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// Bounded aggregation the saved-report runner supports (RMS plan section 4.1 "Reporting and presentation"). + public enum RmsReportAggregate + { + Count = 1, + Sum = 2, + Average = 3, + Minimum = 4, + Maximum = 5 + } + + /// + /// Department saved report over one definition (RMS plan section 5.2 RmsSavedReportDefinition, registry M0161): + /// allowlisted typed fields, bounded filters, one optional group-by, and count/sum/avg/min/max where the pinned + /// field allows it. No SQL, no cross-definition joins, no unbounded query. + /// + public class RmsSavedReportDefinition : IEntity + { + public const int MaxRows = 5000; + public const int MaxColumns = 40; + public const int MaxFilters = 12; + + public string RmsSavedReportDefinitionId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string DefinitionKey { get; set; } + /// Null = current published version, with explicit cross-version mappings declared in MappingsJson. + public int? DefinitionVersion { get; set; } + /// Serialized . + public string SpecJson { get; set; } + public int MaxRowsPerRun { get; set; } = MaxRows; + public bool IncludeRestricted { get; set; } + public DateTime? LastRunOn { get; set; } + public string LastRunByUserId { 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; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsSavedReportDefinitionId; } + set { RmsSavedReportDefinitionId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsSavedReportDefinitions"; + [NotMapped] public string IdName => "RmsSavedReportDefinitionId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Spec" }; + + [NotMapped] + [JsonIgnore] + public RecordReportSpec Spec + { + get { return string.IsNullOrWhiteSpace(SpecJson) ? new RecordReportSpec() : JsonConvert.DeserializeObject(SpecJson) ?? new RecordReportSpec(); } + set { SpecJson = JsonConvert.SerializeObject(value ?? new RecordReportSpec()); } + } + } + + public class RecordReportSpec + { + /// Field keys, plus the built-in record.* columns (record.number, record.state, record.started_on, record.finalized_on, record.author, record.group). + public List Columns { get; set; } = new List(); + public List Filters { get; set; } = new List(); + public string GroupByFieldKey { get; set; } + public List Aggregates { get; set; } = new List(); + public string SortFieldKey { get; set; } + public bool SortDescending { get; set; } + /// Only finalized (and amended/accepted) revisions by default; drafts are working data. + public bool IncludeDrafts { get; set; } + public int? WindowDays { get; set; } + /// Explicit per-version field mappings: version -> (reportFieldKey -> fieldKey in that version). + public Dictionary> VersionMappings { get; set; } = new Dictionary>(); + } + + public class RecordReportFilter + { + public string FieldKey { get; set; } + public RmsRuleOperator Operator { get; set; } = RmsRuleOperator.Equals; + public string Value { get; set; } + public List Values { get; set; } + public decimal? Min { get; set; } + public decimal? Max { get; set; } + public DateTime? MinDate { get; set; } + public DateTime? MaxDate { get; set; } + } + + public class RecordReportAggregateSpec + { + public RmsReportAggregate Aggregate { get; set; } + /// Null for Count. + public string FieldKey { get; set; } + } + + public class RecordReportResult + { + public string ReportId { get; set; } + public string Name { get; set; } + public string DefinitionKey { get; set; } + public int? DefinitionVersion { get; set; } + public DateTime RanOn { get; set; } + public List Columns { get; set; } = new List(); + public List ColumnLabels { get; set; } = new List(); + public List> Rows { get; set; } = new List>(); + public List Groups { get; set; } = new List(); + public int TotalMatched { get; set; } + public bool Truncated { get; set; } + /// Definition versions the run met that had no mapping for a report column; their rows are reported, not coerced. + public List UnmappedVersions { get; set; } = new List(); + public List Warnings { get; set; } = new List(); + } + + public class RecordReportGroup + { + public string GroupKey { get; set; } + public string GroupLabel { get; set; } + public int Count { get; set; } + public Dictionary Aggregates { get; set; } = new Dictionary(); + } + + public class RecordReportValidation + { + public List Issues { get; set; } = new List(); + public bool IsValid => Issues.All(i => i.Severity != "error"); + } +} diff --git a/Core/Resgrid.Model/Records/RmsTemplatePacks.cs b/Core/Resgrid.Model/Records/RmsTemplatePacks.cs new file mode 100644 index 00000000..c6fdd5b9 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsTemplatePacks.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// How an artifact produced from a pack may be described (RMS plan section 4.1). Nothing product-shipped is Exact until validated against the current source profile. + public enum RmsArtifactStatus + { + DepartmentLocal = 0, + Compatible = 1, + Exact = 2 + } + + /// + /// Product-managed vertical pack/version (RMS plan section 5.2 RmsTemplatePackVersion, registry M0162). Rows are + /// product scope (DepartmentId 0) and are upserted from the code catalog so departments see release notes, source + /// provenance and deprecation without a redeploy. A pack update never mutates a department clone. + /// + public class RmsTemplatePackVersion : IEntity + { + public const int ProductDepartmentId = 0; + + public string RmsTemplatePackVersionId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string PackKey { get; set; } + public int Version { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string Description { get; set; } + public bool IsPreview { get; set; } + /// Comma-separated definition keys the pack ships (template.* / pack.* keys). + public string DefinitionKeys { get; set; } + /// Comma-separated jurisdiction profile keys the pack supports (generic, us, ca, us-ca). + public string SupportedProfiles { get; set; } + public string SupportedLocales { get; set; } + public string ReleaseNotes { get; set; } + /// Serialized list of : which published sources shaped the pack and when they were reviewed. + public string SourceProvenanceJson { get; set; } + public DateTime? ReviewedOn { get; set; } + public int ArtifactStatus { get; set; } + public string ContentChecksum { get; set; } + public bool IsDeprecated { get; set; } + public string DeprecatedByPackKey { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsTemplatePackVersionId; } + set { RmsTemplatePackVersionId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsTemplatePackVersions"; + [NotMapped] public string IdName => "RmsTemplatePackVersionId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// Locked jurisdiction/standards overlay (RMS plan section 5.2 RmsJurisdictionProfileVersion, registry M0162): + /// country, subdivision/agency scope, terminology, units, currency, locales, classification/retention defaults and + /// whether artifacts are exact, compatible or department-local. Product scope (DepartmentId 0). + /// + public class RmsJurisdictionProfileVersion : IEntity + { + public string RmsJurisdictionProfileVersionId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string ProfileKey { get; set; } + public int Version { get; set; } + public string Name { get; set; } + /// ISO 3166-1 alpha-2, or "XX" for the generic base and "US-CA" style for a cross-border pair. + public string Country { get; set; } + public string Subdivision { get; set; } + public string AgencyScope { get; set; } + public string DefaultLocale { get; set; } + public string SupportedLocales { get; set; } + /// metric | customary + public string MeasurementSystem { get; set; } + public string CurrencyCode { get; set; } + public string DefaultTimeZone { get; set; } + /// Serialized dictionary locale -> (term -> label). + public string TerminologyJson { get; set; } + /// Serialized list of : form/rule identifiers and source versions. + public string StandardsJson { get; set; } + public int ClassificationDefault { get; set; } + public int? RetentionYearsDefault { get; set; } + public string RequiredSections { get; set; } + public int ArtifactStatus { get; set; } + public DateTime? ReviewedOn { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsJurisdictionProfileVersionId; } + set { RmsJurisdictionProfileVersionId = value?.ToString(); } + } + + [NotMapped] public string TableName => "RmsJurisdictionProfileVersions"; + [NotMapped] public string IdName => "RmsJurisdictionProfileVersionId"; + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + + [NotMapped] + [JsonIgnore] + public Dictionary> Terminology => string.IsNullOrWhiteSpace(TerminologyJson) ? new Dictionary>() : JsonConvert.DeserializeObject>>(TerminologyJson) ?? new Dictionary>(); + } + + public class RmsSourceProvenance + { + public string Identifier { get; set; } + public string Title { get; set; } + public string Publisher { get; set; } + public string Version { get; set; } + public string Url { get; set; } + public DateTime? ReviewedOn { get; set; } + /// operational-aid | named-form + public string Kind { get; set; } = "operational-aid"; + } + + /// A product template or pack definition as shipped in code: the generic base schema plus per-profile overlays. + public class RecordTemplateDefinition + { + public string Key { get; set; } + public string PackKey { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string Description { get; set; } + public RmsLifecyclePreset LifecyclePreset { get; set; } = RmsLifecyclePreset.QuickEntry; + public string NumberPrefix { get; set; } + public string PermittedSubjectTypes { get; set; } + public RmsFieldClassification Classification { get; set; } + public int? RetentionYears { get; set; } + public RecordDefinitionSchema Schema { get; set; } = new RecordDefinitionSchema(); + public RecordDefinitionClientSurface ClientSurface { get; set; } = new RecordDefinitionClientSurface { Responder = true, Unit = true, IncidentCommand = true, Dispatch = true, AllowOffline = true }; + /// Profile-specific overrides (RMS-1C): profile key -> overlay. + public Dictionary Overlays { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// Fields whose classification is forced by the pack (subject, treatment, exposure, manifest, release, regulatory); a clone cannot loosen them. + public List LockedClassificationFieldKeys { get; set; } = new List(); + + /// + /// Per-pack protected-data policies (RMS-1C): the classification floor a category of fields carries in every + /// rendering and every department clone. A floor can be raised by the department, never lowered; search, Workflow, + /// reports and exports project the field by its classification, so a floor also fixes the safe projection. + /// + public List ProtectedDataPolicies { get; set; } = new List(); + + /// The floor for a field: the strictest policy naming it, or the locked-key floor (Restricted), or none. + public RmsFieldClassification? FloorFor(string fieldKey) + { + RmsFieldClassification? floor = null; + foreach (var policy in ProtectedDataPolicies.Where(p => p.FieldKeys.Contains(fieldKey, StringComparer.OrdinalIgnoreCase))) + if (!floor.HasValue || policy.Floor > floor.Value) floor = policy.Floor; + if (LockedClassificationFieldKeys.Contains(fieldKey, StringComparer.OrdinalIgnoreCase) && (!floor.HasValue || floor.Value < RmsFieldClassification.Restricted)) + floor = RmsFieldClassification.Restricted; + return floor; + } + } + + /// What a jurisdiction overlay changes on a template: labels by locale, units, currency, added required sections, provenance. + /// One protected-data policy of a template: a category of fields and the classification floor they carry. + public class RecordTemplateFieldPolicy + { + /// subject-clue-recovery, treatment-casualty, exposure-health, manifest-travel, facility-security, release, regulatory. + public string Category { get; set; } + public List FieldKeys { get; set; } = new List(); + public RmsFieldClassification Floor { get; set; } = RmsFieldClassification.Restricted; + public string Rationale { get; set; } + } + + public class RecordTemplateOverlay + { + public string ProfileKey { get; set; } + /// locale -> (field or section key -> label) + public Dictionary> Labels { get; set; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); + /// field key -> unit code (quantity fields) in the profile's measurement system. + public Dictionary DefaultUnits { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public string CurrencyCode { get; set; } + public List RequiredSectionKeys { get; set; } = new List(); + public List Sources { get; set; } = new List(); + public RmsArtifactStatus ArtifactStatus { get; set; } = RmsArtifactStatus.Compatible; + } + + /// Catalog entry for the template browser: pack metadata plus the definitions it ships. + public class RecordTemplatePackSummary + { + public string PackKey { get; set; } + public int Version { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string Description { get; set; } + public bool IsPreview { get; set; } + public string ArtifactStatus { get; set; } + public List SupportedProfiles { get; set; } = new List(); + public List SupportedLocales { get; set; } = new List(); + public DateTime? ReviewedOn { get; set; } + public List Definitions { get; set; } = new List(); + public List Sources { get; set; } = new List(); + } + + public class RecordTemplateSummary + { + public string Key { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string Description { get; set; } + public string LifecyclePreset { get; set; } + public int SectionCount { get; set; } + public int FieldCount { get; set; } + public string MinimumClientCapability { get; set; } + public string ArtifactStatus { get; set; } + public bool IsPreview { get; set; } + } + + /// A published pack definition rendered for one profile and locale (labels, units, currency applied). + public class RecordTemplateRendering + { + /// The pack's protected-data policies as applied to this rendering (RMS-1C). + public List Policies { get; set; } = new List(); + public RecordTemplateDefinition Template { get; set; } + public string ProfileKey { get; set; } + public string Locale { get; set; } + public string MeasurementSystem { get; set; } + public string CurrencyCode { get; set; } + public RecordDefinitionSchema Schema { get; set; } + public RmsArtifactStatus ArtifactStatus { get; set; } + public List Sources { get; set; } = new List(); + /// "Compatible with ; not an exact named form" style statement every generated artifact displays. + public string ProvenanceStatement { get; set; } + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs new file mode 100644 index 00000000..343c3612 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + // RMS-1B/1C repositories (registry M0158, M0159, M0161, M0162, M0163). Every query begins at DepartmentId; product + // catalog rows (packs, profiles) use DepartmentId 0. + + public interface IRmsRecordDefinitionsRepository : IRepository + { + Task GetByKeyAsync(int departmentId, string definitionKey); + Task GetByIdForDepartmentAsync(int departmentId, string definitionId); + Task> GetForDepartmentAsync(int departmentId, bool includeRetired); + Task TryBumpRowVersionAsync(int departmentId, string definitionId, long expectedVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsRecordDefinitionVersionsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string versionId); + Task GetAsync(int departmentId, string definitionKey, int version); + Task> GetForDefinitionAsync(int departmentId, string definitionKey); + /// Published versions across the department, for the client catalog and the New Record chooser. + Task> GetPublishedForDepartmentAsync(int departmentId); + Task TryBumpRowVersionAsync(int departmentId, string versionId, long expectedVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsRecordSectionDefinitionsRepository : IRepository + { + Task> GetForVersionAsync(int departmentId, string versionId); + Task DeleteForVersionAsync(int departmentId, string versionId, CancellationToken cancellationToken = default); + } + + public interface IRmsRecordFieldDefinitionsRepository : IRepository + { + Task> GetForVersionAsync(int departmentId, string versionId); + Task DeleteForVersionAsync(int departmentId, string versionId, CancellationToken cancellationToken = default); + } + + public interface IRmsRecordValueGroupsRepository : IRepository + { + Task> GetForRecordAsync(int departmentId, string recordId, string revisionId); + Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds, bool draftsOnly); + Task> GetForRevisionsAsync(int departmentId, IEnumerable revisionIds); + Task DeleteDraftForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default); + } + + public interface IRmsRecordValuesRepository : IRepository + { + Task> GetForRecordAsync(int departmentId, string recordId, string revisionId); + /// Draft rows for many records at once (the New Record chooser's duplicate hints, saved reports over drafts). + Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds, bool draftsOnly); + /// Revision rows for a bounded record set (saved reports run over immutable revisions). + Task> GetForRevisionsAsync(int departmentId, IEnumerable revisionIds); + Task DeleteDraftForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default); + Task CountRecordsOnVersionAsync(int departmentId, string versionId, bool draftsOnly); + } + + public interface IRmsSavedReportDefinitionsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string reportId); + Task> GetForDepartmentAsync(int departmentId); + Task TryBumpRowVersionAsync(int departmentId, string reportId, long expectedVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsTemplatePackVersionsRepository : IRepository + { + Task> GetCatalogAsync(); + Task GetAsync(string packKey, int version); + } + + public interface IRmsJurisdictionProfileVersionsRepository : IRepository + { + Task> GetCatalogAsync(); + Task GetAsync(string profileKey, int version); + Task GetLatestAsync(string profileKey); + } + + public interface IRmsExternalOrdersRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string orderId, bool includeArtifact); + Task GetForRecordAsync(int departmentId, string recordId); + Task> GetForDepartmentAsync(int departmentId, bool includeClosed); + Task GetArtifactAsync(int departmentId, string orderId); + Task TryBumpRowVersionAsync(int departmentId, string orderId, long expectedVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsExternalOrderFillsRepository : IRepository + { + Task> GetForOrderAsync(int departmentId, string orderId); + Task GetByIdForDepartmentAsync(int departmentId, string fillId); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsExportRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsExportRepositories.cs index 23aae0f0..ae9c12d6 100644 --- a/Core/Resgrid.Model/Repositories/IRmsExportRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsExportRepositories.cs @@ -17,6 +17,14 @@ public interface IRmsExportTemplatesRepository : IRepository Task> GetDueAsync(DateTime utcNow, int take); Task TryBumpRowVersionAsync(int departmentId, string templateId, long expectedVersion, CancellationToken cancellationToken = default); + + /// + /// Claims a due template for one sweep by moving NextRunOn off in the + /// same statement that matches it. False means another sweep already took it, so this one must skip it: + /// bumping the row version alone would leave the row due and let a second sweep render it again. + /// + Task TryClaimDueAsync(int departmentId, string templateId, DateTime expectedNextRunOn, DateTime deferUntil, DateTime utcNow, + CancellationToken cancellationToken = default); } public interface IRmsExportRunsRepository : IRepository diff --git a/Core/Resgrid.Model/Repositories/IRmsFieldRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsFieldRepositories.cs new file mode 100644 index 00000000..75f0dede --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsFieldRepositories.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// Work assignments (RMS-1D, registry M0179). + public interface IRmsRecordWorkAssignmentsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string assignmentId); + + Task> GetForRecordAsync(int departmentId, string recordId); + + /// Open/acknowledged assignments addressed to the person, any of the units, any of the groups, or any of the roles. + Task> GetOpenForAssigneesAsync(int departmentId, string userId, IEnumerable unitIds, IEnumerable groupIds, IEnumerable roles, int take); + + /// Assignments modified after the cursor, oldest first, for the sync delta. + Task> GetModifiedSinceAsync(int departmentId, DateTime? since, int take); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs index 13522ea6..87ca865e 100644 --- a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs @@ -21,6 +21,14 @@ public sealed class RmsIncidentReportQuery /// public IList VisibleGroupIds { get; set; } + /// + /// Half-open finalized-on window [FinalizedOnStart, FinalizedOnEnd). Filtering in the query rather than + /// after paging is what keeps a windowed export honest: out-of-window rows would otherwise eat the page + /// budget and silently drop reports that belong in the window. + /// + public DateTime? FinalizedOnStart { get; set; } + public DateTime? FinalizedOnEnd { get; set; } + public string ViewerUserId { get; set; } public int Skip { get; set; } public int Take { get; set; } = 50; diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs index 56331d0c..2f9a95c8 100644 --- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs @@ -36,6 +36,10 @@ public interface IRmsOperationalRecordsRepository : IRepositoryRecords of one definition whose StartedOn falls in [start, end], in the given states; the report feed. Task> GetByDefinitionAndStartedRangeAsync(int departmentId, string definitionKey, IEnumerable states, DateTime start, DateTime end); Task> GetByOwnerAndStatesAsync(int departmentId, string ownerUserId, IEnumerable states); + /// Records pinned to one definition version in the given states (RMS-1B draft migration, impact preview). + Task> GetByDefinitionVersionAsync(int departmentId, string definitionKey, int definitionVersion, IEnumerable states); + /// Live Records by id, department-scoped (saved reports resolve current revisions this way). + Task> GetByIdsAsync(int departmentId, IEnumerable recordIds); Task> GetByDepartmentAndStatesAsync(int departmentId, IEnumerable states, int? year, int skip, int take); Task CountByDepartmentAsync(int departmentId, IEnumerable states); Task CountVisibleAsync(int departmentId, IEnumerable states, List visibleGroupIds, string userId); diff --git a/Core/Resgrid.Model/Services/IFieldRecordsService.cs b/Core/Resgrid.Model/Services/IFieldRecordsService.cs new file mode 100644 index 00000000..11b7c61a --- /dev/null +++ b/Core/Resgrid.Model/Services/IFieldRecordsService.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// The Field Records contract for the four operational apps (RMS plan RMS-1D): minimum-version preflight, + /// the FieldRecordCatalogV1 manifest filtered by department, app, version, flags, role, verified context, + /// Protected Data state and each definition's client surface; server-calculated prefill with provenance; and + /// the bounded sync bundle. Every filter is applied server-side from the authenticated principal; a client + /// cannot widen what it receives by changing a query value. + /// + public interface IFieldRecordsService + { + Task PreflightAsync(int departmentId, string userId, RmsOriginClient origin, string appVersion, string clientCapability); + + /// Verifies that the caller may act in the claimed Call / Unit / group / command context for this app. + Task VerifyContextAsync(int departmentId, string userId, RmsOriginClient origin, FieldRecordContext context); + + Task GetCatalogAsync(int departmentId, string userId, FieldRecordCatalogRequest request); + + /// Prefill for one catalog entry; refused when the entry is not in the caller's catalog for the same request. + Task PrefillAsync(int departmentId, string userId, FieldRecordCatalogRequest request, string definitionKey, int version); + + Task SyncAsync(int departmentId, string userId, FieldRecordSyncRequest request, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordDefinitionsService.cs b/Core/Resgrid.Model/Services/IRecordDefinitionsService.cs new file mode 100644 index 00000000..c7284768 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordDefinitionsService.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Definition management (RMS plan sections 4.1 and 5.4, RMS-1B). Locked system definitions are readable here but + /// never editable; department definitions follow Draft -> Published -> Retired with immutable published versions. + /// Management needs ManageRecordDefinitions; publish and retire need PublishRecordDefinitions. + /// + public interface IRecordDefinitionsService + { + /// Every definition the department can see: locked system entries plus its own, with version state. + Task> ListAsync(int departmentId, bool includeRetired = false); + + /// Published department definitions a user may start a Record on (retired and draft-only excluded). + Task> GetPublishedAsync(int departmentId); + + Task GetAsync(int departmentId, string definitionKey); + + /// The exact pinned version a Record renders against; null when the department has no such version. + Task GetVersionAsync(int departmentId, string definitionKey, int version); + + Task GetVersionByIdAsync(int departmentId, string versionId); + + /// The current published version (what a new Record pins), or null. + Task GetCurrentPublishedAsync(int departmentId, string definitionKey); + + /// Creates a definition with version 1 as a draft, blank, cloned from a product template/pack, or cloned from another department definition. + Task CreateAsync(int departmentId, string userId, RecordDefinitionCreateInput input, CancellationToken cancellationToken = default); + + /// Opens a new draft version from the current published version (editing a published definition never mutates it). + Task OpenDraftAsync(int departmentId, string userId, string definitionKey, CancellationToken cancellationToken = default); + + /// ETag-guarded draft save of schema, lifecycle, numbering and policies. + Task SaveDraftAsync(int departmentId, string userId, string definitionKey, int version, long expectedRowVersion, RecordDefinitionDraftInput input, CancellationToken cancellationToken = default); + + /// Structural, rule (cycle), classification, capability and policy validation of a draft document. + Task ValidateAsync(int departmentId, RecordDefinitionDraftInput input); + + Task ImpactPreviewAsync(int departmentId, string definitionKey, int version); + + /// Freezes the draft: checksum, capability floor, materialized fields, current version pointer, trigger 113. + Task PublishAsync(int departmentId, string userId, string definitionKey, int version, long expectedRowVersion, CancellationToken cancellationToken = default); + + /// Stops new Records on the definition; historical Records stay usable. Trigger 114. + Task RetireAsync(int departmentId, string userId, string definitionKey, long expectedRowVersion, string reason, CancellationToken cancellationToken = default); + + /// Deletes an unused draft version (never a published one). + Task DeleteDraftAsync(int departmentId, string userId, string definitionKey, int version, CancellationToken cancellationToken = default); + + Task> HistoryAsync(int departmentId, string definitionKey); + + Task DiffAsync(int departmentId, string definitionKey, int fromVersion, int toVersion); + + /// Migrates compatible draft Records to a newer version through an explicit mapping; finalized Records never move. + Task MigrateDraftsAsync(int departmentId, string userId, string definitionKey, int fromVersion, int toVersion, List mapping, bool preview, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordDeploymentsService.cs b/Core/Resgrid.Model/Services/IRecordDeploymentsService.cs new file mode 100644 index 00000000..44ee638f --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordDeploymentsService.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Create Deployment from External Order (RMS plan section 4.1 "external-order fill contract", RMS-1C, Preview). + /// Manual entry and artifact snapshot only: no IROC, CIFFC or member-agency connector, no write-back, no inferred + /// order updates. Every deployment is a Record on the mutual-aid deployment definition, so lifecycle, audit, + /// revisions, retention and Workflow events are the ordinary Records ones. + /// + public interface IRecordDeploymentsService + { + Task CreateFromExternalOrderAsync(int departmentId, string userId, RecordDeploymentCreateInput input, CancellationToken cancellationToken = default); + Task GetAsync(int departmentId, string userId, string orderId, bool includeArtifact = false); + Task GetForRecordAsync(int departmentId, string userId, string recordId); + Task> ListAsync(int departmentId, string userId, bool includeClosed); + Task AddFillAsync(int departmentId, string userId, string orderId, RecordDeploymentFillInput input, CancellationToken cancellationToken = default); + Task TransitionFillAsync(int departmentId, string userId, string fillId, RecordDeploymentFillTransitionInput input, CancellationToken cancellationToken = default); + /// Records a later snapshot of the same external order (a new versioned artifact); never overwrites signed history. + Task RecordSourceSnapshotAsync(int departmentId, string userId, string orderId, string sourceVersion, byte[] artifact, string fileName, string contentType, CancellationToken cancellationToken = default); + /// Closeout requires every accepted fill to have actually returned to its home unit. + Task CloseoutAsync(int departmentId, string userId, string orderId, long expectedRowVersion, string notes, CancellationToken cancellationToken = default); + /// The definition key a department's deployments use (provisioned from the pack on first use). + Task EnsureDeploymentDefinitionAsync(int departmentId, string userId, string profileKey, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordSavedReportsService.cs b/Core/Resgrid.Model/Services/IRecordSavedReportsService.cs new file mode 100644 index 00000000..7388cbf0 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordSavedReportsService.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// Department saved reports (RMS plan section 4.1, RMS-1B). Managing needs ManageRecordReports; running honors the runner's group scope and restricted permission. + public interface IRecordSavedReportsService + { + Task> GetForDepartmentAsync(int departmentId); + Task GetAsync(int departmentId, string reportId); + Task ValidateAsync(int departmentId, RmsSavedReportDefinition report); + Task SaveAsync(int departmentId, string userId, RmsSavedReportDefinition report, CancellationToken cancellationToken = default); + Task DeleteAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default); + Task RunAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default); + /// RFC 4180 CSV of a run (formula-guarded), for download. + string ToCsv(RecordReportResult result); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordTemplatePacksService.cs b/Core/Resgrid.Model/Services/IRecordTemplatePacksService.cs new file mode 100644 index 00000000..98a19cdf --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordTemplatePacksService.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Product-managed template packs and jurisdiction profiles (RMS plan section 4.1, RMS-1B launch templates and + /// RMS-1C operational packs). Content lives in code; the catalog tables mirror it so departments see provenance, + /// review dates and deprecation. Rendering a template for a profile/locale applies the locked overlay. + /// + public interface IRecordTemplatePacksService + { + Task> GetCatalogAsync(); + Task> GetProfilesAsync(); + Task GetProfileAsync(string profileKey); + RecordTemplateDefinition GetTemplate(string templateKey); + /// The template rendered for a profile and locale: labels, units and currency applied, provenance statement attached. + Task RenderAsync(string templateKey, string profileKey, string locale); + /// Upserts the code catalog into the product-scope tables (idempotent; called on first browse and by tests). + Task EnsureCatalogAsync(CancellationToken cancellationToken = default); + /// Diff between a department clone's schema and the current product template, for the deliberate-incorporate flow. + RecordDefinitionDiff DiffAgainstTemplate(string templateKey, string profileKey, string locale, RecordDefinitionSchema departmentSchema, string definitionKey, int version); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordTypedValuesService.cs b/Core/Resgrid.Model/Services/IRecordTypedValuesService.cs new file mode 100644 index 00000000..043520e5 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordTypedValuesService.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// The typed value model behind department definitions (RMS plan section 5.3): parsing every posted value against + /// the pinned field type, the bounded rule language, draft/revision storage in RmsRecordValues, and the safe + /// projections (search text, Workflow block, print rows, export cells). Locked system definitions never come here. + /// + public interface IRecordTypedValuesService + { + /// Parses and validates inputs against the version. Draft saves tolerate incompleteness; finalizing applies requiredness and rules. + Task ValidateAsync(int departmentId, RmsRecordDefinitionVersion version, List inputs, bool finalizing); + + /// Evaluates the version's Show/Require rules over a value set. + RecordRuleEvaluation EvaluateRules(RecordDefinitionSchema schema, RecordValueSet values); + + /// Replaces the working-draft rows (groups and values) for a Record inside the caller's transaction. + Task SaveDraftValuesAsync(int departmentId, string userId, string recordId, RmsRecordDefinitionVersion version, List inputs, CancellationToken cancellationToken = default); + + /// Hydrates the draft (revisionId null) or an immutable revision, rendered against the pinned version, withholding restricted cells for the caller. + Task HydrateAsync(int departmentId, string recordId, string revisionId, RmsRecordDefinitionVersion version, bool canViewRestricted); + + /// Copies the working-draft rows into revision-bound rows (finalize/amend) inside the caller's transaction. + Task CopyDraftToRevisionAsync(int departmentId, string recordId, string revisionId, CancellationToken cancellationToken = default); + + /// Restores the working draft from a revision (abandon amendment) inside the caller's transaction. + Task RestoreDraftFromRevisionAsync(int departmentId, string userId, string recordId, string revisionId, RmsRecordDefinitionVersion version, CancellationToken cancellationToken = default); + + Task DeleteDraftAsync(int departmentId, string recordId, CancellationToken cancellationToken = default); + + /// Searchable, non-protected, non-restricted text for the search projection (RMS plan section 5.10). + string ToSearchText(RecordDefinitionSchema schema, RecordValueSet values); + + /// The record.fields.* block: WorkflowExposed fields only, restricted/protected never. + Dictionary ToWorkflowBlock(RecordDefinitionSchema schema, RecordValueSet values); + + /// Snapshot form pinned to the version's labels: section label -> (field label -> display) or a row array for repeating sections. + Dictionary ToSnapshot(RecordDefinitionSchema schema, RecordValueSet values); + + /// Display summary for lists (first searchable short text values), bounded length. + string ToDisplaySummary(RecordDefinitionSchema schema, RecordValueSet values); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordWorkAssignmentsService.cs b/Core/Resgrid.Model/Services/IRecordWorkAssignmentsService.cs new file mode 100644 index 00000000..1d77898f --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordWorkAssignmentsService.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Work assignments on Records (RMS plan section 5.2 RmsRecordWorkAssignment, RMS-1D): assign, acknowledge, + /// complete, cancel, and the per-caller queue. An assignment narrows a work queue; it never replaces live + /// authorization, so every queue read re-checks record visibility. + /// + public interface IRecordWorkAssignmentsService + { + Task AssignAsync(int departmentId, string userId, RecordWorkAssignmentInput input, CancellationToken cancellationToken = default); + + Task AcknowledgeAsync(int departmentId, string userId, string assignmentId, long? expectedRowVersion, FieldRecordContext context, RmsOriginClient origin, CancellationToken cancellationToken = default); + + Task CompleteAsync(int departmentId, string userId, string assignmentId, long? expectedRowVersion, FieldRecordContext context, RmsOriginClient origin, CancellationToken cancellationToken = default); + + Task CancelAsync(int departmentId, string userId, string assignmentId, long? expectedRowVersion, string reason, RmsOriginClient origin, CancellationToken cancellationToken = default); + + Task GetAsync(int departmentId, string userId, string assignmentId); + + Task> GetForRecordAsync(int departmentId, string userId, string recordId); + + /// Open and acknowledged assignments addressed to the caller as a person, through a staffed unit, their group, or a held command/dispatch role. + Task> GetQueueAsync(int departmentId, string userId, FieldRecordContext context, int take); + + /// Whether the caller is an addressee of the assignment in the given context. + Task IsAssigneeAsync(int departmentId, string userId, RmsRecordWorkAssignment assignment, FieldRecordContext context); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsBulkPacketService.cs b/Core/Resgrid.Model/Services/IRecordsBulkPacketService.cs new file mode 100644 index 00000000..3e082d1b --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsBulkPacketService.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Bulk operations over an authorized, paged Records selection (RMS plan section 4.7): assign-for-review, and the + /// compiled print / bundle packet that reuses the scheduled-PDF delivery path. Bulk void and bulk delete are not + /// offered; immutability is not negotiable for convenience. + /// + public interface IRecordsBulkPacketService + { + /// Compiles the selection into one stored export run (30-day retention, sealed under ADP) and optionally emails it. + Task BuildPacketAsync(int departmentId, string userId, RecordsBulkPacketRequest request, CancellationToken cancellationToken = default); + + /// Assigns a reviewer to every selected Record that is awaiting review; the rest are reported as skipped. + Task AssignForReviewAsync(int departmentId, string userId, RecordsBulkAssignRequest request, CancellationToken cancellationToken = default); + + /// A stored packet with its bytes unsealed for download; null when missing, expired or not a bulk packet. Needs ExportRecords. + Task GetPacketAsync(int departmentId, string userId, string runId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsPrintLayoutService.cs b/Core/Resgrid.Model/Services/IRecordsPrintLayoutService.cs index b32a14d8..4f5f084d 100644 --- a/Core/Resgrid.Model/Services/IRecordsPrintLayoutService.cs +++ b/Core/Resgrid.Model/Services/IRecordsPrintLayoutService.cs @@ -10,5 +10,14 @@ public interface IRecordsPrintLayoutService Task GetDepartmentDefaultAsync(int departmentId); Task SaveDepartmentDefaultAsync(int departmentId, string userId, RecordsPrintLayoutConfig config, CancellationToken cancellationToken = default); + + /// The Definition-scope layout for a department definition; a generated default (Version 0) when none was saved. + Task GetDefinitionLayoutAsync(int departmentId, string definitionKey); + + /// Saves (versions) the Definition-scope layout from the designer. + Task SaveDefinitionLayoutAsync(int departmentId, string userId, string definitionKey, RecordsDefinitionLayoutConfig config, CancellationToken cancellationToken = default); + + /// Print-time resolution: definition layout (when it applies to the version) → department default → generated default. + Task ResolveForDefinitionAsync(int departmentId, string definitionKey, int definitionVersion); } } diff --git a/Core/Resgrid.Model/Services/IRecordsProtectionService.cs b/Core/Resgrid.Model/Services/IRecordsProtectionService.cs index 79fcdd42..7099491e 100644 --- a/Core/Resgrid.Model/Services/IRecordsProtectionService.cs +++ b/Core/Resgrid.Model/Services/IRecordsProtectionService.cs @@ -50,6 +50,15 @@ public interface IRecordsProtectionService Task ProtectLegalHoldAsync(int departmentId, RmsRecordLegalHold row, RmsRecordLegalHold existing, string userId = null, CancellationToken cancellationToken = default); Task ProtectExportRunAsync(int departmentId, RmsExportRun row, string userId = null, CancellationToken cancellationToken = default); + /// Seals Protected-classified typed values (catalog v11): each row flagged ProtectionRequired packs its typed columns into its envelope; other rows pass through. + Task ProtectValuesAsync(int departmentId, IReadOnlyList rows, string userId = null, CancellationToken cancellationToken = default); + + /// Reveals sealed typed values for the ambient caller; a refused row keeps its envelope and shapes as the withheld cell. + Task RevealValuesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + + /// Reveals sealed typed values through the purpose-bound workload lane (records-export). + Task RevealValuesForWorkloadAsync(int departmentId, IReadOnlyList rows, string purpose, CancellationToken cancellationToken = default); + Task RevealAsync(int departmentId, RecordAggregate aggregate, CancellationToken cancellationToken = default); Task RevealAsync(int departmentId, IncidentReportAggregate aggregate, CancellationToken cancellationToken = default); Task RevealAsync(int departmentId, IncidentAnalysisAggregate aggregate, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Services/IRecordsRevealService.cs b/Core/Resgrid.Model/Services/IRecordsRevealService.cs new file mode 100644 index 00000000..1ef7ae74 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsRevealService.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// Outcome of a protected reveal (RMS plan section 5.9.3): the keyed plaintext for the reveal module, or the reason it was refused. + public class RecordRevealResult + { + public bool Success { get; set; } + /// step_up_required, grant_expired, grant_revoked, protected_access_denied, broker_unavailable. + public string Error { get; set; } + /// "{table}.{column}:{rowId}" -> plaintext; the same keys the data-adp-field markers carry. + public Dictionary Fields { get; set; } = new Dictionary(); + } + + /// + /// The reveal endpoints' shared logic (Web MVC and v4): hydrate through the seam with the ambient grant, key every + /// cataloged column by table.column:rowId, withhold restricted columns without RecordRestricted_View, and audit. + /// + public interface IRecordsRevealService + { + Task RevealRecordAsync(int departmentId, string userId, RecordAggregate aggregate, bool canViewRestricted, string ipAddress); + Task RevealIncidentAsync(int departmentId, string userId, IncidentReportAggregate aggregate, bool canViewRestricted, string ipAddress); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsService.cs b/Core/Resgrid.Model/Services/IRecordsService.cs index 5bb447f3..5f1292ea 100644 --- a/Core/Resgrid.Model/Services/IRecordsService.cs +++ b/Core/Resgrid.Model/Services/IRecordsService.cs @@ -45,6 +45,9 @@ public interface IRecordsService Task ReassignDraftAsync(int departmentId, string userId, string recordId, string newOwnerUserId, string reason, CancellationToken cancellationToken = default); + /// Assigns the reviewer of a Record awaiting review (bulk assign-for-review, plan 4.7); audited, never a lifecycle transition. + Task AssignReviewerAsync(int departmentId, string userId, string recordId, string reviewerUserId, string reason, CancellationToken cancellationToken = default); + Task GetAsync(int departmentId, string recordId, bool includeRevisions = false); /// Records of the same definition already linked to the Call (duplicate warning, RMS plan section 4.7). diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 889398e1..91405356 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -282,6 +282,39 @@ private static List GetCommon() => new TemplateVariableDescriptor("export.expires_on", "When the stored copy expires (UTC)", "datetime", 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 + { + new TemplateVariableDescriptor("definition.id", "Definition ID", "string", false), + new TemplateVariableDescriptor("definition.key", "Definition key, e.g. security-patrol", "string", false), + new TemplateVariableDescriptor("definition.name", "Definition name", "string", false), + new TemplateVariableDescriptor("definition.category", "Definition category", "string", false), + new TemplateVariableDescriptor("definition.owner", "System or Department", "string", false), + new TemplateVariableDescriptor("definition.version", "Definition version", "int", false), + new TemplateVariableDescriptor("definition.previous_version", "Previously published version (publish trigger)", "int", false), + new TemplateVariableDescriptor("definition.state", "Draft, Published or Retired", "string", false), + new TemplateVariableDescriptor("definition.lifecycle_preset", "Lifecycle preset", "string", false), + new TemplateVariableDescriptor("definition.template_key", "Product template the definition was cloned from", "string", false), + new TemplateVariableDescriptor("definition.jurisdiction_profile_key", "Jurisdiction profile (generic, us, ca, us-ca)", "string", false), + new TemplateVariableDescriptor("definition.minimum_client_capability", "Client capability floor", "string", false), + new TemplateVariableDescriptor("definition.schema_checksum", "Published schema checksum", "string", false), + new TemplateVariableDescriptor("definition.published_on", "When the version was published (UTC)", "datetime", false), + new TemplateVariableDescriptor("definition.retired", "Whether the definition is retired", "bool", false), + new TemplateVariableDescriptor("definition.reason", "Retirement reason (retire trigger)", "string", false), + new TemplateVariableDescriptor("definition.exposed_field_keys", "Field keys available under fields.*", "array", false), + new TemplateVariableDescriptor("definition.section_keys", "Section keys", "array", false), + }; + + // fields.* (RMS-1B): the values of fields the definition author marked WorkflowExposed; restricted and + // protected fields never appear. Repeating sections arrive as fields.
(an array of rows) plus + // fields.
_count. + private static readonly List FieldsVariables = new List + { + // Keys are the definition's own field keys (definition.exposed_field_keys); repeating sections add (rows) and _count. + new TemplateVariableDescriptor("fields", "Workflow-exposed field values of the department definition, keyed by field key; repeating sections appear as arrays plus a _count", "object", false), + }; + // review.* rides only on the two review-path triggers: review bookkeeping, never record content. private static readonly List ReviewVariables = new List { @@ -789,6 +822,15 @@ public static IReadOnlyList GetVariableCatalog(Workf list.Add(new TemplateVariableDescriptor("review.review_due_on", "When the review was due (UTC)", "datetime", false)); list.Add(new TemplateVariableDescriptor("review.return_count", "How many times it was returned", "int", false)); } + list.AddRange(DefinitionVariables); + list.AddRange(FieldsVariables); + list.AddRange(ProtectionVariables); + break; + + case WorkflowTriggerEventType.RecordDefinitionPublished: + case WorkflowTriggerEventType.RecordDefinitionRetired: + list.AddRange(RecordEventVariables); + list.AddRange(DefinitionVariables); list.AddRange(ProtectionVariables); break; diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 93b151d8..1ba6e9c8 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -111,6 +111,15 @@ public enum WorkflowTriggerEventType /// A Record passed the due time of a review, correction or resubmission obligation. RecordOverdue = 112, + // RMS-1B definition lifecycle (plan section 4.1). One trigger per lifecycle outcome, never a trigger per + // department definition; the definition key/version travels in the definition.* block. + + /// A department Record definition version was published and new Records may start on it. + RecordDefinitionPublished = 113, + + /// A department Record definition was retired; historical Records stay usable, new ones cannot start. + RecordDefinitionRetired = 114, + /// An attachment was added to a Record draft or amendment; carries safe metadata only, never bytes or names. RecordAttachmentAdded = 115, diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index 9c67dacb..94700524 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -43,7 +43,7 @@ public static IReadOnlyList ForVersionRange(IProtectedFieldCata // carry the init-only marker column across. scoped.Add(new AdpTableBinding(binding.TableName, binding.PkColumn, binding.PkIsNumeric, binding.DepartmentColumn, binding.ParentFkColumn, binding.ParentTable, binding.ParentPkColumn, - columns) with { ProtectedMarkerColumn = binding.ProtectedMarkerColumn }); + columns) with { ProtectedMarkerColumn = binding.ProtectedMarkerColumn, CarrierColumns = binding.CarrierColumns, RowFilterColumn = binding.RowFilterColumn }); } return scoped; @@ -55,6 +55,8 @@ AdpColumnSpec Text(string table, string column) => new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ProtectedFieldStorageKind.Text); AdpColumnSpec Binary(string table, string column) => new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ProtectedFieldStorageKind.Binary); + AdpColumnSpec Packed(string table, string column) => + new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ProtectedFieldStorageKind.PackedJson); AdpColumnSpec Companion(string table, string column) => new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ProtectedFieldStorageKind.CompanionColumn, $"Protected{column}Envelope"); @@ -383,7 +385,15 @@ AdpColumnSpec Companion(string table, string column) => AdpTableBinding.Direct("RmsExportRuns", "RmsExportRunId", pkIsNumeric: false, "DepartmentId", new[] { Binary("RmsExportRuns", "Data") - }) with { ProtectedMarkerColumn = "IsProtected" } + }) with { ProtectedMarkerColumn = "IsProtected" }, + + // Catalog v11: typed values of department definitions (RMS-1B). Only rows flagged ProtectionRequired + // (a Protected-classified field) are swept; the typed siblings are packed into ProtectedEnvelope and + // cleared while sealed, so Standard/Restricted values stay plaintext for search and reports. + AdpTableBinding.Direct("RmsRecordValues", "RmsRecordValueId", pkIsNumeric: false, "DepartmentId", new[] + { + Packed("RmsRecordValues", "ProtectedEnvelope") + }) with { ProtectedMarkerColumn = "IsProtected", CarrierColumns = RmsRecordValuePack.CarrierColumns, RowFilterColumn = "ProtectionRequired" } }; } } diff --git a/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs b/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs index 78bc9cb3..bc415a6c 100644 --- a/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs +++ b/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs @@ -372,6 +372,33 @@ private async Task EncryptRowColumnAsync(Func> return ColumnOutcome.Changed; } + case ProtectedFieldStorageKind.PackedJson: + { + // Catalog v11 typed values: an existing envelope validates/re-keys exactly like Text; a plaintext row + // packs its carrier columns into the envelope and clears them (one column group stays populated). + var envelope = row.Values.TryGetValue(spec.ColumnName, out var rawEnvelope) ? rawEnvelope as string : null; + if (ProtectedDataEnvelope.HasEnvelopePrefix(envelope)) + { + var (validationDek, envelopeKeyVersion) = await ValidationDekForTextAsync(envelope); + var plaintext = _cryptoService.DecryptText(validationDek, envelope, context.DepartmentId, spec.FieldId, row.RowKey); + if (isRekeying && envelopeKeyVersion != keyVersion) + { + setValues[spec.ColumnName] = _cryptoService.EncryptText(targetDek, keyVersion, plaintext, context.DepartmentId, spec.FieldId, row.RowKey); + return ColumnOutcome.Changed; + } + return ColumnOutcome.AlreadyInTargetState; + } + + var packed = RmsRecordValuePack.PackColumns(row.Values); + if (string.IsNullOrEmpty(packed)) + return ColumnOutcome.Skipped; + + setValues[spec.ColumnName] = _cryptoService.EncryptText(targetDek, keyVersion, packed, context.DepartmentId, spec.FieldId, row.RowKey); + foreach (var carrier in RmsRecordValuePack.CarrierColumns) + setValues[carrier] = null; + return ColumnOutcome.Changed; + } + case ProtectedFieldStorageKind.Binary: { var value = row.Values.TryGetValue(spec.ColumnName, out var raw) ? raw as byte[] : null; @@ -470,6 +497,29 @@ private async Task DecryptRowColumnAsync(Func> return ColumnOutcome.Changed; } + case ProtectedFieldStorageKind.PackedJson: + { + // Offboarding: the envelope opens back into its typed carrier columns and is cleared. + var envelope = row.Values.TryGetValue(spec.ColumnName, out var rawEnvelope) ? rawEnvelope as string : null; + if (string.IsNullOrEmpty(envelope)) + return ColumnOutcome.Skipped; + + if (!ProtectedDataEnvelope.TryParse(envelope, out _, out var packedKeyVersion, out _)) + return ProtectedDataEnvelope.HasEnvelopePrefix(envelope) + ? throw new CryptographicException("Corrupt envelope on the decrypt path.") + : ColumnOutcome.Anomalous; + + var packedDek = await resolveDekAsync(packedKeyVersion); + if (packedDek == null) + throw new InvalidOperationException($"No key row for envelope version {packedKeyVersion}."); + + var plaintext = _cryptoService.DecryptText(packedDek, envelope, context.DepartmentId, spec.FieldId, row.RowKey); + foreach (var column in RmsRecordValuePack.UnpackColumns(plaintext)) + setValues[column.Key] = column.Value; + setValues[spec.ColumnName] = null; + return ColumnOutcome.Changed; + } + case ProtectedFieldStorageKind.Binary: { var value = row.Values.TryGetValue(spec.ColumnName, out var raw) ? raw as byte[] : null; diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 40fbcc00..5a6f0256 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -59,6 +59,12 @@ public class ProtectedFieldCatalog : IProtectedFieldCatalog /// public const int RecordsCatalogVersion = 10; + /// + /// Catalog version the typed values of department definitions were added in (RMS-1B, plan section 5.9.4 (e)): + /// one PackedJson field per RmsRecordValues row, sealed only where the field is Protected-classified. + /// + public const int RecordsTypedValuesCatalogVersion = 11; + private static readonly IReadOnlyList Entries = BuildV1(); private static readonly Dictionary ById = Entries.ToDictionary(e => e.FieldId, StringComparer.OrdinalIgnoreCase); @@ -533,6 +539,15 @@ void Records(string table, string column, ProtectedFieldClassification classific // A rendered department export inherits the highest classification of what it carried. Records("RmsExportRuns", "Data", ProtectedFieldClassification.Phi, ProtectedFieldStorageKind.Binary); + // ---- Records typed values (RMS-1B), catalog v11 ------------------------------------- + // A department definition's Protected-classified field values: the row's typed columns are packed into one + // sealed envelope (PackedJson). Standard and Restricted values stay plaintext; ProtectionRequired scopes the + // sweep. Classification is Sensitive at the catalog level because the definition author picked Protected + // without naming PII/PHI; RecordRestricted_View still decides who sees the revealed cell. + list.Add(new ProtectedFieldDefinition(RmsProtectedFields.ValueFieldId, RmsProtectedFields.Family, "RmsRecordValues", "ProtectedEnvelope", + ProtectedFieldStorageKind.PackedJson, ProtectedFieldClassification.Sensitive, + PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, RecordsTypedValuesCatalogVersion)); + return list; } } diff --git a/Core/Resgrid.Services/Records/Evidence/PackProjectionEvidenceAdapter.cs b/Core/Resgrid.Services/Records/Evidence/PackProjectionEvidenceAdapter.cs new file mode 100644 index 00000000..8ed6ffce --- /dev/null +++ b/Core/Resgrid.Services/Records/Evidence/PackProjectionEvidenceAdapter.cs @@ -0,0 +1,218 @@ +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.Evidence +{ + /// + /// The projection kinds an operational pack composes from owning modules (RMS plan RMS-1C, "compose ... from their + /// owning modules with immutable references"). Each is a bounded snapshot with source identifiers; none hydrates a live + /// source or becomes a second copy of it. + /// + public static class RecordPackProjectionKinds + { + /// Who was on the record and their current status/staffing at capture (CERT/EOC/deployment check-in). + public const string PersonnelCheckIn = "personnel-check-in"; + /// Units on the record plus the inventory ledger usage the record already references (equipment/resource summary). + public const string ResourceSummary = "resource-summary"; + /// Certification codes/status/validity for the record's participants; never numbers or documents. + public const string Qualifications = "qualifications"; + /// The active command structure for the record's Call: roles, assignments, objectives, needs (IAP/ICS inputs). + public const string CommandSummary = "command-summary"; + public static readonly IReadOnlyList All = new[] { PersonnelCheckIn, ResourceSummary, Qualifications, CommandSummary }; + public static bool IsKnown(string kind) => kind != null && All.Contains(kind.Trim().ToLowerInvariant()); + } + + /// + /// Composes an operational pack's module projections into one evidence artifact per kind (RMS-1C). The projection kind + /// rides in [0]; the manifest carries only identifiers, codes, + /// counts and statuses from the owning module so the artifact is minimum-necessary by construction. + /// + public class PackProjectionEvidenceAdapter : IRecordEvidenceAdapter + { + public const string SourceSubsystem = "Modules"; + + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsRecordParticipantsRepository _participants; + private readonly IRmsRecordUnitResponsesRepository _unitResponses; + private readonly IUserStateService _userStates; + private readonly IDepartmentsService _departments; + private readonly ICertificationService _certifications; + private readonly IRmsInventoryUsageAdapter _inventory; + private readonly IUnitsService _units; + private readonly IIncidentCommandService _command; + private readonly Lazy _authorization; + + public PackProjectionEvidenceAdapter(IRmsOperationalRecordsRepository records, IRmsRecordParticipantsRepository participants, IRmsRecordUnitResponsesRepository unitResponses, + IUserStateService userStates, IDepartmentsService departments, ICertificationService certifications, IRmsInventoryUsageAdapter inventory, IUnitsService units, + IIncidentCommandService command, Lazy authorization) + { + _records = records; + _participants = participants; + _unitResponses = unitResponses; + _userStates = userStates; + _departments = departments; + _certifications = certifications; + _inventory = inventory; + _units = units; + _command = command; + _authorization = authorization; + } + + public RmsEvidenceKind Kind => RmsEvidenceKind.ModuleProjection; + + public Task IsAvailableAsync(int departmentId) => Task.FromResult(true); + + public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) + { + var kind = request.SourceIds?.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s))?.Trim().ToLowerInvariant(); + if (!RecordPackProjectionKinds.IsKnown(kind)) + throw new ArgumentException("Choose a module projection: " + string.Join(", ", RecordPackProjectionKinds.All) + "."); + if (request.RecordKind != RmsRecordKind.Operational) + return RecordEvidenceCapture.Unavailable("Module projections compose onto operational Records only."); + var record = await _records.GetByIdForDepartmentAsync(request.DepartmentId, request.RecordId); + if (record == null) + return RecordEvidenceCapture.Unavailable("The Record was not found."); + + switch (kind) + { + case RecordPackProjectionKinds.PersonnelCheckIn: return await PersonnelCheckInAsync(request, record, cancellationToken); + case RecordPackProjectionKinds.ResourceSummary: return await ResourceSummaryAsync(request, record, cancellationToken); + case RecordPackProjectionKinds.Qualifications: return await QualificationsAsync(request, record, cancellationToken); + default: return await CommandSummaryAsync(request, record, cancellationToken); + } + } + + private async Task> ParticipantsAsync(RecordEvidenceCaptureRequest request) + { + var rows = (await _participants.GetForRecordAsync(request.DepartmentId, request.RecordId, null))?.Where(p => !string.IsNullOrWhiteSpace(p.UserId)).ToList() ?? new List(); + var chosen = (request.UserIds ?? new List()).Where(u => !string.IsNullOrWhiteSpace(u)).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (chosen.Count > 0) rows = rows.Where(p => chosen.Contains(p.UserId)).ToList(); + if (rows.Count > EvidenceLimits.MaxItems) throw new ArgumentException("The Record has too many participants for one projection; select a subset."); + return rows; + } + + private async Task RequirePeopleAsync(RecordEvidenceCaptureRequest request, IEnumerable userIds) + { + foreach (var userId in userIds.Distinct(StringComparer.OrdinalIgnoreCase)) + if (!await _authorization.Value.CanUserViewPersonAsync(request.CapturedByUserId, userId, request.DepartmentId)) + throw new UnauthorizedAccessException("Personnel source access is not authorized."); + } + + private async Task PersonnelCheckInAsync(RecordEvidenceCaptureRequest request, RmsOperationalRecord record, CancellationToken cancellationToken) + { + var participants = await ParticipantsAsync(request); + if (participants.Count == 0) return RecordEvidenceCapture.Unavailable("Personnel check-in needs at least one participant on the Record."); + await RequirePeopleAsync(request, participants.Select(p => p.UserId)); + var names = (await _departments.GetAllPersonnelNamesForDepartmentAsync(request.DepartmentId) ?? new List()).ToDictionary(n => n.UserId, n => n.Name, StringComparer.OrdinalIgnoreCase); + var now = DateTime.UtcNow; + var people = new List(); + foreach (var participant in participants) + { + cancellationToken.ThrowIfCancellationRequested(); + var state = await _userStates.GetLastUserStateByUserIdAsync(participant.UserId); + people.Add(new + { + user_id = participant.UserId, name = participant.DisplayNameSnapshot ?? (names.TryGetValue(participant.UserId, out var n) ? n : null), role = participant.Role, + group_id = participant.GroupIdSnapshot, group = participant.GroupNameSnapshot, unit_id = participant.UnitId, + participation_start = participant.ParticipationStart, participation_end = participant.ParticipationEnd, + status = state == null ? null : new { state_id = state.State, changed_on = state.Timestamp, source_id = state.UserId + ":" + state.Timestamp.ToString("O") } + }); + } + return new RecordEvidenceCapture + { + Title = "Personnel check-in", SourceSubsystem = SourceSubsystem, SourceEntityType = "personnel-check-in", SourceEntityId = RecordPackProjectionKinds.PersonnelCheckIn, + IdentifierScheme = "resgrid:user", CoverageStart = record.StartedOn, CoverageEnd = now, SourceItemCount = people.Count, Classification = RmsEvidenceClassification.Unrestricted, + Manifest = new { projection = RecordPackProjectionKinds.PersonnelCheckIn, record_id = record.RmsOperationalRecordId, captured_on = now, people } + }; + } + + private async Task ResourceSummaryAsync(RecordEvidenceCaptureRequest request, RmsOperationalRecord record, CancellationToken cancellationToken) + { + var responses = (await _unitResponses.GetForRecordAsync(request.DepartmentId, request.RecordId, null))?.ToList() ?? new List(); + var usage = (await _inventory.GetUsageForRecordAsync(request.DepartmentId, request.RecordId))?.ToList() ?? new List(); + if (responses.Count == 0 && usage.Count == 0) return RecordEvidenceCapture.Unavailable("The Record names no units and references no inventory usage yet."); + if (responses.Count + usage.Count > EvidenceLimits.MaxItems) throw new ArgumentException("The Record references too many resources for one projection."); + var units = new List(); + foreach (var response in responses) + { + cancellationToken.ThrowIfCancellationRequested(); + var unit = response.UnitId > 0 ? await _units.GetUnitByIdAsync(response.UnitId) : null; + if (unit != null && unit.DepartmentId != request.DepartmentId) unit = null; + units.Add(new { unit_id = response.UnitId, name = response.UnitNameSnapshot ?? unit?.Name, type = unit?.Type, station_group_id = unit?.StationGroupId, dispatched = response.Dispatched, enroute = response.Enroute, on_scene = response.OnScene, released = response.Released, in_quarters = response.InQuarters }); + } + var now = DateTime.UtcNow; + return new RecordEvidenceCapture + { + Title = "Equipment and resource summary", SourceSubsystem = SourceSubsystem, SourceEntityType = "resource-summary", SourceEntityId = RecordPackProjectionKinds.ResourceSummary, + IdentifierScheme = "resgrid:unit", CoverageStart = record.StartedOn, CoverageEnd = record.EndedOn ?? now, SourceItemCount = responses.Count + usage.Count, Classification = RmsEvidenceClassification.Unrestricted, + Manifest = new + { + projection = RecordPackProjectionKinds.ResourceSummary, record_id = record.RmsOperationalRecordId, captured_on = now, units, + inventory_usage = usage.Select(u => new { source_id = u.ReferenceId, inventory_id = u.InventoryId, item = u.ItemName, quantity = u.Quantity, unit_of_measure = u.UnitOfMeasure, recorded_on = u.CapturedOn, source_checksum = u.SourceChecksum }).ToList() + } + }; + } + + private async Task QualificationsAsync(RecordEvidenceCaptureRequest request, RmsOperationalRecord record, CancellationToken cancellationToken) + { + var participants = await ParticipantsAsync(request); + if (participants.Count == 0) return RecordEvidenceCapture.Unavailable("Qualifications need at least one participant on the Record."); + await RequirePeopleAsync(request, participants.Select(p => p.UserId)); + var asOf = request.CoverageEnd ?? record.StartedOn ?? DateTime.UtcNow; + var people = new List(); + var total = 0; + foreach (var userId in participants.Select(p => p.UserId).Distinct(StringComparer.OrdinalIgnoreCase)) + { + cancellationToken.ThrowIfCancellationRequested(); + var certifications = (await _certifications.GetCertificationsByUserIdAsync(userId))?.Where(c => c != null && c.DepartmentId == request.DepartmentId).ToList() ?? new List(); + total += certifications.Count; + if (total > EvidenceLimits.MaxItems) throw new ArgumentException("The participants hold too many certifications for one projection."); + people.Add(new + { + user_id = userId, + qualifications = certifications.Select(c => new { source_id = c.PersonnelCertificationId, type = c.Type, name = c.Name, area = c.Area, issued_by = c.IssuedBy, expires_on = c.ExpiresOn, + valid_as_of = (!c.RecievedOn.HasValue || c.RecievedOn.Value <= asOf) && (!c.ExpiresOn.HasValue || c.ExpiresOn.Value >= asOf) }).ToList() + }); + } + return new RecordEvidenceCapture + { + Title = "Qualifications", SourceSubsystem = SourceSubsystem, SourceEntityType = "qualifications", SourceEntityId = RecordPackProjectionKinds.Qualifications, + IdentifierScheme = "resgrid:personnelcertification", CoverageEnd = asOf, SourceItemCount = total, + // Certification standing is Restricted in every pack's protected-data policy; numbers and documents never leave Certifications. + Classification = RmsEvidenceClassification.Restricted, + Manifest = new { projection = RecordPackProjectionKinds.Qualifications, record_id = record.RmsOperationalRecordId, as_of = asOf, people } + }; + } + + private async Task CommandSummaryAsync(RecordEvidenceCaptureRequest request, RmsOperationalRecord record, CancellationToken cancellationToken) + { + var callId = request.CallId ?? record.CallId; + if (!callId.HasValue) return RecordEvidenceCapture.Unavailable("A command summary needs the Record's Call."); + var board = await _command.GetCommandBoardAsync(request.DepartmentId, callId.Value); + if (board?.Command == null) return RecordEvidenceCapture.Unavailable("No incident command was established for this Call."); + var command = board.Command; + var now = DateTime.UtcNow; + return new RecordEvidenceCapture + { + Title = "Incident command summary", SourceSubsystem = "IncidentCommand", SourceEntityType = "incident-command", SourceEntityId = command.IncidentCommandId, + IdentifierScheme = "resgrid:incidentcommand", CoverageStart = command.EstablishedOn, CoverageEnd = command.EstimatedEndOn ?? now, + SourceItemCount = (board.Nodes?.Count ?? 0) + (board.Assignments?.Count ?? 0) + (board.Objectives?.Count ?? 0) + (board.Needs?.Count ?? 0), + Classification = RmsEvidenceClassification.Unrestricted, + Manifest = new + { + projection = RecordPackProjectionKinds.CommandSummary, record_id = record.RmsOperationalRecordId, captured_on = now, + command = new { command.IncidentCommandId, command.CallId, command.Name, command.EstablishedOn, command.EstablishedByUserId, command.CurrentCommanderUserId, command.IcsLevel, command.EstimatedEndOn, command_post = command.CommandPostLocationText, staging = command.StagingLocationText }, + structure_nodes = board.Nodes?.Count ?? 0, assignments = board.Assignments?.Count ?? 0, objectives = board.Objectives?.Count ?? 0, needs = board.Needs?.Count ?? 0, timers = board.Timers?.Count ?? 0, + node_ids = (board.Nodes ?? new List()).Select(n => n.CommandStructureNodeId).ToList(), + objective_ids = (board.Objectives ?? new List()).Select(o => o.TacticalObjectiveId).ToList() + } + }; + } + } +} diff --git a/Core/Resgrid.Services/Records/FieldRecordsService.cs b/Core/Resgrid.Services/Records/FieldRecordsService.cs new file mode 100644 index 00000000..48038b83 --- /dev/null +++ b/Core/Resgrid.Services/Records/FieldRecordsService.cs @@ -0,0 +1,582 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Field Records for the four operational apps (RMS plan RMS-1D). Everything here is computed from the + /// authenticated principal and the department: the app flag, the minimum app version, the renderer capability + /// the client reports, the verified Call/Unit/group/command context, the Protected Data state, and each + /// definition version's own client surface. A client that sends a different origin, context or capability + /// gets a narrower catalog, never a wider one, and every sync row is re-authorized at read time. + /// + public class FieldRecordsService : IFieldRecordsService + { + private readonly IRecordsCutoverService _cutover; + private readonly IRecordsAuthorizationService _authorization; + private readonly IFeatureToggleService _flags; + private readonly IRecordDefinitionsService _definitions; + private readonly IDepartmentDataProtectionService _protection; + private readonly IRecordsService _records; + private readonly IRecordWorkAssignmentsService _assignments; + private readonly IUnitsService _units; + private readonly IDepartmentGroupsService _groups; + private readonly ICallsService _calls; + private readonly IIncidentCommandService _command; + + public FieldRecordsService(IRecordsCutoverService cutover, IRecordsAuthorizationService authorization, IFeatureToggleService flags, IRecordDefinitionsService definitions, + IDepartmentDataProtectionService protection, IRecordsService records, IRecordWorkAssignmentsService assignments, IUnitsService units, IDepartmentGroupsService groups, + ICallsService calls, IIncidentCommandService command) + { + _cutover = cutover; + _authorization = authorization; + _flags = flags; + _definitions = definitions; + _protection = protection; + _records = records; + _assignments = assignments; + _units = units; + _groups = groups; + _calls = calls; + _command = command; + } + + #region Preflight + + public async Task PreflightAsync(int departmentId, string userId, RmsOriginClient origin, string appVersion, string clientCapability) + { + var preflight = new FieldRecordPreflight + { + Origin = origin, + AppVersion = appVersion, + ClientCapability = NormalizeCapability(clientCapability), + MinimumAppVersion = MinimumVersionFor(origin), + ServerTimestampMs = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds() + }; + + if (!FieldRecordCatalogV1.IsFieldOrigin(origin)) + { + preflight.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.OriginNotField); + return preflight; + } + + var moduleState = await _cutover.GetModuleStateAsync(departmentId); + preflight.ModuleEnabled = moduleState.FlagEnabled; + preflight.RecordsUsable = moduleState.RecordsUsable; + if (!moduleState.FlagEnabled) preflight.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.ModuleDisabled); + else if (!moduleState.RecordsUsable) preflight.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.RecordsNotUsable); + + preflight.AppEnabled = moduleState.FlagEnabled && await _flags.IsEnabledAsync(RecordsApiFlagFor(origin), departmentId); + if (!preflight.AppEnabled) preflight.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.AppDisabled); + + if (!await _authorization.IsActiveMemberAsync(userId, departmentId)) preflight.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.NotMember); + if (!FieldRecordCatalogV1.MeetsMinimum(appVersion, preflight.MinimumAppVersion)) preflight.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.AppVersionTooOld); + + preflight.ProtectionState = await ProtectionStateAsync(departmentId); + preflight.Ok = preflight.Reasons.Count == 0; + return preflight; + } + + #endregion + + #region Context + + public async Task VerifyContextAsync(int departmentId, string userId, RmsOriginClient origin, FieldRecordContext context) + { + context ??= new FieldRecordContext(); + var verification = FieldRecordContextVerification.Allowed(); + if (context.IsEmpty) + return verification; + + if (context.CallId.HasValue) + { + var call = await _calls.GetCallByIdAsync(context.CallId.Value); + if (call == null || call.DepartmentId != departmentId || !await _authorization.CanReadSourceCallAsync(userId, departmentId, call)) + return FieldRecordContextVerification.Denied(FieldRecordCatalogV1.ExclusionReasons.ContextNotVerified); + verification.CallNumber = call.Number; + } + + if (context.UnitId.HasValue) + { + var unit = await _units.GetUnitByIdAsync(context.UnitId.Value); + if (unit == null || unit.DepartmentId != departmentId) + return FieldRecordContextVerification.Denied(FieldRecordCatalogV1.ExclusionReasons.ContextNotVerified); + verification.UnitName = unit.Name; + // The Unit app authors on the apparatus the caller is actually staffed on; every other app may + // reference a unit it can see but never claims crew membership from the client's word. + var state = await _units.GetLastUnitStateByUnitIdAsync(unit.UnitId); + verification.StaffedOnUnit = state?.Roles != null && state.Roles.Any(r => string.Equals(r.UserId, userId, StringComparison.OrdinalIgnoreCase)); + if (origin == RmsOriginClient.Unit && !verification.StaffedOnUnit) + return FieldRecordContextVerification.Denied(FieldRecordCatalogV1.ExclusionReasons.ContextNotVerified); + } + + if (context.GroupId.HasValue) + { + var group = await _groups.GetGroupByIdAsync(context.GroupId.Value); + if (group == null || group.DepartmentId != departmentId) + return FieldRecordContextVerification.Denied(FieldRecordCatalogV1.ExclusionReasons.ContextNotVerified); + var visible = await _authorization.GetVisibleGroupIdsAsync(userId, departmentId); + if (visible != null && !visible.Contains(group.DepartmentGroupId)) + return FieldRecordContextVerification.Denied(FieldRecordCatalogV1.ExclusionReasons.ContextNotAllowed); + verification.GroupName = group.Name; + } + + if (!string.IsNullOrWhiteSpace(context.CommandRole)) + { + // A command role is never taken on the client's word: it must exist on the Call's active command. + if (!context.CallId.HasValue) + return FieldRecordContextVerification.Denied(FieldRecordCatalogV1.ExclusionReasons.ContextNotVerified); + var command = await _command.GetActiveCommandForCallAsync(departmentId, context.CallId.Value); + if (command == null) + return FieldRecordContextVerification.Denied(FieldRecordCatalogV1.ExclusionReasons.ContextNotVerified); + verification.CommandName = command.Name; + verification.HoldsCommandRole = string.Equals(command.CurrentCommanderUserId, userId, StringComparison.OrdinalIgnoreCase); + if (!verification.HoldsCommandRole) + { + var board = await _command.GetCommandBoardAsync(departmentId, context.CallId.Value); + verification.HoldsCommandRole = board?.Nodes != null && board.Nodes.Any(n => string.Equals(n.SupervisorUserId, userId, StringComparison.OrdinalIgnoreCase) + && string.Equals(n.Name, context.CommandRole, StringComparison.OrdinalIgnoreCase)); + } + if (!verification.HoldsCommandRole) + return FieldRecordContextVerification.Denied(FieldRecordCatalogV1.ExclusionReasons.ContextNotVerified); + } + + return verification; + } + + #endregion + + #region Catalog + + public async Task GetCatalogAsync(int departmentId, string userId, FieldRecordCatalogRequest request) + { + request ??= new FieldRecordCatalogRequest(); + var context = request.Context ?? new FieldRecordContext(); + var capability = NormalizeCapability(request.ClientCapability); + var catalog = new FieldRecordCatalog + { + Origin = request.Origin, + ContextKind = context.Kind, + ServerTimestampMs = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds() + }; + + var preflight = await PreflightAsync(departmentId, userId, request.Origin, request.AppVersion, capability); + catalog.ProtectionState = preflight.ProtectionState; + if (!preflight.Ok) + { + catalog.Reasons.AddRange(preflight.Reasons); + return catalog; + } + + var verification = await VerifyContextAsync(departmentId, userId, request.Origin, context); + catalog.ContextVerified = verification.Ok; + if (!verification.Ok) + { + catalog.Reasons.AddRange(verification.Reasons); + return catalog; + } + + catalog.ScopeStamp = await _authorization.GetReadScopeStampAsync(userId, departmentId); + if (catalog.ScopeStamp == null) + { + catalog.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.NotMember); + return catalog; + } + + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord)) + { + // A member who cannot author still gets an empty catalog rather than an error: the app shows read-only. + catalog.Ok = true; + return catalog; + } + + var enforced = string.Equals(preflight.ProtectionState, DepartmentDataProtectionState.Enabled.ToString(), StringComparison.Ordinal) + || string.Equals(preflight.ProtectionState, DepartmentDataProtectionState.Rotating.ToString(), StringComparison.Ordinal); + + AddLockedStarters(catalog, request.Origin, context, capability); + await AddDepartmentDefinitionsAsync(departmentId, catalog, request.Origin, request.AppVersion, capability, context, enforced); + + catalog.Definitions = catalog.Definitions.OrderBy(d => d.Locked ? 1 : 0).ThenBy(d => d.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); + catalog.Ok = true; + return catalog; + } + + /// The locked starter allowlist for the app: system definitions a field client may always start. + private static void AddLockedStarters(FieldRecordCatalog catalog, RmsOriginClient origin, FieldRecordContext context, string capability) + { + foreach (var key in FieldRecordCatalogV1.LockedStarterAllowlist(origin)) + { + var contexts = FieldRecordCatalogV1.LockedLaunchContexts(key); + if (!contexts.Contains(context.Kind, StringComparer.OrdinalIgnoreCase)) + { + catalog.Exclusions.Add(new FieldRecordCatalogExclusion { DefinitionKey = key, Reason = FieldRecordCatalogV1.ExclusionReasons.ContextNotAllowed }); + continue; + } + if (!RecordsClientCapabilities.Satisfies(capability, RecordsClientCapabilities.Locked)) + { + catalog.Exclusions.Add(new FieldRecordCatalogExclusion { DefinitionKey = key, Reason = FieldRecordCatalogV1.ExclusionReasons.CapabilityUnsupported }); + continue; + } + var type = RmsDefinitionKeys.LockedTypes.TryGetValue(key, out var value) ? (RmsOperationalRecordType?)value : null; + catalog.Definitions.Add(new FieldRecordCatalogEntry + { + DefinitionKey = key, + Version = RmsDefinitionKeys.LockedDefinitionVersion, + Name = type?.ToString() ?? key, + Category = "System", + Locked = true, + RecordType = type.HasValue ? (int)type.Value : (int?)null, + LifecyclePreset = RmsLifecyclePreset.QuickEntry.ToString(), + LaunchContexts = contexts.ToList(), + AllowOffline = true, + AllowAttachments = true, + MinimumClientCapability = RecordsClientCapabilities.Locked, + Restricted = RmsDefinitionKeys.RestrictedClass.Contains(key), + SupportsPrefill = true, + PrefillVersion = 1 + }); + } + } + + private async Task AddDepartmentDefinitionsAsync(int departmentId, FieldRecordCatalog catalog, RmsOriginClient origin, string appVersion, string capability, FieldRecordContext context, bool protectionEnforced) + { + List published; + List summaries; + try + { + published = await _definitions.GetPublishedAsync(departmentId) ?? new List(); + summaries = await _definitions.ListAsync(departmentId, true) ?? new List(); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex, "Field Records catalog could not list department definitions."); + return; + } + + var byKey = summaries.ToDictionary(s => s.Key, StringComparer.OrdinalIgnoreCase); + foreach (var version in published) + { + var summary = byKey.TryGetValue(version.DefinitionKey, out var found) ? found : null; + if (summary != null && summary.Retired) + { + catalog.Exclusions.Add(new FieldRecordCatalogExclusion { DefinitionKey = version.DefinitionKey, Reason = FieldRecordCatalogV1.ExclusionReasons.Retired }); + continue; + } + + var surface = version.ClientSurface ?? new RecordDefinitionClientSurface(); + if (!SurfaceAllows(surface, origin)) + { + catalog.Exclusions.Add(new FieldRecordCatalogExclusion { DefinitionKey = version.DefinitionKey, Reason = FieldRecordCatalogV1.ExclusionReasons.SurfaceNotEnabled }); + continue; + } + + var contexts = surface.LaunchContexts.Count > 0 ? surface.LaunchContexts : new List { FieldRecordCatalogV1.LaunchContexts.None }; + if (!contexts.Contains(context.Kind, StringComparer.OrdinalIgnoreCase)) + { + catalog.Exclusions.Add(new FieldRecordCatalogExclusion { DefinitionKey = version.DefinitionKey, Reason = FieldRecordCatalogV1.ExclusionReasons.ContextNotAllowed }); + continue; + } + + if (!FieldRecordCatalogV1.MeetsMinimum(appVersion, surface.MinimumAppVersion)) + { + catalog.Exclusions.Add(new FieldRecordCatalogExclusion { DefinitionKey = version.DefinitionKey, Reason = FieldRecordCatalogV1.ExclusionReasons.AppVersionTooOld }); + continue; + } + + var required = version.MinimumClientCapability ?? RecordsClientCapabilities.Derive(version.Schema); + if (!RecordsClientCapabilities.Satisfies(capability, required)) + { + catalog.Exclusions.Add(new FieldRecordCatalogExclusion { DefinitionKey = version.DefinitionKey, Reason = FieldRecordCatalogV1.ExclusionReasons.CapabilityUnsupported }); + continue; + } + + var fields = version.Schema?.AllFields()?.ToList() ?? new List(); + var protectedFields = fields.Any(f => f.Classification == RmsFieldClassification.Protected); + if (protectedFields && !protectionEnforced) + { + // A definition that seals values needs an enrolled department; without one the field app would + // have nowhere to put ciphertext and would quietly store plaintext instead. + catalog.Exclusions.Add(new FieldRecordCatalogExclusion { DefinitionKey = version.DefinitionKey, Reason = FieldRecordCatalogV1.ExclusionReasons.ProtectedDataUnavailable }); + continue; + } + + catalog.Definitions.Add(new FieldRecordCatalogEntry + { + DefinitionKey = version.DefinitionKey, + Version = version.Version, + Name = summary?.Name ?? version.DefinitionKey, + Category = summary?.Category, + Locked = false, + LifecyclePreset = ((RmsLifecyclePreset)version.LifecyclePreset).ToString(), + LaunchContexts = contexts.ToList(), + // Protected values never sit in an offline draft, whatever the surface asks for. + AllowOffline = surface.AllowOffline && !protectedFields, + AllowAttachments = surface.AllowAttachments, + MinimumAppVersion = surface.MinimumAppVersion, + MinimumClientCapability = required, + Restricted = fields.Any(f => f.Classification != RmsFieldClassification.Standard), + RequiresProtectedGrant = protectedFields, + SchemaChecksum = version.SchemaChecksum, + SupportsPrefill = true, + PrefillVersion = version.Version + }); + } + } + + private static bool SurfaceAllows(RecordDefinitionClientSurface surface, RmsOriginClient origin) + { + switch (origin) + { + case RmsOriginClient.Responder: return surface.Responder; + case RmsOriginClient.Unit: return surface.Unit; + case RmsOriginClient.IncidentCommand: return surface.IncidentCommand; + case RmsOriginClient.Dispatch: return surface.Dispatch; + default: return false; + } + } + + #endregion + + #region Prefill + + public async Task PrefillAsync(int departmentId, string userId, FieldRecordCatalogRequest request, string definitionKey, int version) + { + request ??= new FieldRecordCatalogRequest(); + var catalog = await GetCatalogAsync(departmentId, userId, request); + if (!catalog.Ok || !catalog.Includes(definitionKey, version)) + throw new UnauthorizedAccessException("The definition is not in this client's catalog for this context."); + + var context = request.Context ?? new FieldRecordContext(); + var entry = catalog.Definitions.First(d => string.Equals(d.DefinitionKey, definitionKey, StringComparison.OrdinalIgnoreCase) && d.Version == version); + var now = DateTime.UtcNow; + var prefill = new FieldRecordPrefill + { + DefinitionKey = entry.DefinitionKey, + Version = entry.Version, + PrefillVersion = entry.PrefillVersion, + CallId = context.CallId, + UnitId = context.UnitId, + CalculatedOn = now + }; + + Call call = null; + if (context.CallId.HasValue) + { + call = await _calls.GetCallByIdAsync(context.CallId.Value); + if (call != null && call.DepartmentId != departmentId) call = null; + } + + Unit unit = null; + if (context.UnitId.HasValue) + { + unit = await _units.GetUnitByIdAsync(context.UnitId.Value); + if (unit != null && unit.DepartmentId != departmentId) unit = null; + } + + var group = context.GroupId.HasValue ? await _groups.GetGroupByIdAsync(context.GroupId.Value) : await _groups.GetGroupForUserAsync(userId, departmentId); + if (group != null && group.DepartmentId == departmentId) prefill.StationGroupId = group.DepartmentGroupId; + + if (unit != null) prefill.SuggestedUnitIds.Add(unit.UnitId); + prefill.SuggestedParticipantUserIds.Add(userId); + + // Locked definitions carry no schema here; the app fills their fixed fields from the same context block. + if (entry.Locked) + return prefill; + + var schemaVersion = await _definitions.GetVersionAsync(departmentId, entry.DefinitionKey, entry.Version); + var schema = schemaVersion?.Schema; + if (schema == null) + return prefill; + + foreach (var section in schema.Sections.Where(s => !s.Repeating)) + { + foreach (var field in section.Fields) + { + // Prefill is minimum-necessary: identity and time only, never a restricted or protected value. + if (field.Classification != RmsFieldClassification.Standard) continue; + switch (field.Type) + { + case RmsFieldType.CallReference when call != null: + Add(prefill, section.Key, field.Key, call.CallId.ToString(), "call", call.CallId.ToString(), now, referenceType: "call", referenceId: call.CallId.ToString()); + break; + case RmsFieldType.Unit when unit != null: + Add(prefill, section.Key, field.Key, unit.UnitId.ToString(), "unit", unit.UnitId.ToString(), now, referenceType: "unit", referenceId: unit.UnitId.ToString()); + break; + case RmsFieldType.Group when group != null: + Add(prefill, section.Key, field.Key, group.DepartmentGroupId.ToString(), "group", group.DepartmentGroupId.ToString(), now, referenceType: "group", referenceId: group.DepartmentGroupId.ToString()); + break; + case RmsFieldType.Person when IsAuthorField(field.Key): + Add(prefill, section.Key, field.Key, userId, "user", userId, now, referenceType: "user", referenceId: userId); + break; + case RmsFieldType.Address when call != null && IsLocationField(field.Key): + Add(prefill, section.Key, field.Key, call.Address, "call.address", call.CallId.ToString(), now); + break; + case RmsFieldType.DateTime when call != null && IsStartField(field.Key): + Add(prefill, section.Key, field.Key, call.LoggedOn.ToString("O"), "call.logged_on", call.CallId.ToString(), now); + break; + case RmsFieldType.DateTime when call == null && IsStartField(field.Key): + Add(prefill, section.Key, field.Key, now.ToString("O"), "now", null, now); + break; + } + } + } + + return prefill; + } + + private static void Add(FieldRecordPrefill prefill, string sectionKey, string fieldKey, string value, string source, string sourceId, DateTime now, string referenceType = null, string referenceId = null) + { + if (string.IsNullOrWhiteSpace(value)) return; + prefill.Values.Add(new RecordValueInput { SectionKey = sectionKey, FieldKey = fieldKey, Value = value, ReferenceType = referenceType, ReferenceId = referenceId }); + prefill.Provenance.Add(new FieldRecordPrefillProvenance { FieldKey = fieldKey, Source = source, SourceId = sourceId, CapturedOn = now }); + } + + private static bool IsAuthorField(string key) => Contains(key, "author", "reported_by", "completed_by", "member", "officer", "recorded_by"); + private static bool IsLocationField(string key) => Contains(key, "location", "address", "scene", "site"); + private static bool IsStartField(string key) => Contains(key, "start", "began", "occurred", "logged", "dispatched", "time"); + private static bool Contains(string key, params string[] needles) => key != null && needles.Any(n => key.IndexOf(n, StringComparison.OrdinalIgnoreCase) >= 0); + + #endregion + + #region Sync + + public async Task SyncAsync(int departmentId, string userId, FieldRecordSyncRequest request, CancellationToken cancellationToken = default) + { + request ??= new FieldRecordSyncRequest(); + var take = Math.Max(1, Math.Min(RecordsFieldConfig.SyncTakeMax, request.Take <= 0 ? RecordsFieldConfig.SyncTakeMax : request.Take)); + var now = DateTime.UtcNow; + var bundle = new FieldRecordSyncBundle { Since = request.Since, ServerTimestampMs = new DateTimeOffset(now).ToUnixTimeMilliseconds() }; + + var catalogRequest = new FieldRecordCatalogRequest { Origin = request.Origin, AppVersion = request.AppVersion, ClientCapability = request.ClientCapability, Context = request.Context }; + var catalog = await GetCatalogAsync(departmentId, userId, catalogRequest); + if (request.IncludeCatalog) bundle.Catalog = catalog; + if (!catalog.Ok) + { + bundle.Reasons.AddRange(catalog.Reasons); + bundle.ResetRequired = request.Since > 0; + bundle.ServerTimestampMs = 0; + return bundle; + } + + bundle.ScopeStamp = catalog.ScopeStamp; + // A scope change invalidates every cached row on the device, catalog included. + if (request.Since > 0 && !string.Equals(request.ScopeStamp, bundle.ScopeStamp, StringComparison.Ordinal)) + { + bundle.ResetRequired = true; + bundle.ServerTimestampMs = 0; + return bundle; + } + + var since = request.Since <= 0 ? (DateTime?)null : DateTimeOffset.FromUnixTimeMilliseconds(request.Since).UtcDateTime; + var rows = await _records.GetChangesSinceAsync(departmentId, since, take + 1, request.SinceId); + bundle.HasMore = rows.Count > take; + var page = rows.Take(take).ToList(); + foreach (var projection in page) + { + cancellationToken.ThrowIfCancellationRequested(); + // The assignment queue narrows; live authorization decides. A row the caller may not read leaves + // as a tombstone id so a previously cached copy is evicted, never as content. + if (projection.DeletedOn.HasValue || !await _authorization.CanUserViewRecordAsync(userId, projection.RmsRecordSearchProjectionId, departmentId)) + bundle.Tombstones.Add(projection.RmsRecordSearchProjectionId); + else + bundle.Changes.Add(projection); + } + + if (bundle.HasMore && page.Count > 0) + { + var last = page[page.Count - 1]; + bundle.ServerTimestampMs = new DateTimeOffset(DateTime.SpecifyKind(last.ModifiedOn, DateTimeKind.Utc)).ToUnixTimeMilliseconds(); + bundle.ServerCursorId = last.RmsRecordSearchProjectionId; + } + else + { + // Replay the final millisecond so a concurrent write in that clock bucket is not skipped. + bundle.ServerTimestampMs = new DateTimeOffset(now).ToUnixTimeMilliseconds() - 1; + } + + bundle.Drafts = await DraftsAsync(departmentId, userId); + bundle.Assignments = await _assignments.GetQueueAsync(departmentId, userId, request.Context, RecordsFieldConfig.AssignmentsMax); + + var finalScope = await _authorization.GetReadScopeStampAsync(userId, departmentId); + if (finalScope == null || !string.Equals(finalScope, bundle.ScopeStamp, StringComparison.Ordinal)) + { + // Policy moved under the read; the whole page is discarded rather than partially trusted. + return new FieldRecordSyncBundle { Since = request.Since, ScopeStamp = finalScope, ResetRequired = true, ServerTimestampMs = 0, Catalog = bundle.Catalog, Ok = finalScope != null }; + } + + bundle.Ok = true; + return bundle; + } + + private async Task> DraftsAsync(int departmentId, string userId) + { + var query = new RmsRecordQuery + { + OwnerUserId = userId, + ViewerUserId = userId, + States = new List { (int)RmsRecordState.Draft, (int)RmsRecordState.Returned, (int)RmsRecordState.ReadyForReview }, + VisibleGroupIds = await _authorization.GetVisibleGroupIdsAsync(userId, departmentId), + Take = RecordsFieldConfig.SyncDraftsMax + }; + return await _records.QueryAsync(departmentId, query) ?? new List(); + } + + #endregion + + #region Helpers + + private static string RecordsApiFlagFor(RmsOriginClient origin) + { + switch (origin) + { + case RmsOriginClient.Responder: return FeatureFlagKeys.RecordsFieldResponder; + case RmsOriginClient.Unit: return FeatureFlagKeys.RecordsFieldUnit; + case RmsOriginClient.IncidentCommand: return FeatureFlagKeys.RecordsFieldIncidentCommand; + case RmsOriginClient.Dispatch: return FeatureFlagKeys.RecordsFieldDispatch; + default: return FeatureFlagKeys.RecordsSystem; + } + } + + private static string MinimumVersionFor(RmsOriginClient origin) + { + switch (origin) + { + case RmsOriginClient.Responder: return RecordsFieldConfig.MinimumResponderVersion; + case RmsOriginClient.Unit: return RecordsFieldConfig.MinimumUnitVersion; + case RmsOriginClient.IncidentCommand: return RecordsFieldConfig.MinimumIncidentCommandVersion; + case RmsOriginClient.Dispatch: return RecordsFieldConfig.MinimumDispatchVersion; + default: return null; + } + } + + /// An unknown or missing capability is the oldest one, never the newest: a client is never assumed able. + private static string NormalizeCapability(string capability) + { + var trimmed = (capability ?? string.Empty).Trim().ToLowerInvariant(); + return RecordsClientCapabilities.Rank(trimmed) >= 0 ? trimmed : RecordsClientCapabilities.Locked; + } + + private async Task ProtectionStateAsync(int departmentId) + { + try + { + var policy = await _protection.GetPolicyByDepartmentIdAsync(departmentId); + return policy == null ? DepartmentDataProtectionState.Disabled.ToString() : ((DepartmentDataProtectionState)policy.State).ToString(); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex, "Field Records preflight could not read the Protected Data policy."); + return DepartmentDataProtectionState.Disabled.ToString(); + } + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/IncidentAnalysisService.cs b/Core/Resgrid.Services/Records/IncidentAnalysisService.cs index f7e157bd..a1a3ba9d 100644 --- a/Core/Resgrid.Services/Records/IncidentAnalysisService.cs +++ b/Core/Resgrid.Services/Records/IncidentAnalysisService.cs @@ -488,6 +488,7 @@ private async Task> ReplaceModulesAsync(RmsIncidentAnaly return (await _modules.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, null))?.ToList() ?? new List(); var existingRows = (await _modules.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, null))?.OrderBy(m => m.Ordinal).ToList() ?? new List(); + var match = IncidentReportsService.SectionMatcher(existingRows, inputs.Select(i => i.ModuleId), m => m.RmsIncidentModuleId, "section"); await _modules.DeleteDraftForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, cancellationToken); var result = new List(); var ordinal = 0; @@ -498,7 +499,7 @@ private async Task> ReplaceModulesAsync(RmsIncidentAnaly if (descriptor == null || !descriptor.BelongsToAnalysis) continue; - var existing = existingRows.ElementAtOrDefault(ordinal); + var existing = match(ordinal, input.ModuleId); var row = new RmsIncidentModule { RmsIncidentModuleId = existing?.RmsIncidentModuleId ?? Guid.NewGuid().ToString(), DepartmentId = analysis.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), diff --git a/Core/Resgrid.Services/Records/IncidentReportsService.cs b/Core/Resgrid.Services/Records/IncidentReportsService.cs index 486dd302..7b499127 100644 --- a/Core/Resgrid.Services/Records/IncidentReportsService.cs +++ b/Core/Resgrid.Services/Records/IncidentReportsService.cs @@ -1237,6 +1237,7 @@ private async Task> ReplaceModulesAsync(RmsIncidentRepor return (await _modules.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.ToList() ?? new List(); var existingRows = (await _modules.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.OrderBy(m => m.Ordinal).ToList() ?? new List(); + var match = SectionMatcher(existingRows, inputs.Select(i => i.ModuleId), m => m.RmsIncidentModuleId, "section"); await _modules.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); var result = new List(); var ordinal = 0; @@ -1248,7 +1249,7 @@ private async Task> ReplaceModulesAsync(RmsIncidentRepor if (descriptor == null || descriptor.BelongsToAnalysis) continue; - var existing = existingRows.ElementAtOrDefault(ordinal); + var existing = match(ordinal, input.ModuleId); var row = new RmsIncidentModule { RmsIncidentModuleId = existing?.RmsIncidentModuleId ?? Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), @@ -1271,12 +1272,14 @@ private async Task> ReplaceResourcesAsync(RmsIncidentR return (await _resources.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.ToList() ?? new List(); var existingRows = (await _resources.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.OrderBy(r => r.Ordinal).ToList() ?? new List(); + var kept = inputs.Where(i => !string.IsNullOrWhiteSpace(i.ResourceCode)).ToList(); + var match = SectionMatcher(existingRows, kept.Select(i => i.ResourceId), r => r.RmsIncidentResourceId, "resource"); await _resources.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); var result = new List(); var ordinal = 0; - foreach (var input in inputs.Where(i => !string.IsNullOrWhiteSpace(i.ResourceCode))) + foreach (var input in kept) { - var existing = existingRows.ElementAtOrDefault(ordinal); + var existing = match(ordinal, input.ResourceId); var row = new RmsIncidentResource { RmsIncidentResourceId = existing?.RmsIncidentResourceId ?? Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), @@ -1372,12 +1375,13 @@ private async Task> ReplaceExposuresAsync(RmsIncidentReport re return (await _exposures.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.ToList() ?? new List(); var existingRows = (await _exposures.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.OrderBy(e => e.Ordinal).ToList() ?? new List(); + var match = SectionMatcher(existingRows, inputs.Select(i => i.ExposureId), e => e.RmsExposureId, "exposure"); await _exposures.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); var result = new List(); var ordinal = 0; foreach (var input in inputs) { - var existing = existingRows.ElementAtOrDefault(ordinal); + var existing = match(ordinal, input.ExposureId); var row = new RmsExposure { RmsExposureId = existing?.RmsExposureId ?? Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), @@ -1399,6 +1403,27 @@ private async Task> ReplaceExposuresAsync(RmsIncidentReport re return result; } + /// + /// Resolves the stored row a replaced RMS-3 section input stands for. When the client sends row + /// identifiers the match is by identity, and an identifier outside the draft (or sent twice) fails the + /// save the way ReplaceCasualtiesAsync does — reordering or deleting a row must never copy another + /// row's id, ProtectionId or sealed envelopes onto different content. A client that sends no identifiers + /// at all keeps the historical positional match, so an app build that predates the field still round-trips + /// its rows instead of re-keying every one of them on each save. + /// + internal static Func SectionMatcher(List existingRows, IEnumerable suppliedIds, Func idOf, string what) where T : class + { + var supplied = (suppliedIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).ToList(); + if (supplied.Count == 0) + return (ordinal, id) => existingRows.ElementAtOrDefault(ordinal); + + var byId = existingRows.ToDictionary(idOf, StringComparer.Ordinal); + if (supplied.Distinct(StringComparer.Ordinal).Count() != supplied.Count || supplied.Any(id => !byId.ContainsKey(id))) + throw new ArgumentException($"A {what} row does not belong to this draft or was supplied more than once."); + + return (ordinal, id) => string.IsNullOrWhiteSpace(id) ? null : byId[id]; + } + /// Upper-cased, de-duplicated value-set codes as the comma-separated form the columns store. private static string JoinCodes(List codes) { diff --git a/Core/Resgrid.Services/Records/RecordDefinitionsService.cs b/Core/Resgrid.Services/Records/RecordDefinitionsService.cs new file mode 100644 index 00000000..3ad2a961 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordDefinitionsService.cs @@ -0,0 +1,949 @@ +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 +{ + /// + /// Department definition management (RMS plan sections 4.1 and 5.4, RMS-1B). A definition has a stable key and + /// immutable published versions; editing opens a new draft, publishing freezes it (checksum, capability floor, + /// materialized field rows) and raises trigger 113; retiring stops new Records and raises 114. Locked system + /// definitions are listed alongside but never editable here. Every write needs ManageRecordDefinitions; publish + /// and retire also need PublishRecordDefinitions. + /// + public class RecordDefinitionsService : IRecordDefinitionsService + { + public const string DefinitionAggregate = "RmsRecordDefinition"; + private static readonly string[] KnownSubjectTypes = { "call", "unit", "group", "contact", "person", "checklist", "workorder", "inventory", "none" }; + + private readonly IRmsRecordDefinitionsRepository _definitions; + private readonly IRmsRecordDefinitionVersionsRepository _versions; + private readonly IRmsRecordSectionDefinitionsRepository _sections; + private readonly IRmsRecordFieldDefinitionsRepository _fields; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsRecordValuesRepository _values; + private readonly IRecordTypedValuesService _typedValues; + private readonly IRecordTemplatePacksService _templates; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsProtectionService _protection; + private readonly IDomainEventOutboxService _outbox; + private readonly IFeatureToggleService _featureToggles; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IUnitOfWork _unitOfWork; + + public RecordDefinitionsService(IRmsRecordDefinitionsRepository definitions, IRmsRecordDefinitionVersionsRepository versions, IRmsRecordSectionDefinitionsRepository sections, + IRmsRecordFieldDefinitionsRepository fields, IRmsOperationalRecordsRepository records, IRmsRecordValuesRepository values, IRecordTypedValuesService typedValues, + IRecordTemplatePacksService templates, IRecordsAuthorizationService authorization, IRecordsProtectionService protection, IDomainEventOutboxService outbox, + IFeatureToggleService featureToggles, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork) + { + _definitions = definitions; + _versions = versions; + _sections = sections; + _fields = fields; + _records = records; + _values = values; + _typedValues = typedValues; + _templates = templates; + _authorization = authorization; + _protection = protection; + _outbox = outbox; + _featureToggles = featureToggles; + _audits = audits; + _unitOfWork = unitOfWork; + } + + // ------------------------------------------------------------------------------------------------ + // Reads + // ------------------------------------------------------------------------------------------------ + + public async Task> ListAsync(int departmentId, bool includeRetired = false) + { + var list = RecordDefinitionCatalog.Describe().Select(d => new RecordDefinitionSummary + { + Key = d.Key, Name = d.Name, Category = "System", Owner = RmsDefinitionOwner.System.ToString(), Locked = true, PublishedVersion = d.Version, + LifecyclePreset = d.LifecyclePresetName, MinimumClientCapability = d.MinimumClientCapability, ArtifactStatus = RmsArtifactStatus.Exact.ToString() + }).ToList(); + + var definitions = (await _definitions.GetForDepartmentAsync(departmentId, includeRetired))?.ToList() ?? new List(); + foreach (var definition in definitions.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)) + { + var versions = (await _versions.GetForDefinitionAsync(departmentId, definition.DefinitionKey))?.ToList() ?? new List(); + var published = versions.FirstOrDefault(v => v.IsPublished && v.Version == definition.CurrentPublishedVersion); + var draft = versions.Where(v => v.IsDraft).OrderByDescending(v => v.Version).FirstOrDefault(); + var latest = published ?? draft ?? versions.OrderByDescending(v => v.Version).FirstOrDefault(); + var template = definition.TemplateKey == null ? null : _templates.GetTemplate(definition.TemplateKey); + list.Add(new RecordDefinitionSummary + { + 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(), + 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() + }); + } + return list; + } + + public async Task> GetPublishedAsync(int departmentId) + { + var definitions = (await _definitions.GetForDepartmentAsync(departmentId, false))?.ToDictionary(d => d.DefinitionKey, StringComparer.OrdinalIgnoreCase) ?? new Dictionary(); + var versions = (await _versions.GetPublishedForDepartmentAsync(departmentId))?.ToList() ?? new List(); + return versions.Where(v => definitions.TryGetValue(v.DefinitionKey, out var d) && !d.IsRetired && d.CurrentPublishedVersion == v.Version).OrderBy(v => v.DefinitionKey, StringComparer.Ordinal).ToList(); + } + + public async Task GetAsync(int departmentId, string definitionKey) + { + var definition = await _definitions.GetByKeyAsync(departmentId, RecordDefinitionKeys.NormalizeKey(definitionKey)); + if (definition == null) return null; + return new RecordDefinitionAggregate + { + Definition = definition, + Versions = (await _versions.GetForDefinitionAsync(departmentId, definition.DefinitionKey))?.OrderBy(v => v.Version).ToList() ?? new List() + }; + } + + public Task GetVersionAsync(int departmentId, string definitionKey, int version) + => _versions.GetAsync(departmentId, RecordDefinitionKeys.NormalizeKey(definitionKey), version); + + public Task GetVersionByIdAsync(int departmentId, string versionId) => _versions.GetByIdForDepartmentAsync(departmentId, versionId); + + public async Task GetCurrentPublishedAsync(int departmentId, string definitionKey) + { + var definition = await _definitions.GetByKeyAsync(departmentId, RecordDefinitionKeys.NormalizeKey(definitionKey)); + if (definition == null || definition.IsRetired || !definition.CurrentPublishedVersion.HasValue) return null; + var version = await _versions.GetAsync(departmentId, definition.DefinitionKey, definition.CurrentPublishedVersion.Value); + return version != null && version.IsPublished ? version : null; + } + + // ------------------------------------------------------------------------------------------------ + // Authoring + // ------------------------------------------------------------------------------------------------ + + public async Task CreateAsync(int departmentId, string userId, RecordDefinitionCreateInput input, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + await RequireManageAsync(userId, departmentId); + var key = RecordDefinitionKeys.NormalizeKey(input.DefinitionKey); + if (!RecordDefinitionKeys.IsValidDefinitionKey(key)) + throw new ArgumentException("A definition key uses lower-case letters, digits, dots and dashes, starts with a letter and never uses the reserved 'system.' prefix.", nameof(input)); + if (await _definitions.GetByKeyAsync(departmentId, key) != null) + throw new ArgumentException($"A definition with key '{key}' already exists.", nameof(input)); + if (string.IsNullOrWhiteSpace(input.Name)) + throw new ArgumentException("A definition name is required.", nameof(input)); + + var draft = new RecordDefinitionDraftInput { Name = input.Name.Trim(), Category = input.Category?.Trim() }; + string templateKey = null, profileKey = null; + if (!string.IsNullOrWhiteSpace(input.TemplateKey)) + { + var rendering = await _templates.RenderAsync(input.TemplateKey, input.JurisdictionProfileKey ?? "generic", input.Locale); + if (rendering == null) throw new ArgumentException($"'{input.TemplateKey}' is not a product template.", nameof(input)); + templateKey = rendering.Template.Key; + profileKey = rendering.ProfileKey; + draft.Schema = rendering.Schema; + draft.LifecyclePreset = rendering.Template.LifecyclePreset; + draft.Numbering = new RecordDefinitionNumbering { Prefix = rendering.Template.NumberPrefix }; + draft.PermittedSubjectTypes = rendering.Template.PermittedSubjectTypes; + draft.Classification = rendering.Template.Classification; + draft.RetentionYears = rendering.Template.RetentionYears; + draft.ClientSurface = rendering.Template.ClientSurface; + draft.Category = draft.Category ?? rendering.Template.Category; + draft.Description = rendering.Template.Description; + } + else if (!string.IsNullOrWhiteSpace(input.CloneFromDefinitionKey)) + { + var source = await GetAsync(departmentId, input.CloneFromDefinitionKey); + var sourceVersion = source?.Published ?? source?.Latest; + if (sourceVersion == null) throw new ArgumentException($"'{input.CloneFromDefinitionKey}' has no version to clone.", nameof(input)); + draft = ToDraftInput(sourceVersion, source.Definition); + draft.Name = input.Name.Trim(); + draft.Category = input.Category?.Trim() ?? source.Definition.Category; + templateKey = source.Definition.TemplateKey; + profileKey = source.Definition.JurisdictionProfileKey; + } + else + { + // A blank definition starts with one editable section so the first draft validates; the designer replaces it. + draft.Numbering = new RecordDefinitionNumbering { Prefix = DerivePrefix(key) }; + draft.Schema = StarterSchema(); + } + + var validation = await ValidateAsync(departmentId, draft); + if (!validation.IsValid) + throw new ArgumentException(string.Join(" ", validation.Issues.Where(i => i.Severity == "error").Select(i => i.Message))); + + var now = DateTime.UtcNow; + var definition = new RmsRecordDefinition + { + RmsRecordDefinitionId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + DefinitionKey = key, + Owner = (int)RmsDefinitionOwner.Department, + Name = draft.Name, + Category = draft.Category, + Description = draft.Description, + TemplateKey = templateKey, + TemplatePackVersion = templateKey == null ? (int?)null : 1, + JurisdictionProfileKey = profileKey, + PermittedSubjectTypes = draft.PermittedSubjectTypes, + LatestVersion = 1, + CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, ModifiedByUserId = userId, RowVersion = 1 + }; + var version = NewVersion(definition, 1, draft, userId, now); + + await InTransactionAsync(async () => + { + await _definitions.InsertAsync(definition, cancellationToken, true); + await _versions.InsertAsync(version, cancellationToken, true); + await AuditAsync(departmentId, userId, definition, version, "Create definition", cancellationToken); + }); + return await GetAsync(departmentId, key); + } + + public async Task OpenDraftAsync(int departmentId, string userId, string definitionKey, CancellationToken cancellationToken = default) + { + await RequireManageAsync(userId, departmentId); + var aggregate = await GetAsync(departmentId, definitionKey) ?? throw new ArgumentException($"'{definitionKey}' is not a department definition.", nameof(definitionKey)); + if (aggregate.Definition.IsRetired) throw new InvalidOperationException("A retired definition cannot open a new draft."); + if (aggregate.Draft != null) throw new InvalidOperationException($"Draft v{aggregate.Draft.Version} is already open; edit, publish or delete it first."); + if (aggregate.Draft != null) return aggregate.Draft; + var source = aggregate.Published ?? aggregate.Latest ?? throw new InvalidOperationException("The definition has no version to draft from."); + + var now = DateTime.UtcNow; + var draft = NewVersion(aggregate.Definition, aggregate.Definition.LatestVersion + 1, ToDraftInput(source, aggregate.Definition), userId, now); + await InTransactionAsync(async () => + { + await _versions.InsertAsync(draft, cancellationToken, true); + aggregate.Definition.LatestVersion = draft.Version; + aggregate.Definition.ModifiedOn = now; aggregate.Definition.ModifiedByUserId = userId; aggregate.Definition.RowVersion += 1; + await _definitions.UpdateAsync(aggregate.Definition, cancellationToken, true); + await AuditAsync(departmentId, userId, aggregate.Definition, draft, "Open draft version", cancellationToken); + }); + return draft; + } + + public async Task SaveDraftAsync(int departmentId, string userId, string definitionKey, int version, long expectedRowVersion, RecordDefinitionDraftInput input, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + await RequireManageAsync(userId, departmentId); + var aggregate = await GetAsync(departmentId, definitionKey) ?? throw new ArgumentException($"'{definitionKey}' is not a department definition.", nameof(definitionKey)); + var row = aggregate.Versions.FirstOrDefault(v => v.Version == version) ?? throw new ArgumentException($"Version {version} does not exist.", nameof(version)); + if (!row.IsDraft) throw new InvalidOperationException("A published version is immutable; open a new draft to change it."); + if (row.RowVersion != expectedRowVersion) throw new RecordConcurrencyException(row.RmsRecordDefinitionVersionId, expectedRowVersion, row.RowVersion); + + var validation = await ValidateAsync(departmentId, input); + ApplyTemplateFloors(validation, aggregate.Definition.TemplateKey, input.Schema); + if (!validation.IsValid) + throw new ArgumentException(string.Join(" ", validation.Issues.Where(i => i.Severity == "error").Select(i => i.Message))); + + var now = DateTime.UtcNow; + Apply(row, input, validation.MinimumClientCapability); + row.ModifiedOn = now; row.ModifiedByUserId = userId; row.RowVersion += 1; + aggregate.Definition.Name = string.IsNullOrWhiteSpace(input.Name) ? aggregate.Definition.Name : input.Name.Trim(); + aggregate.Definition.Category = input.Category?.Trim() ?? aggregate.Definition.Category; + aggregate.Definition.Description = input.Description ?? aggregate.Definition.Description; + aggregate.Definition.PermittedSubjectTypes = input.PermittedSubjectTypes ?? aggregate.Definition.PermittedSubjectTypes; + aggregate.Definition.ModifiedOn = now; aggregate.Definition.ModifiedByUserId = userId; aggregate.Definition.RowVersion += 1; + + await InTransactionAsync(async () => + { + await _versions.UpdateAsync(row, cancellationToken, true); + await _definitions.UpdateAsync(aggregate.Definition, cancellationToken, true); + await AuditAsync(departmentId, userId, aggregate.Definition, row, "Save draft version", cancellationToken); + }); + return row; + } + + public async Task DeleteDraftAsync(int departmentId, string userId, string definitionKey, int version, CancellationToken cancellationToken = default) + { + await RequireManageAsync(userId, departmentId); + var aggregate = await GetAsync(departmentId, definitionKey); + var row = aggregate?.Versions.FirstOrDefault(v => v.Version == version); + if (row == null) return false; + if (!row.IsDraft) throw new InvalidOperationException("Only an unused draft can be deleted."); + if (await _values.CountRecordsOnVersionAsync(departmentId, row.RmsRecordDefinitionVersionId, false) > 0) + throw new InvalidOperationException("Records already reference this draft version; it cannot be deleted."); + + await InTransactionAsync(async () => + { + await _versions.DeleteAsync(row, cancellationToken); + if (aggregate.Versions.Count == 1) + { + aggregate.Definition.DeletedOn = DateTime.UtcNow; + await _definitions.UpdateAsync(aggregate.Definition, cancellationToken, true); + } + await AuditAsync(departmentId, userId, aggregate.Definition, row, "Delete draft version", cancellationToken); + }); + return true; + } + + // ------------------------------------------------------------------------------------------------ + // Validation + // ------------------------------------------------------------------------------------------------ + + public Task ValidateAsync(int departmentId, RecordDefinitionDraftInput input) + { + var result = new RecordDefinitionValidation(); + if (input == null) { result.Issues.Add(RecordDefinitionIssue.Error("", "missing", "Nothing to validate.")); return Task.FromResult(result); } + var schema = input.Schema ?? new RecordDefinitionSchema(); + var issues = result.Issues; + + // Name/category/description live on the definition; a null name on a draft save means "unchanged" (ToDraftInput, publish re-validation). + 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 (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.")); + if (input.ApproveDueHours.HasValue && (input.ApproveDueHours <= 0 || input.ApproveDueHours > 24 * 365)) issues.Add(RecordDefinitionIssue.Error("approveDueHours", "out_of_range", "Approve due hours must be between 1 and 8760.")); + if (input.RetentionYears.HasValue && (input.RetentionYears < 0 || input.RetentionYears > 100)) issues.Add(RecordDefinitionIssue.Error("retentionYears", "out_of_range", "Retention is 0 (permanent) to 100 years.")); + foreach (var subject in (input.PermittedSubjectTypes ?? string.Empty).Split(',').Select(s => s.Trim().ToLowerInvariant()).Where(s => s.Length > 0)) + if (!KnownSubjectTypes.Contains(subject)) issues.Add(RecordDefinitionIssue.Error("permittedSubjectTypes", "unknown_subject", $"'{subject}' is not a supported subject type.")); + + var numbering = input.Numbering ?? new RecordDefinitionNumbering(); + if (string.IsNullOrWhiteSpace(numbering.Prefix) || numbering.Prefix.Length < 2 || numbering.Prefix.Length > 6 || !numbering.Prefix.All(c => char.IsLetterOrDigit(c) && !char.IsLower(c))) + issues.Add(RecordDefinitionIssue.Error("numbering.prefix", "bad_prefix", "The number prefix is 2 to 6 upper-case letters or digits.")); + if (numbering.SequenceWidth < 3 || numbering.SequenceWidth > 8) issues.Add(RecordDefinitionIssue.Error("numbering.sequenceWidth", "out_of_range", "The sequence width is 3 to 8 digits.")); + if (!Enum.IsDefined(typeof(RmsNumberAssignment), numbering.Assignment)) issues.Add(RecordDefinitionIssue.Error("numbering.assignment", "unknown", "Numbers are assigned OnFinalize or OnCreate.")); + + if (schema.Sections.Count == 0) issues.Add(RecordDefinitionIssue.Error("schema", "no_sections", "A definition needs at least one section.")); + if (schema.Sections.Count > RecordDefinitionSchema.MaxSections) issues.Add(RecordDefinitionIssue.Error("schema", "too_many_sections", $"At most {RecordDefinitionSchema.MaxSections} sections.")); + var sectionKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var fieldKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var fieldSections = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var section in schema.Sections) + { + var path = "schema.sections." + (section.Key ?? "?"); + if (!RecordDefinitionKeys.IsValidMemberKey(section.Key)) issues.Add(RecordDefinitionIssue.Error(path, "bad_key", $"Section key '{section.Key}' is invalid (letters, digits, underscore, dash; starts with a letter).")); + else if (!sectionKeys.Add(section.Key)) issues.Add(RecordDefinitionIssue.Error(path, "duplicate_key", $"Section key '{section.Key}' is used twice.")); + if (string.IsNullOrWhiteSpace(section.Label)) issues.Add(RecordDefinitionIssue.Error(path, "label_required", $"Section '{section.Key}' needs a label.")); + if (section.Fields.Count == 0) issues.Add(RecordDefinitionIssue.Error(path, "no_fields", $"Section '{section.Key}' has no fields.")); + if (section.Fields.Count > RecordDefinitionSchema.MaxFieldsPerSection) issues.Add(RecordDefinitionIssue.Error(path, "too_many_fields", $"Section '{section.Key}' exceeds {RecordDefinitionSchema.MaxFieldsPerSection} fields.")); + if (section.Repeating && section.MaxRows.HasValue && (section.MaxRows < 1 || section.MaxRows > RecordTypedValuesService.MaxRowsPerSection)) issues.Add(RecordDefinitionIssue.Error(path, "bad_rows", $"Repeating sections allow 1 to {RecordTypedValuesService.MaxRowsPerSection} rows.")); + if (section.MinRows.HasValue && section.MaxRows.HasValue && section.MinRows > section.MaxRows) issues.Add(RecordDefinitionIssue.Error(path, "bad_rows", "MinRows exceeds MaxRows.")); + foreach (var field in section.Fields) + { + var fieldPath = path + "." + (field.Key ?? "?"); + if (!RecordDefinitionKeys.IsValidMemberKey(field.Key)) issues.Add(RecordDefinitionIssue.Error(fieldPath, "bad_key", $"Field key '{field.Key}' is invalid.")); + else if (!fieldKeys.Add(field.Key)) issues.Add(RecordDefinitionIssue.Error(fieldPath, "duplicate_key", $"Field key '{field.Key}' is used twice; keys are unique across the definition.")); + else fieldSections[field.Key] = section; + if (string.IsNullOrWhiteSpace(field.Label)) issues.Add(RecordDefinitionIssue.Error(fieldPath, "label_required", $"Field '{field.Key}' needs a label.")); + if (!Enum.IsDefined(typeof(RmsFieldType), field.Type)) issues.Add(RecordDefinitionIssue.Error(fieldPath, "unknown_type", $"Field '{field.Key}' has an unsupported type.")); + if ((field.Type == RmsFieldType.SingleSelect || field.Type == RmsFieldType.MultiSelect)) + { + if (field.Options.Count == 0) issues.Add(RecordDefinitionIssue.Error(fieldPath, "no_options", $"'{field.Key}' needs at least one option.")); + if (field.Options.Count > RecordDefinitionSchema.MaxOptions) issues.Add(RecordDefinitionIssue.Error(fieldPath, "too_many_options", $"'{field.Key}' exceeds {RecordDefinitionSchema.MaxOptions} options.")); + var optionKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var option in field.Options) + { + if (string.IsNullOrWhiteSpace(option.Key) || option.Key.Length > 64) issues.Add(RecordDefinitionIssue.Error(fieldPath, "bad_option", $"'{field.Key}' has an option without a key.")); + else if (!optionKeys.Add(option.Key)) issues.Add(RecordDefinitionIssue.Error(fieldPath, "duplicate_option", $"'{field.Key}' repeats option '{option.Key}'.")); + } + } + if (field.Type == RmsFieldType.Quantity && (string.IsNullOrWhiteSpace(field.UnitFamily) || !RmsUnits.CanonicalByFamily.ContainsKey(field.UnitFamily))) + issues.Add(RecordDefinitionIssue.Error(fieldPath, "bad_unit_family", $"Quantity field '{field.Key}' needs a unit family ({string.Join(", ", RmsUnits.CanonicalByFamily.Keys)}).")); + if (field.Type == RmsFieldType.Quantity && !string.IsNullOrWhiteSpace(field.DefaultUnit) && RmsUnits.Find(field.DefaultUnit)?.Family != field.UnitFamily) + issues.Add(RecordDefinitionIssue.Error(fieldPath, "bad_unit", $"'{field.DefaultUnit}' is not a {field.UnitFamily} unit.")); + if (field.Type == RmsFieldType.Currency && !string.IsNullOrWhiteSpace(field.DefaultCurrency) && !RmsCurrencies.IsSupported(field.DefaultCurrency)) + issues.Add(RecordDefinitionIssue.Error(fieldPath, "bad_currency", $"'{field.DefaultCurrency}' is not a supported currency.")); + if (field.Min.HasValue && field.Max.HasValue && field.Min > field.Max) issues.Add(RecordDefinitionIssue.Error(fieldPath, "bad_range", $"'{field.Key}' has Min above Max.")); + if (field.MaxLength.HasValue && (field.MaxLength < 1 || field.MaxLength > RecordTypedValuesService.MaxLongText)) issues.Add(RecordDefinitionIssue.Error(fieldPath, "bad_length", $"'{field.Key}' MaxLength is out of range.")); + if (field.Classification < input.Classification) issues.Add(RecordDefinitionIssue.Error(fieldPath, "classification_loosened", $"'{field.Key}' cannot be less classified than the definition ({input.Classification}).")); + + // Capability flags are restricted by type and protection (plan 4.1): an author cannot declare a + // protected or restricted value safe for search, Workflow or export, and long text never indexes. + if (field.Classification != RmsFieldClassification.Standard && (field.Searchable || field.WorkflowExposed || field.Groupable || field.Aggregatable)) + issues.Add(RecordDefinitionIssue.Error(fieldPath, "protected_exposed", $"'{field.Key}' is {field.Classification}; it cannot be searchable, groupable, aggregatable or Workflow-exposed.")); + if (field.Aggregatable && !IsNumeric(field.Type)) issues.Add(RecordDefinitionIssue.Error(fieldPath, "not_aggregatable", $"'{field.Key}' ({field.Type}) cannot be aggregated; only numeric, currency, quantity and duration fields can.")); + if (field.Groupable && !IsGroupable(field.Type)) issues.Add(RecordDefinitionIssue.Error(fieldPath, "not_groupable", $"'{field.Key}' ({field.Type}) cannot group a report.")); + if ((field.Sortable || field.Filterable) && (field.Type == RmsFieldType.LongText || field.Type == RmsFieldType.Attachment || field.Type == RmsFieldType.Signature)) + issues.Add(RecordDefinitionIssue.Error(fieldPath, "not_filterable", $"'{field.Key}' ({field.Type}) cannot be filtered or sorted.")); + if (field.Searchable && (field.Type == RmsFieldType.Attachment || field.Type == RmsFieldType.Signature)) + issues.Add(RecordDefinitionIssue.Error(fieldPath, "not_searchable", $"'{field.Key}' ({field.Type}) cannot be searched.")); + } + } + + // Rules: bounded operators, references to existing scalar fields, depth, and no cycles. + var graph = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var section in schema.Sections) + { + foreach (var rule in section.Rules) + ValidateRule(issues, "schema.sections." + section.Key + ".rules", rule, schema, fieldSections, section, true, graph, "section:" + section.Key); + foreach (var field in section.Fields) + foreach (var rule in field.Rules) + ValidateRule(issues, "schema.sections." + section.Key + "." + field.Key + ".rules", rule, schema, fieldSections, section, false, graph, field.Key); + } + var cycle = FindCycle(graph); + if (cycle != null) issues.Add(RecordDefinitionIssue.Error("schema.rules", "cycle", "Rules form a cycle: " + cycle + ". A rule may not depend on a field whose own visibility depends on it.")); + + result.MinimumClientCapability = RecordsClientCapabilities.Derive(schema); + if (result.MinimumClientCapability == RecordsClientCapabilities.Packs) + result.Issues.Add(RecordDefinitionIssue.Warning("schema", "capability", "This version uses RMS-1C field types (currency, quantity, country/subdivision or module references); clients below records.v1c fail closed for authoring.")); + return Task.FromResult(result); + } + + private static void ValidateRule(List issues, string path, RecordRuleSchema rule, RecordDefinitionSchema schema, Dictionary fieldSections, RecordSectionSchema owner, bool isSection, Dictionary> graph, string target) + { + if (rule == null) return; + if (isSection && rule.Effect != RmsRuleEffect.Show) issues.Add(RecordDefinitionIssue.Error(path, "bad_effect", "A section rule can only control visibility.")); + if (rule.Condition == null) { issues.Add(RecordDefinitionIssue.Error(path, "no_condition", "A rule needs a condition.")); return; } + ValidateCondition(issues, path, rule.Condition, schema, fieldSections, 0, owner, isSection); + if (!graph.TryGetValue(target, out var deps)) graph[target] = deps = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var key in rule.Condition.ReferencedFieldKeys()) + { + deps.Add(key); + // A field's visibility also depends on its section's visibility. + if (fieldSections.TryGetValue(key, out var depSection) && depSection.Rules.Count > 0) deps.Add("section:" + depSection.Key); + } + if (!isSection && owner.Rules.Count > 0) + { + if (!graph.TryGetValue(target, out var own)) graph[target] = own = new HashSet(StringComparer.OrdinalIgnoreCase); + own.Add("section:" + owner.Key); + } + } + + private static void ValidateCondition(List issues, string path, RecordConditionSchema condition, RecordDefinitionSchema schema, Dictionary fieldSections, int depth, RecordSectionSchema owner = null, bool isSection = false) + { + if (depth > RecordDefinitionSchema.MaxRuleDepth) { issues.Add(RecordDefinitionIssue.Error(path, "too_deep", $"Rules nest at most {RecordDefinitionSchema.MaxRuleDepth} levels.")); return; } + if (!Enum.IsDefined(typeof(RmsRuleOperator), condition.Operator)) { issues.Add(RecordDefinitionIssue.Error(path, "bad_operator", "Unsupported rule operator.")); return; } + if (condition.Operator == RmsRuleOperator.And || condition.Operator == RmsRuleOperator.Or) + { + if (condition.Conditions == null || condition.Conditions.Count == 0) issues.Add(RecordDefinitionIssue.Error(path, "empty_composition", "AND/OR needs child conditions.")); + foreach (var child in condition.Conditions ?? new List()) ValidateCondition(issues, path, child, schema, fieldSections, depth + 1, owner, isSection); + return; + } + var field = schema.FindField(condition.FieldKey); + if (field == null) { issues.Add(RecordDefinitionIssue.Error(path, "unknown_field", $"Rule references unknown field '{condition.FieldKey}'.")); return; } + // A field inside a repeating section may be referenced only by a field rule of the same section: the rule then + // evaluates per row against that row's cell. Section rules and other sections see scalars only; row counts and + // cross-row aggregates stay deferred (plan section 4.1). + if (fieldSections.TryGetValue(field.Key, out var section) && section.Repeating && (isSection || owner == null || !string.Equals(owner.Key, section.Key, StringComparison.OrdinalIgnoreCase))) + issues.Add(RecordDefinitionIssue.Error(path, "repeating_reference", $"Rules cannot reference '{condition.FieldKey}' inside repeating section '{section.Key}' from outside that section; only a field of the same section may, and it evaluates per row (count-of-rows conditions are deferred).")); + switch (condition.Operator) + { + case RmsRuleOperator.Equals: case RmsRuleOperator.NotEquals: + if (condition.Value == null) issues.Add(RecordDefinitionIssue.Error(path, "no_value", "Equals/NotEquals needs a value.")); + if ((field.Type == RmsFieldType.SingleSelect || field.Type == RmsFieldType.MultiSelect) && condition.Value != null && !field.Options.Any(o => string.Equals(o.Key, condition.Value, StringComparison.OrdinalIgnoreCase))) + issues.Add(RecordDefinitionIssue.Error(path, "unknown_option", $"'{condition.Value}' is not an option of '{field.Key}'.")); + break; + case RmsRuleOperator.InSet: case RmsRuleOperator.NotInSet: + if (condition.Values == null || condition.Values.Count == 0) issues.Add(RecordDefinitionIssue.Error(path, "no_values", "InSet/NotInSet needs values.")); + break; + case RmsRuleOperator.InRange: + if (!IsNumeric(field.Type) && field.Type != RmsFieldType.Date && field.Type != RmsFieldType.DateTime) issues.Add(RecordDefinitionIssue.Error(path, "not_range", $"'{field.Key}' does not support range conditions.")); + if (!condition.Min.HasValue && !condition.Max.HasValue && !condition.MinDate.HasValue && !condition.MaxDate.HasValue) issues.Add(RecordDefinitionIssue.Error(path, "no_range", "InRange needs a minimum or maximum.")); + break; + } + } + + /// Depth-first cycle search over the rule dependency graph; returns the cycle path or null. + public static string FindCycle(Dictionary> graph) + { + var state = new Dictionary(StringComparer.OrdinalIgnoreCase); + var stack = new List(); + string Visit(string node) + { + if (state.TryGetValue(node, out var s)) + { + if (s == 1) return string.Join(" -> ", stack.SkipWhile(n => !string.Equals(n, node, StringComparison.OrdinalIgnoreCase)).Concat(new[] { node })); + return null; + } + state[node] = 1; stack.Add(node); + if (graph.TryGetValue(node, out var deps)) + foreach (var dep in deps) + { + var found = Visit(dep); + if (found != null) return found; + } + stack.RemoveAt(stack.Count - 1); state[node] = 2; + return null; + } + foreach (var node in graph.Keys.ToList()) + { + var found = Visit(node); + if (found != null) return found; + } + return null; + } + + public static bool IsNumeric(RmsFieldType type) => type == RmsFieldType.Integer || type == RmsFieldType.Decimal || type == RmsFieldType.Currency || type == RmsFieldType.Quantity || type == RmsFieldType.Duration; + public static bool IsGroupable(RmsFieldType type) => type == RmsFieldType.SingleSelect || type == RmsFieldType.Boolean || type == RmsFieldType.ShortText || type == RmsFieldType.Unit || type == RmsFieldType.Group || type == RmsFieldType.Person || type == RmsFieldType.Contact || type == RmsFieldType.Date || type == RmsFieldType.CountrySubdivision || type == RmsFieldType.Integer; + + // ------------------------------------------------------------------------------------------------ + // Publish, retire, history, diff, impact + // ------------------------------------------------------------------------------------------------ + + public async Task ImpactPreviewAsync(int departmentId, string definitionKey, int version) + { + var aggregate = await GetAsync(departmentId, definitionKey) ?? throw new ArgumentException($"'{definitionKey}' is not a department definition.", nameof(definitionKey)); + var row = aggregate.Versions.FirstOrDefault(v => v.Version == version) ?? throw new ArgumentException($"Version {version} does not exist.", nameof(version)); + var validation = await ValidateAsync(departmentId, ToDraftInput(row, aggregate.Definition)); + var schema = row.Schema; + var preview = new RecordDefinitionImpactPreview + { + DefinitionKey = aggregate.Definition.DefinitionKey, Version = version, Issues = validation.Issues, + MinimumClientCapability = validation.MinimumClientCapability, + FieldTypesUsed = schema.AllFields().Select(f => f.Type.ToString()).Distinct().OrderBy(t => t, StringComparer.Ordinal).ToList(), + UsesRepeatingGroups = schema.Sections.Any(s => s.Repeating), + CurrentPublishedVersion = aggregate.Definition.CurrentPublishedVersion + }; + var published = aggregate.Published; + if (published != null && published.Version != version) + { + preview.OpenDraftsOnCurrentVersion = await _values.CountRecordsOnVersionAsync(departmentId, published.RmsRecordDefinitionVersionId, true); + preview.FinalizedRecordsOnEarlierVersions = await _values.CountRecordsOnVersionAsync(departmentId, published.RmsRecordDefinitionVersionId, false) - preview.OpenDraftsOnCurrentVersion; + var diff = DiffVersions(aggregate.Definition.DefinitionKey, published, row); + preview.BreakingChange = diff.Breaking; + } + var surface = row.ClientSurface; + foreach (var (app, flag, eligible) in new[] + { + ("Responder", FeatureFlagKeys.RecordsFieldResponder, surface.Responder), + ("Unit", FeatureFlagKeys.RecordsFieldUnit, surface.Unit), + ("IncidentCommand", FeatureFlagKeys.RecordsFieldIncidentCommand, surface.IncidentCommand), + ("Dispatch", FeatureFlagKeys.RecordsFieldDispatch, surface.Dispatch) + }) + { + bool enabled; + try { enabled = await _featureToggles.IsEnabledAsync(flag, departmentId); } catch (Exception) { enabled = false; } + preview.Clients.Add(new RecordDefinitionClientImpact + { + App = app, Enabled = enabled, EligibleOnSurface = eligible, RequiredCapability = validation.MinimumClientCapability, ClientsBelowFloor = null, + Message = !enabled ? "Field Records are off for this app; Web is the authoring path." : !eligible ? "This version is not offered to this app." : + validation.MinimumClientCapability == RecordsClientCapabilities.Packs ? "Clients below records.v1c fail closed for authoring and keep read-only access; Web always renders it." : "Clients at records.v1b or later can author this version." + }); + } + return preview; + } + + public async Task PublishAsync(int departmentId, string userId, string definitionKey, int version, long expectedRowVersion, CancellationToken cancellationToken = default) + { + await RequirePublishAsync(userId, departmentId); + var aggregate = await GetAsync(departmentId, definitionKey) ?? throw new ArgumentException($"'{definitionKey}' is not a department definition.", nameof(definitionKey)); + if (aggregate.Definition.IsRetired) throw new InvalidOperationException("A retired definition cannot publish."); + var row = aggregate.Versions.FirstOrDefault(v => v.Version == version) ?? throw new ArgumentException($"Version {version} does not exist.", nameof(version)); + if (!row.IsDraft) throw new InvalidOperationException("Only a draft version can be published."); + if (row.RowVersion != expectedRowVersion) throw new RecordConcurrencyException(row.RmsRecordDefinitionVersionId, expectedRowVersion, row.RowVersion); + + var validation = await ValidateAsync(departmentId, ToDraftInput(row, aggregate.Definition)); + ApplyTemplateFloors(validation, aggregate.Definition.TemplateKey, row.Schema); + if (!validation.IsValid) throw new ArgumentException(string.Join(" ", validation.Issues.Where(i => i.Severity == "error").Select(i => i.Message))); + + var now = DateTime.UtcNow; + var schema = row.Schema; + row.SchemaJson = RecordDefinitionSchema.Serialize(schema); + row.SchemaChecksum = RecordSnapshotSerializer.Checksum(schema.Canonical()); + row.MinimumClientCapability = validation.MinimumClientCapability; + row.State = (int)RmsDefinitionVersionState.Published; + row.PublishedOn = now; row.PublishedByUserId = userId; + row.ModifiedOn = now; row.ModifiedByUserId = userId; row.RowVersion += 1; + + var previous = aggregate.Published; + var outboxIds = new List(); + await InTransactionAsync(async () => + { + await _versions.UpdateAsync(row, cancellationToken, true); + await MaterializeAsync(row, schema, now, cancellationToken); + if (previous != null && previous.RmsRecordDefinitionVersionId != row.RmsRecordDefinitionVersionId) + { + // The previous published version stays readable for its Records; only the pointer moves. + previous.ModifiedOn = now; previous.ModifiedByUserId = userId; previous.RowVersion += 1; + await _versions.UpdateAsync(previous, cancellationToken, true); + } + aggregate.Definition.CurrentPublishedVersion = row.Version; + aggregate.Definition.LatestVersion = Math.Max(aggregate.Definition.LatestVersion, row.Version); + aggregate.Definition.ModifiedOn = now; aggregate.Definition.ModifiedByUserId = userId; aggregate.Definition.RowVersion += 1; + await _definitions.UpdateAsync(aggregate.Definition, cancellationToken, true); + outboxIds.Add((await EnqueueAsync(aggregate.Definition, row, WorkflowTriggerEventType.RecordDefinitionPublished, previous?.Version, null, cancellationToken)).DomainEventOutboxId); + await AuditAsync(departmentId, userId, aggregate.Definition, row, "Publish definition version", cancellationToken); + }); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + return row; + } + + public async Task RetireAsync(int departmentId, string userId, string definitionKey, long expectedRowVersion, string reason, CancellationToken cancellationToken = default) + { + await RequirePublishAsync(userId, departmentId); + var aggregate = await GetAsync(departmentId, definitionKey) ?? throw new ArgumentException($"'{definitionKey}' is not a department definition.", nameof(definitionKey)); + var definition = aggregate.Definition; + if (definition.IsRetired) return definition; + if (definition.RowVersion != expectedRowVersion) throw new RecordConcurrencyException(definition.RmsRecordDefinitionId, expectedRowVersion, definition.RowVersion); + if (string.IsNullOrWhiteSpace(reason)) throw new ArgumentException("A reason is required to retire a definition.", nameof(reason)); + + var now = DateTime.UtcNow; + var published = aggregate.Published; + var outboxIds = new List(); + await InTransactionAsync(async () => + { + definition.IsRetired = true; definition.RetiredOn = now; definition.RetiredByUserId = userId; definition.RetiredReason = reason.Trim(); + definition.ModifiedOn = now; definition.ModifiedByUserId = userId; definition.RowVersion += 1; + await _definitions.UpdateAsync(definition, cancellationToken, true); + foreach (var version in aggregate.Versions.Where(v => v.IsPublished)) + { + version.State = (int)RmsDefinitionVersionState.Retired; version.RetiredOn = now; version.RetiredByUserId = userId; + version.ModifiedOn = now; version.ModifiedByUserId = userId; version.RowVersion += 1; + await _versions.UpdateAsync(version, cancellationToken, true); + } + outboxIds.Add((await EnqueueAsync(definition, published ?? aggregate.Latest, WorkflowTriggerEventType.RecordDefinitionRetired, null, reason, cancellationToken)).DomainEventOutboxId); + await AuditAsync(departmentId, userId, definition, published, "Retire definition", cancellationToken); + }); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + return definition; + } + + public async Task> HistoryAsync(int departmentId, string definitionKey) + => (await GetAsync(departmentId, definitionKey))?.Versions.OrderByDescending(v => v.Version).ToList() ?? new List(); + + public async Task DiffAsync(int departmentId, string definitionKey, int fromVersion, int toVersion) + { + var aggregate = await GetAsync(departmentId, definitionKey) ?? throw new ArgumentException($"'{definitionKey}' is not a department definition.", nameof(definitionKey)); + var from = aggregate.Versions.FirstOrDefault(v => v.Version == fromVersion) ?? throw new ArgumentException($"Version {fromVersion} does not exist."); + var to = aggregate.Versions.FirstOrDefault(v => v.Version == toVersion) ?? throw new ArgumentException($"Version {toVersion} does not exist."); + return DiffVersions(aggregate.Definition.DefinitionKey, from, to); + } + + /// Safe diff: keys, types, requiredness, classification, options, rules and policies. Removing or retyping a field is breaking. + public static RecordDefinitionDiff DiffVersions(string definitionKey, RmsRecordDefinitionVersion from, RmsRecordDefinitionVersion to) + { + 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 }); + 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 }); + if (!string.Equals(from.ReviewerRoleIds ?? "", to.ReviewerRoleIds ?? "", StringComparison.Ordinal) || !string.Equals(from.ApproverRoleIds ?? "", to.ApproverRoleIds ?? "", StringComparison.Ordinal)) + diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "policy", Change = "changed", Key = "roles", Detail = "Reviewer/approver roles changed.", Breaking = false }); + return diff; + } + + public static void DiffSchemas(RecordDefinitionDiff diff, RecordDefinitionSchema from, RecordDefinitionSchema to) + { + from = from ?? new RecordDefinitionSchema(); to = to ?? new RecordDefinitionSchema(); + foreach (var section in from.Sections.Where(s => to.FindSection(s.Key) == null)) + diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "section", Change = "removed", Key = section.Key, Detail = section.Label, Breaking = true }); + foreach (var section in to.Sections.Where(s => from.FindSection(s.Key) == null)) + diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "section", Change = "added", Key = section.Key, Detail = section.Label, Breaking = false }); + foreach (var section in to.Sections) + { + var old = from.FindSection(section.Key); + if (old == null) continue; + if (old.Repeating != section.Repeating) diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "section", Change = "changed", Key = section.Key, Detail = "Repeating changed", Breaking = true }); + else if (!string.Equals(old.Label, section.Label, StringComparison.Ordinal)) diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "section", Change = "changed", Key = section.Key, Detail = $"Label '{old.Label}' -> '{section.Label}'", Breaking = false }); + } + foreach (var field in from.AllFields().Where(f => to.FindField(f.Key) == null)) + diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "field", Change = "removed", Key = field.Key, Detail = field.Label, Breaking = true }); + foreach (var field in to.AllFields().Where(f => from.FindField(f.Key) == null)) + diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "field", Change = "added", Key = field.Key, Detail = $"{field.Label} ({field.Type})" + (field.Required || field.RequiredToFinalize ? ", required" : ""), Breaking = field.Required || field.RequiredToFinalize }); + foreach (var field in to.AllFields()) + { + var old = from.FindField(field.Key); + if (old == null) continue; + var details = new List(); var breaking = false; + if (old.Type != field.Type) { details.Add($"type {old.Type} -> {field.Type}"); breaking = true; } + if (!string.Equals(old.Label, field.Label, StringComparison.Ordinal)) details.Add($"label '{old.Label}' -> '{field.Label}'"); + if ((old.Required || old.RequiredToFinalize) != (field.Required || field.RequiredToFinalize)) { details.Add(field.Required || field.RequiredToFinalize ? "now required" : "no longer required"); breaking |= field.Required || field.RequiredToFinalize; } + if (old.Classification != field.Classification) { details.Add($"classification {old.Classification} -> {field.Classification}"); breaking |= field.Classification < old.Classification; } + var removedOptions = old.Options.Select(o => o.Key).Except(field.Options.Select(o => o.Key), StringComparer.OrdinalIgnoreCase).ToList(); + var addedOptions = field.Options.Select(o => o.Key).Except(old.Options.Select(o => o.Key), StringComparer.OrdinalIgnoreCase).ToList(); + if (removedOptions.Count > 0) { details.Add("options removed: " + string.Join(", ", removedOptions)); breaking = true; } + if (addedOptions.Count > 0) details.Add("options added: " + string.Join(", ", addedOptions)); + if (JsonConvert.SerializeObject(old.Rules) != JsonConvert.SerializeObject(field.Rules)) details.Add("rules changed"); + if (old.Searchable != field.Searchable || old.Filterable != field.Filterable || old.Sortable != field.Sortable || old.Groupable != field.Groupable || old.Aggregatable != field.Aggregatable || old.WorkflowExposed != field.WorkflowExposed || old.Exportable != field.Exportable) details.Add("capability flags changed"); + if (old.Min != field.Min || old.Max != field.Max || old.MaxLength != field.MaxLength) details.Add("constraints changed"); + if (details.Count > 0) diff.Entries.Add(new RecordDefinitionDiffEntry { Kind = "field", Change = "changed", Key = field.Key, Detail = string.Join("; ", details), Breaking = breaking }); + } + } + + public async Task MigrateDraftsAsync(int departmentId, string userId, string definitionKey, int fromVersion, int toVersion, List mapping, bool preview, CancellationToken cancellationToken = default) + { + await RequireManageAsync(userId, departmentId); + var aggregate = await GetAsync(departmentId, definitionKey) ?? throw new ArgumentException($"'{definitionKey}' is not a department definition.", nameof(definitionKey)); + var from = aggregate.Versions.FirstOrDefault(v => v.Version == fromVersion) ?? throw new ArgumentException($"Version {fromVersion} does not exist."); + var to = aggregate.Versions.FirstOrDefault(v => v.Version == toVersion) ?? throw new ArgumentException($"Version {toVersion} does not exist."); + if (!to.IsPublished) throw new InvalidOperationException("Drafts can only migrate to a published version."); + if (toVersion <= fromVersion) throw new InvalidOperationException("Drafts migrate forward only."); + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var field in from.Schema.AllFields()) if (to.Schema.FindField(field.Key) != null) map[field.Key] = field.Key; + foreach (var entry in mapping ?? new List()) + if (!string.IsNullOrWhiteSpace(entry?.FromFieldKey) && !string.IsNullOrWhiteSpace(entry.ToFieldKey)) map[entry.FromFieldKey] = entry.ToFieldKey; + + var result = new RecordDefinitionMigrationResult(); + foreach (var pair in map) + { + var source = from.Schema.FindField(pair.Key); var target = to.Schema.FindField(pair.Value); + if (source == null || target == null || source.Type != target.Type) result.UnmappedFieldKeys.Add(pair.Key); + } + foreach (var field in from.Schema.AllFields()) if (!map.ContainsKey(field.Key)) result.UnmappedFieldKeys.Add(field.Key); + result.UnmappedFieldKeys = result.UnmappedFieldKeys.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + var drafts = (await _records.GetByDefinitionVersionAsync(departmentId, aggregate.Definition.DefinitionKey, fromVersion, new[] { (int)RmsRecordState.Draft, (int)RmsRecordState.Returned }))?.ToList() ?? new List(); + foreach (var record in drafts) + { + if (record.AmendsRevisionId != null) { result.Skipped++; result.SkippedRecordIds.Add(record.RmsOperationalRecordId); continue; } + if (preview) { result.Migrated++; continue; } + var values = await _typedValues.HydrateAsync(departmentId, record.RmsOperationalRecordId, null, from, true); + var inputs = values.ToInputs().Where(i => map.ContainsKey(i.FieldKey)).Select(i => { var target = map[i.FieldKey]; i.FieldKey = target; i.SectionKey = to.Schema.SectionOf(target)?.Key; return i; }).Where(i => i.SectionKey != null).ToList(); + var validation = await _typedValues.ValidateAsync(departmentId, to, inputs, false); + if (!validation.IsValid) { result.Skipped++; result.SkippedRecordIds.Add(record.RmsOperationalRecordId); continue; } + await InTransactionAsync(async () => + { + await _typedValues.SaveDraftValuesAsync(departmentId, userId, record.RmsOperationalRecordId, to, inputs, cancellationToken); + record.DefinitionVersion = toVersion; + record.LifecyclePreset = to.LifecyclePreset; + record.ModifiedOn = DateTime.UtcNow; record.ModifiedByUserId = userId; record.RowVersion += 1; + await _records.UpdateAsync(record, cancellationToken, true); + }); + result.Migrated++; + } + if (!preview) await AuditAsync(departmentId, userId, aggregate.Definition, to, $"Migrate {result.Migrated} draft(s) from v{fromVersion}", cancellationToken); + return result; + } + + // ------------------------------------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------------------------------------ + + private static RmsRecordDefinitionVersion NewVersion(RmsRecordDefinition definition, int number, RecordDefinitionDraftInput input, string userId, DateTime now) + { + var version = new RmsRecordDefinitionVersion + { + RmsRecordDefinitionVersionId = Guid.NewGuid().ToString(), + DepartmentId = definition.DepartmentId, + ProtectionId = Guid.NewGuid().ToString(), + RmsRecordDefinitionId = definition.RmsRecordDefinitionId, + DefinitionKey = definition.DefinitionKey, + Version = number, + State = (int)RmsDefinitionVersionState.Draft, + CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, ModifiedByUserId = userId, RowVersion = 1 + }; + Apply(version, input, RecordsClientCapabilities.Derive(input.Schema)); + return version; + } + + private static void Apply(RmsRecordDefinitionVersion version, RecordDefinitionDraftInput input, string capability) + { + version.LifecyclePreset = (int)input.LifecyclePreset; + version.ReviewerRoleIds = string.Join(",", (input.ReviewerRoleIds ?? new List()).Distinct()); + version.ApproverRoleIds = string.Join(",", (input.ApproverRoleIds ?? new List()).Distinct()); + version.ReviewDueHours = input.ReviewDueHours; + version.ApproveDueHours = input.ApproveDueHours; + version.RequireAuthorAttestation = input.RequireAuthorAttestation; + version.Numbering = input.Numbering ?? new RecordDefinitionNumbering(); + version.RetentionYears = input.RetentionYears; + version.Classification = (int)input.Classification; + version.Schema = Normalize(input.Schema ?? new RecordDefinitionSchema()); + version.ClientSurface = input.ClientSurface ?? new RecordDefinitionClientSurface(); + version.MigrationMapJson = JsonConvert.SerializeObject(input.MigrationMap ?? new List()); + version.ChangeNotes = input.ChangeNotes; + version.MinimumClientCapability = capability; + } + + private static RecordDefinitionSchema Normalize(RecordDefinitionSchema schema) + { + foreach (var section in schema.Sections) + { + section.Key = RecordDefinitionKeys.NormalizeKey(section.Key); + foreach (var field in section.Fields) + { + field.Key = RecordDefinitionKeys.NormalizeKey(field.Key); + field.Options ??= new List(); + field.Rules ??= new List(); + foreach (var option in field.Options) option.Key = option.Key?.Trim(); + } + section.Rules ??= new List(); + } + return schema; + } + + public static RecordDefinitionDraftInput ToDraftInput(RmsRecordDefinitionVersion version, RmsRecordDefinition definition = null) + { + return new RecordDefinitionDraftInput + { + Name = definition?.Name, Category = definition?.Category, Description = definition?.Description, PermittedSubjectTypes = definition?.PermittedSubjectTypes, + LifecyclePreset = (RmsLifecyclePreset)version.LifecyclePreset, + 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, + Schema = RecordDefinitionSchema.Parse(version.SchemaJson), ClientSurface = version.ClientSurface, + MigrationMap = string.IsNullOrWhiteSpace(version.MigrationMapJson) ? new List() : JsonConvert.DeserializeObject>(version.MigrationMapJson) ?? new List(), + ChangeNotes = version.ChangeNotes + }; + } + + public static List ParseIds(string csv) => (csv ?? string.Empty).Split(',').Select(s => int.TryParse(s.Trim(), out var id) ? id : (int?)null).Where(i => i.HasValue).Select(i => i.Value).ToList(); + + /// + /// A department clone of a pack template may raise a field's classification but never lower it below the pack's + /// protected-data policy floor (RMS-1C); the floor is what keeps the safe projections safe. + /// + public void ApplyTemplateFloors(RecordDefinitionValidation validation, string templateKey, RecordDefinitionSchema schema) + { + if (validation == null || string.IsNullOrWhiteSpace(templateKey) || schema == null) return; + var template = _templates.GetTemplate(templateKey); + if (template == null) return; + foreach (var field in schema.AllFields()) + { + var floor = template.FloorFor(field.Key); + if (floor.HasValue && field.Classification < floor.Value) + validation.Issues.Add(RecordDefinitionIssue.Error("schema.sections." + (schema.SectionOf(field.Key)?.Key ?? "?") + "." + field.Key, "template_floor", + $"'{field.Key}' carries the {template.PackKey} policy floor {floor.Value}; a department may raise it, not lower it.")); + } + } + + public static RecordDefinitionSchema StarterSchema() => new RecordDefinitionSchema + { + Sections = new List + { + new RecordSectionSchema + { + Key = "details", Label = "Details", + Fields = new List + { + new RecordFieldSchema { Key = "summary", Label = "Summary", Type = RmsFieldType.ShortText, Required = true, Searchable = true, Filterable = true, Sortable = true, WorkflowExposed = true }, + new RecordFieldSchema { Key = "notes", Label = "Notes", Type = RmsFieldType.LongText } + } + } + } + }; + + private static string DerivePrefix(string key) + { + var letters = new string(key.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant(); + return letters.Length >= 3 ? letters.Substring(0, 3) : (letters + "REC").Substring(0, 3); + } + + private async Task MaterializeAsync(RmsRecordDefinitionVersion version, RecordDefinitionSchema schema, DateTime now, CancellationToken cancellationToken) + { + await _fields.DeleteForVersionAsync(version.DepartmentId, version.RmsRecordDefinitionVersionId, cancellationToken); + await _sections.DeleteForVersionAsync(version.DepartmentId, version.RmsRecordDefinitionVersionId, cancellationToken); + var sectionOrdinal = 0; + foreach (var section in schema.Sections) + { + await _sections.InsertAsync(new RmsRecordSectionDefinition + { + RmsRecordSectionDefinitionId = Guid.NewGuid().ToString(), DepartmentId = version.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RmsRecordDefinitionVersionId = version.RmsRecordDefinitionVersionId, DefinitionKey = version.DefinitionKey, DefinitionVersion = version.Version, + SectionKey = section.Key, Label = section.Label, Help = section.Help, Ordinal = sectionOrdinal++, IsRepeating = section.Repeating, MinRows = section.MinRows, MaxRows = section.MaxRows, + RulesJson = section.Rules.Count == 0 ? null : JsonConvert.SerializeObject(section.Rules), CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }, cancellationToken, true); + var fieldOrdinal = 0; + foreach (var field in section.Fields) + await _fields.InsertAsync(new RmsRecordFieldDefinition + { + RmsRecordFieldDefinitionId = Guid.NewGuid().ToString(), DepartmentId = version.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RmsRecordDefinitionVersionId = version.RmsRecordDefinitionVersionId, DefinitionKey = version.DefinitionKey, DefinitionVersion = version.Version, + SectionKey = section.Key, FieldKey = field.Key, Label = field.Label, DataType = (int)field.Type, Ordinal = fieldOrdinal++, Required = field.Required, RequiredToFinalize = field.RequiredToFinalize, + Classification = (int)field.Classification, ReferenceType = field.ReferenceType, Searchable = field.Searchable, Filterable = field.Filterable, Sortable = field.Sortable, Groupable = field.Groupable, + Aggregatable = field.Aggregatable, WorkflowExposed = field.WorkflowExposed, Exportable = field.Exportable, + ConstraintsJson = JsonConvert.SerializeObject(new { field.Min, field.Max, field.MaxLength, field.UnitFamily, field.DefaultUnit, field.FixedUnitLabel, field.DefaultCurrency, Options = field.Options.Select(o => o.Key) }), + RulesJson = field.Rules.Count == 0 ? null : JsonConvert.SerializeObject(field.Rules), CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }, cancellationToken, true); + } + } + + private async Task EnqueueAsync(RmsRecordDefinition definition, RmsRecordDefinitionVersion version, WorkflowTriggerEventType trigger, int? previousVersion, string reason, CancellationToken cancellationToken) + { + int catalogVersion; + try { catalogVersion = await _protection.GetCatalogVersionAsync(definition.DepartmentId); } catch (Exception) { catalogVersion = 0; } + var payload = new Dictionary + { + ["definition"] = DefinitionBlock(definition, version, previousVersion, reason), + ["protection"] = IncidentReportsService.ProtectionBlock(catalogVersion) + }; + return await _outbox.EnqueueAsync(definition.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = trigger.ToString(), + SchemaVersion = 1, + AggregateType = DefinitionAggregate, + AggregateId = definition.RmsRecordDefinitionId, + AggregateVersion = version?.Version ?? definition.LatestVersion, + Trigger = trigger, + Payload = payload, + CorrelationId = definition.RmsRecordDefinitionId, + OriginClient = RmsOriginClient.Web + }, cancellationToken); + } + + /// The definition.* block (plan section 5.6): stable key, version, category/template lineage and the explicitly exposed field keys. Never field values. + public static object DefinitionBlock(RmsRecordDefinition definition, RmsRecordDefinitionVersion version, int? previousVersion, string reason) + { + var schema = version?.Schema ?? new RecordDefinitionSchema(); + return new + { + id = definition.RmsRecordDefinitionId, + key = definition.DefinitionKey, + name = definition.Name, + category = definition.Category, + owner = ((RmsDefinitionOwner)definition.Owner).ToString(), + version = version?.Version, + previous_version = previousVersion, + state = version == null ? null : ((RmsDefinitionVersionState)version.State).ToString(), + lifecycle_preset = version == null ? null : ((RmsLifecyclePreset)version.LifecyclePreset).ToString(), + template_key = definition.TemplateKey, + jurisdiction_profile_key = definition.JurisdictionProfileKey, + minimum_client_capability = version?.MinimumClientCapability, + schema_checksum = version?.SchemaChecksum, + published_on = version?.PublishedOn, + retired = definition.IsRetired, + retired_on = definition.RetiredOn, + reason, + exposed_field_keys = schema.AllFields().Where(f => f.WorkflowExposed && f.Classification == RmsFieldClassification.Standard).Select(f => f.Key).ToList(), + section_keys = schema.Sections.Select(s => s.Key).ToList() + }; + } + + private async Task RequireManageAsync(string userId, int departmentId) + { + if (string.IsNullOrWhiteSpace(userId) || !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordDefinitions)) + throw new UnauthorizedAccessException("Managing Record definitions is not authorized."); + } + + private async Task RequirePublishAsync(string userId, int departmentId) + { + await RequireManageAsync(userId, departmentId); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.PublishRecordDefinitions)) + throw new UnauthorizedAccessException("Publishing Record definitions is not authorized."); + } + + private async Task AuditAsync(int departmentId, string userId, RmsRecordDefinition definition, RmsRecordDefinitionVersion version, string purpose, CancellationToken cancellationToken) + { + await _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + RecordId = definition.RmsRecordDefinitionId, + RevisionId = version?.RmsRecordDefinitionVersionId, + Action = (int)RmsAccessAuditAction.Admin, + ActorUserId = userId, + Purpose = purpose + " " + definition.DefinitionKey + (version == null ? string.Empty : " v" + version.Version), + OriginClient = (int)RmsOriginClient.Web, + Successful = true, + OccurredOn = DateTime.UtcNow, + CorrelationId = definition.RmsRecordDefinitionId + }, cancellationToken, true); + } + + private async Task InTransactionAsync(Func work) + { + _unitOfWork.CreateOrGetConnection(); + try + { + await work(); + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordDeploymentsService.cs b/Core/Resgrid.Services/Records/RecordDeploymentsService.cs new file mode 100644 index 00000000..acb7a231 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordDeploymentsService.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +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 +{ + /// + /// Create Deployment from External Order (RMS plan section 4.1 "external-order fill contract", RMS-1C, Preview). + /// The ordering system (IROC, CIFFC, a member agency, a local compact) stays authoritative: the order arrives as a + /// manually entered, checksummed snapshot; each supplied resource links to its exact request/fill number; later + /// snapshots are versioned, never overwriting signed history; and a resource is returned only when the + /// department says so, never because the external system marked it released. The deployment itself is a Record + /// on the Mutual Aid pack's deployment definition, so revisions, audit, retention and Workflow events are the + /// ordinary Records ones. + /// + public class RecordDeploymentsService : IRecordDeploymentsService + { + public const string DeploymentTemplateKey = "pack.mutual-aid.deployment"; + public const string DefaultDefinitionKey = "mutual-aid.deployment"; + + private readonly IRmsExternalOrdersRepository _orders; + private readonly IRmsExternalOrderFillsRepository _fills; + private readonly IRmsExternalReferencesRepository _references; + private readonly IRecordsService _records; + private readonly IRecordDefinitionsService _definitions; + private readonly IRecordTemplatePacksService _packs; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IUnitOfWork _unitOfWork; + + public RecordDeploymentsService(IRmsExternalOrdersRepository orders, IRmsExternalOrderFillsRepository fills, IRmsExternalReferencesRepository references, IRecordsService records, + IRecordDefinitionsService definitions, IRecordTemplatePacksService packs, IRecordsAuthorizationService authorization, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork) + { + _orders = orders; + _fills = fills; + _references = references; + _records = records; + _definitions = definitions; + _packs = packs; + _authorization = authorization; + _audits = audits; + _unitOfWork = unitOfWork; + } + + public async Task EnsureDeploymentDefinitionAsync(int departmentId, string userId, string profileKey, CancellationToken cancellationToken = default) + { + var existing = (await _definitions.ListAsync(departmentId)).FirstOrDefault(d => string.Equals(d.TemplateKey, DeploymentTemplateKey, StringComparison.OrdinalIgnoreCase) && d.PublishedVersion.HasValue && !d.Retired); + if (existing != null) return existing.Key; + var draftOnly = (await _definitions.ListAsync(departmentId)).FirstOrDefault(d => string.Equals(d.TemplateKey, DeploymentTemplateKey, StringComparison.OrdinalIgnoreCase) && !d.Retired); + RecordDefinitionAggregate aggregate; + if (draftOnly != null) + aggregate = await _definitions.GetAsync(departmentId, draftOnly.Key); + else + { + var profile = RmsDeploymentProfiles.IsKnown(profileKey) ? ProfileFor(profileKey) : "generic"; + aggregate = await _definitions.CreateAsync(departmentId, userId, new RecordDefinitionCreateInput { DefinitionKey = DefaultDefinitionKey, Name = "Deployment (External Order)", Category = "Mutual aid", TemplateKey = DeploymentTemplateKey, JurisdictionProfileKey = profile }, cancellationToken); + } + var draft = aggregate.Draft ?? throw new InvalidOperationException("The deployment definition has no draft to publish."); + await _definitions.PublishAsync(departmentId, userId, aggregate.Definition.DefinitionKey, draft.Version, draft.RowVersion, cancellationToken); + return aggregate.Definition.DefinitionKey; + } + + private static string ProfileFor(string deploymentProfile) + { + switch ((deploymentProfile ?? string.Empty).ToLowerInvariant()) + { + case RmsDeploymentProfiles.UsWildland: case RmsDeploymentProfiles.Compact: return "us"; + case RmsDeploymentProfiles.CaWildland: return "ca"; + case RmsDeploymentProfiles.CrossBorder: return "us-ca"; + default: return "generic"; + } + } + + public async Task CreateFromExternalOrderAsync(int departmentId, string userId, RecordDeploymentCreateInput input, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + if (!RmsDeploymentProfiles.IsKnown(input.ProfileKey)) throw new ArgumentException($"'{input.ProfileKey}' is not a deployment profile ({string.Join(", ", RmsDeploymentProfiles.All)}).", nameof(input)); + if (string.IsNullOrWhiteSpace(input.OrderNumber)) throw new ArgumentException("The external order number is required.", nameof(input)); + if (string.IsNullOrWhiteSpace(input.IncidentName)) throw new ArgumentException("The incident name is required.", nameof(input)); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord)) throw new UnauthorizedAccessException("Creating a deployment is not authorized."); + if (input.ArtifactData != null && input.ArtifactData.Length > 25 * 1024 * 1024) throw new ArgumentException("The order artifact exceeds 25 MB.", nameof(input)); + if (!string.IsNullOrWhiteSpace(input.CurrencyCode) && !RmsCurrencies.IsSupported(input.CurrencyCode)) throw new ArgumentException($"'{input.CurrencyCode}' is not a supported currency.", nameof(input)); + + var profileKey = input.ProfileKey.ToLowerInvariant(); + var homeProfile = input.HomeProfileKey ?? (profileKey == RmsDeploymentProfiles.CrossBorder ? "us" : ProfileFor(profileKey)); + var hostProfile = input.HostProfileKey ?? (profileKey == RmsDeploymentProfiles.CrossBorder ? "ca" : ProfileFor(profileKey)); + var profile = await _packs.GetProfileAsync(ProfileFor(profileKey)) ?? await _packs.GetProfileAsync("generic"); + + var definitionKey = await EnsureDeploymentDefinitionAsync(departmentId, userId, profileKey, cancellationToken); + var draft = new RecordDraftInput + { + DefinitionKey = definitionKey, StationGroupId = input.StationGroupId, IdempotencyKey = input.IdempotencyKey, OriginClient = input.OriginClient, + StartedOn = DateTime.UtcNow, ExternalId = input.OrderNumber.Trim(), Values = BuildValues(input, userId, profileKey) + }; + var record = await _records.CreateDraftAsync(departmentId, userId, draft, cancellationToken); + + var now = DateTime.UtcNow; + var order = new RmsExternalOrder + { + RmsExternalOrderId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = record.Record.RmsOperationalRecordId, + ProfileKey = profileKey, ProfileVersion = profile?.Version ?? 1, HomeProfileKey = homeProfile, HostProfileKey = hostProfile, + SourceScheme = (input.SourceScheme ?? DefaultScheme(profileKey)).Trim(), SourceSystem = input.SourceSystem?.Trim(), OrderNumber = input.OrderNumber.Trim(), + IncidentName = input.IncidentName.Trim(), IncidentNumber = input.IncidentNumber?.Trim(), IncidentCountry = input.IncidentCountry?.Trim().ToUpperInvariant(), IncidentSubdivision = input.IncidentSubdivision?.Trim().ToUpperInvariant(), + OrderingOffice = input.OrderingOffice?.Trim(), DispatchOffice = input.DispatchOffice?.Trim(), RequestingAgency = input.RequestingAgency?.Trim(), ReceivingAgency = input.ReceivingAgency?.Trim(), SendingAgency = input.SendingAgency?.Trim(), + DepartmentRole = string.IsNullOrWhiteSpace(input.DepartmentRole) ? "filling" : input.DepartmentRole.Trim().ToLowerInvariant(), CostCode = input.CostCode?.Trim(), AgreementReference = input.AgreementReference?.Trim(), + CurrencyCode = (input.CurrencyCode ?? profile?.CurrencyCode ?? "USD").ToUpperInvariant(), MeasurementSystem = input.MeasurementSystem ?? profile?.MeasurementSystem ?? "metric", + TimeZoneId = input.TimeZoneId, CapturedOffsetMinutes = input.CapturedOffsetMinutes, SourceCapturedOn = input.SourceCapturedOn ?? now, SourceVersion = input.SourceVersion ?? "1", + ArtifactFileName = input.ArtifactData == null ? null : input.ArtifactFileName, ArtifactContentType = input.ArtifactData == null ? null : input.ArtifactContentType, + ArtifactChecksum = input.ArtifactData == null ? null : RecordSnapshotSerializer.Checksum(input.ArtifactData), ArtifactData = input.ArtifactData, ArtifactSafeUrl = SafeUrl(input.ArtifactSafeUrl), + Status = (int)RmsExternalOrderStatus.Open, CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, ModifiedByUserId = userId, RowVersion = 1 + }; + var fills = (input.Fills ?? new List()).Select(f => ToFill(order, f, userId, now)).ToList(); + + await InTransactionAsync(async () => + { + await _orders.InsertAsync(order, cancellationToken, true); + foreach (var fill in fills) await _fills.InsertAsync(fill, cancellationToken, true); + await AuditAsync(departmentId, userId, order, $"Create deployment from external order {order.OrderNumber} ({profileKey})", cancellationToken); + }); + return await GetAsync(departmentId, userId, order.RmsExternalOrderId); + } + + private static List BuildValues(RecordDeploymentCreateInput input, string userId, string profileKey) + { + var values = new List + { + new RecordValueInput { SectionKey = "order", FieldKey = "profile", Value = ProfileOptionKey(profileKey) }, + new RecordValueInput { SectionKey = "order", FieldKey = "order_number", ReferenceId = input.OrderNumber?.Trim(), ReferenceType = input.SourceScheme ?? DefaultScheme(profileKey) }, + new RecordValueInput { SectionKey = "order", FieldKey = "incident_name", Value = input.IncidentName?.Trim() }, + new RecordValueInput { SectionKey = "mobilization", FieldKey = "coordinator", ReferenceId = userId } + }; + if (!string.IsNullOrWhiteSpace(input.IncidentNumber)) values.Add(new RecordValueInput { SectionKey = "order", FieldKey = "incident_number", ReferenceId = input.IncidentNumber.Trim(), ReferenceType = input.SourceScheme ?? DefaultScheme(profileKey) }); + if (!string.IsNullOrWhiteSpace(input.IncidentCountry)) + values.Add(new RecordValueInput { SectionKey = "order", FieldKey = "incident_subdivision", Value = string.IsNullOrWhiteSpace(input.IncidentSubdivision) ? input.IncidentCountry.Trim().ToUpperInvariant() : input.IncidentCountry.Trim().ToUpperInvariant() + "-" + input.IncidentSubdivision.Trim().ToUpperInvariant() }); + if (!string.IsNullOrWhiteSpace(input.OrderingOffice)) values.Add(new RecordValueInput { SectionKey = "order", FieldKey = "ordering_office", Value = input.OrderingOffice.Trim() }); + if (!string.IsNullOrWhiteSpace(input.RequestingAgency)) values.Add(new RecordValueInput { SectionKey = "order", FieldKey = "requesting_agency", Value = input.RequestingAgency.Trim() }); + if (!string.IsNullOrWhiteSpace(input.SendingAgency)) values.Add(new RecordValueInput { SectionKey = "order", FieldKey = "sending_agency", Value = input.SendingAgency.Trim() }); + if (!string.IsNullOrWhiteSpace(input.AgreementReference)) values.Add(new RecordValueInput { SectionKey = "order", FieldKey = "agreement", ReferenceId = input.AgreementReference.Trim(), ReferenceType = "agreement" }); + if (!string.IsNullOrWhiteSpace(input.CostCode)) values.Add(new RecordValueInput { SectionKey = "order", FieldKey = "cost_code", ReferenceId = input.CostCode.Trim(), ReferenceType = "cost-code" }); + var ordinal = 0; + foreach (var fill in input.Fills ?? new List()) + { + var rowKey = "fill-" + (fill.RequestNumber ?? ordinal.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(fill.AssignedUserId)) values.Add(new RecordValueInput { SectionKey = "roster", FieldKey = "member", RowKey = rowKey, Ordinal = ordinal, ReferenceId = fill.AssignedUserId }); + if (!string.IsNullOrWhiteSpace(fill.Position)) values.Add(new RecordValueInput { SectionKey = "roster", FieldKey = "position", RowKey = rowKey, Ordinal = ordinal, Value = fill.Position.Trim() }); + values.Add(new RecordValueInput { SectionKey = "roster", FieldKey = "trainee", RowKey = rowKey, Ordinal = ordinal, Value = fill.IsTrainee ? "true" : "false" }); + if (!string.IsNullOrWhiteSpace(fill.RequestNumber)) values.Add(new RecordValueInput { SectionKey = "roster", FieldKey = "request_number", RowKey = rowKey, Ordinal = ordinal, ReferenceId = fill.RequestNumber.Trim(), ReferenceType = "request" }); + if (fill.AssignedUnitId.HasValue) values.Add(new RecordValueInput { SectionKey = "roster", FieldKey = "unit", RowKey = rowKey, Ordinal = ordinal, ReferenceId = fill.AssignedUnitId.Value.ToString(CultureInfo.InvariantCulture) }); + ordinal++; + } + return values; + } + + public static string ProfileOptionKey(string profileKey) + { + switch ((profileKey ?? string.Empty).ToLowerInvariant()) + { + case RmsDeploymentProfiles.UsWildland: return "us-wildland"; + case RmsDeploymentProfiles.CaWildland: return "ca-wildland"; + case RmsDeploymentProfiles.CrossBorder: return "us-ca-cross-border"; + case RmsDeploymentProfiles.Compact: return "emac-compact"; + case RmsDeploymentProfiles.LocalMutualAid: return "local-mutual-aid"; + default: return "generic"; + } + } + + public static string DefaultScheme(string profileKey) + { + switch ((profileKey ?? string.Empty).ToLowerInvariant()) + { + case RmsDeploymentProfiles.UsWildland: return "iroc"; + case RmsDeploymentProfiles.CaWildland: return "ciffc"; + case RmsDeploymentProfiles.CrossBorder: return "iroc-ciffc"; + case RmsDeploymentProfiles.Compact: return "emac"; + default: return "local"; + } + } + + private static string SafeUrl(string url) + { + // A safe URL is a plain https link the coordinator typed; never a capability-bearing download link stored from a source. + if (string.IsNullOrWhiteSpace(url)) return null; + return Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps && string.IsNullOrEmpty(uri.Query) ? uri.ToString() : null; + } + + private static RmsExternalOrderFill ToFill(RmsExternalOrder order, RecordDeploymentFillInput input, string userId, DateTime now) + { + if (string.IsNullOrWhiteSpace(input?.RequestNumber)) throw new ArgumentException("Every fill needs the external request number it answers.", nameof(input)); + return new RmsExternalOrderFill + { + RmsExternalOrderFillId = Guid.NewGuid().ToString(), DepartmentId = order.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), RmsExternalOrderId = order.RmsExternalOrderId, RecordId = order.RecordId, + RequestNumber = input.RequestNumber.Trim(), ParentRequestNumber = input.ParentRequestNumber?.Trim(), RequestCategory = input.RequestCategory?.Trim().ToLowerInvariant() ?? "other", FillNumber = input.FillNumber?.Trim(), + ResourceKind = input.ResourceKind?.Trim(), ResourceType = input.ResourceType?.Trim(), ResourceTypeScheme = input.ResourceTypeScheme?.Trim() ?? order.SourceScheme, Position = input.Position?.Trim(), PositionScheme = input.PositionScheme?.Trim() ?? order.SourceScheme, + IsTrainee = input.IsTrainee, HomeUnit = input.HomeUnit?.Trim(), HostAgency = input.HostAgency?.Trim() ?? order.ReceivingAgency, AgencyUnitId = input.AgencyUnitId?.Trim(), PointOfHire = input.PointOfHire?.Trim(), + CostCode = input.CostCode?.Trim() ?? order.CostCode, AgreementReference = input.AgreementReference?.Trim() ?? order.AgreementReference, AssignedUserId = input.AssignedUserId, AssignedUnitId = input.AssignedUnitId, + Status = (int)RmsDeploymentFillStatus.Requested, RequestedOn = input.RequestedOn, NeededOn = input.NeededOn, FilledOn = input.FilledOn, CapturedOffsetMinutes = input.CapturedOffsetMinutes ?? order.CapturedOffsetMinutes, Notes = input.Notes, + CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, ModifiedByUserId = userId, RowVersion = 1 + }; + } + + public async Task GetAsync(int departmentId, string userId, string orderId, bool includeArtifact = false) + { + var order = await _orders.GetByIdForDepartmentAsync(departmentId, orderId, includeArtifact); + if (order == null) return null; + if (!await _authorization.CanUserViewRecordAsync(userId, order.RecordId, departmentId)) throw new UnauthorizedAccessException("Deployment access is not authorized."); + return await BuildAsync(departmentId, order); + } + + public async Task GetForRecordAsync(int departmentId, string userId, string recordId) + { + var order = await _orders.GetForRecordAsync(departmentId, recordId); + if (order == null) return null; + if (!await _authorization.CanUserViewRecordAsync(userId, order.RecordId, departmentId)) throw new UnauthorizedAccessException("Deployment access is not authorized."); + return await BuildAsync(departmentId, order); + } + + private async Task BuildAsync(int departmentId, RmsExternalOrder order) + { + return new RecordDeploymentAggregate + { + Order = order, + Fills = (await _fills.GetForOrderAsync(departmentId, order.RmsExternalOrderId))?.OrderBy(f => f.RequestNumber, StringComparer.Ordinal).ToList() ?? new List(), + Record = await _records.GetAsync(departmentId, order.RecordId, true), + Profile = await _packs.GetProfileAsync(ProfileFor(order.ProfileKey)), + HomeProfile = await _packs.GetProfileAsync(order.HomeProfileKey), + HostProfile = await _packs.GetProfileAsync(order.HostProfileKey) + }; + } + + public async Task> ListAsync(int departmentId, string userId, bool includeClosed) + { + var orders = (await _orders.GetForDepartmentAsync(departmentId, includeClosed))?.ToList() ?? new List(); + var visible = new List(); + foreach (var order in orders) + if (await _authorization.CanUserViewRecordAsync(userId, order.RecordId, departmentId)) visible.Add(order); + return visible; + } + + public async Task AddFillAsync(int departmentId, string userId, string orderId, RecordDeploymentFillInput input, CancellationToken cancellationToken = default) + { + var order = await RequireEditableAsync(departmentId, userId, orderId); + var fill = ToFill(order, input, userId, DateTime.UtcNow); + await InTransactionAsync(async () => + { + await _fills.InsertAsync(fill, cancellationToken, true); + await TouchAsync(order, userId, cancellationToken); + await AuditAsync(departmentId, userId, order, $"Add fill {fill.RequestNumber}", cancellationToken); + }); + return fill; + } + + private static readonly Dictionary Allowed = new Dictionary + { + [RmsDeploymentFillStatus.Requested] = new[] { RmsDeploymentFillStatus.Accepted, RmsDeploymentFillStatus.Declined }, + [RmsDeploymentFillStatus.Accepted] = new[] { RmsDeploymentFillStatus.Mobilized, RmsDeploymentFillStatus.Declined, RmsDeploymentFillStatus.Released }, + [RmsDeploymentFillStatus.Mobilized] = new[] { RmsDeploymentFillStatus.CheckedIn, RmsDeploymentFillStatus.Released }, + [RmsDeploymentFillStatus.CheckedIn] = new[] { RmsDeploymentFillStatus.Assigned, RmsDeploymentFillStatus.Released }, + [RmsDeploymentFillStatus.Assigned] = new[] { RmsDeploymentFillStatus.Assigned, RmsDeploymentFillStatus.Released }, + [RmsDeploymentFillStatus.Released] = new[] { RmsDeploymentFillStatus.Demobilized, RmsDeploymentFillStatus.Returned }, + [RmsDeploymentFillStatus.Demobilized] = new[] { RmsDeploymentFillStatus.Returned }, + [RmsDeploymentFillStatus.Declined] = new RmsDeploymentFillStatus[0], + [RmsDeploymentFillStatus.Returned] = new RmsDeploymentFillStatus[0] + }; + + public async Task TransitionFillAsync(int departmentId, string userId, string fillId, RecordDeploymentFillTransitionInput input, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + var fill = await _fills.GetByIdForDepartmentAsync(departmentId, fillId) ?? throw new ArgumentException("Unknown fill.", nameof(fillId)); + var order = await RequireEditableAsync(departmentId, userId, fill.RmsExternalOrderId); + 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}."); + if (input.Status == RmsDeploymentFillStatus.Declined && string.IsNullOrWhiteSpace(input.Reason)) throw new ArgumentException("Declining a request needs a reason.", nameof(input)); + + var when = input.OccurredOn ?? DateTime.UtcNow; + fill.Status = (int)input.Status; + fill.CapturedOffsetMinutes = input.CapturedOffsetMinutes ?? fill.CapturedOffsetMinutes; + if (!string.IsNullOrWhiteSpace(input.Notes)) fill.Notes = string.IsNullOrWhiteSpace(fill.Notes) ? input.Notes : fill.Notes + "\n" + input.Notes; + if (!string.IsNullOrWhiteSpace(input.RosterJson)) fill.RosterJson = input.RosterJson; + if (!string.IsNullOrWhiteSpace(input.TravelJson)) fill.TravelJson = input.TravelJson; + switch (input.Status) + { + case RmsDeploymentFillStatus.Accepted: fill.FilledOn ??= when; break; + case RmsDeploymentFillStatus.Declined: fill.DeclineReason = input.Reason; break; + case RmsDeploymentFillStatus.Mobilized: fill.MobilizedOn = when; break; + case RmsDeploymentFillStatus.CheckedIn: fill.CheckedInOn = when; break; + case RmsDeploymentFillStatus.Assigned: fill.AssignedOn = when; break; + case RmsDeploymentFillStatus.Released: fill.ReleasedOn = when; break; + case RmsDeploymentFillStatus.Demobilized: fill.DemobilizedOn = when; break; + case RmsDeploymentFillStatus.Returned: fill.ReturnedOn = when; break; + } + fill.ModifiedOn = DateTime.UtcNow; fill.ModifiedByUserId = userId; fill.RowVersion += 1; + + await InTransactionAsync(async () => + { + 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(); + if (active.Any(f => f.Status >= (int)RmsDeploymentFillStatus.Mobilized) && order.Status == (int)RmsExternalOrderStatus.Open) { order.Status = (int)RmsExternalOrderStatus.Mobilized; order.MobilizedOn ??= when; } + if (active.Count > 0 && active.All(f => f.Status >= (int)RmsDeploymentFillStatus.Released) && order.Status < (int)RmsExternalOrderStatus.Released) { order.Status = (int)RmsExternalOrderStatus.Released; order.ReleasedOn ??= when; } + await TouchAsync(order, userId, cancellationToken); + await AuditAsync(departmentId, userId, order, $"Fill {fill.RequestNumber}: {from} -> {input.Status}", cancellationToken); + }); + return fill; + } + + public async Task RecordSourceSnapshotAsync(int departmentId, string userId, string orderId, string sourceVersion, byte[] artifact, string fileName, string contentType, CancellationToken cancellationToken = default) + { + var order = await RequireEditableAsync(departmentId, userId, orderId, true); + if (artifact == null || artifact.Length == 0) throw new ArgumentException("A snapshot needs the source artifact.", nameof(artifact)); + var now = DateTime.UtcNow; + await InTransactionAsync(async () => + { + if (order.ArtifactChecksum != null) + { + // The previous snapshot stays on record as a versioned reference; a later import never erases what was signed against. + await _references.InsertAsync(new RmsExternalReference + { + RmsExternalReferenceId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = order.RecordId, RecordKind = (int)RmsRecordKind.Operational, + SourceSubsystem = "external-order", SourceEntityType = "order-snapshot", SourceEntityId = order.OrderNumber, IdentifierScheme = order.SourceScheme, SourceVersion = order.SourceVersion, SemanticRole = "superseded-snapshot", + CapturedOn = order.SourceCapturedOn ?? order.CreatedOn, CapturedByUserId = order.ModifiedByUserId, Checksum = order.ArtifactChecksum, + SnapshotJson = JsonConvert.SerializeObject(new { order.ArtifactFileName, order.ArtifactContentType, order.SourceVersion, supersededOn = now, supersededBy = userId }), + CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }, cancellationToken, true); + } + order.ArtifactData = artifact; order.ArtifactFileName = fileName; order.ArtifactContentType = contentType; order.ArtifactChecksum = RecordSnapshotSerializer.Checksum(artifact); + order.SourceVersion = string.IsNullOrWhiteSpace(sourceVersion) ? (int.TryParse(order.SourceVersion, out var v) ? (v + 1).ToString(CultureInfo.InvariantCulture) : now.ToString("yyyyMMddHHmmss")) : sourceVersion.Trim(); + order.SourceCapturedOn = now; + await TouchAsync(order, userId, cancellationToken); + await AuditAsync(departmentId, userId, order, $"Record source snapshot v{order.SourceVersion}", cancellationToken); + }); + return order; + } + + public async Task CloseoutAsync(int departmentId, string userId, string orderId, long expectedRowVersion, string notes, CancellationToken cancellationToken = default) + { + var order = await RequireEditableAsync(departmentId, userId, orderId); + if (order.RowVersion != expectedRowVersion) throw new RecordConcurrencyException(order.RmsExternalOrderId, expectedRowVersion, order.RowVersion); + var fills = (await _fills.GetForOrderAsync(departmentId, orderId))?.ToList() ?? new List(); + var active = fills.Where(f => f.Status != (int)RmsDeploymentFillStatus.Declined).ToList(); + if (active.Count == 0) throw new InvalidOperationException("A deployment with no accepted fill has nothing to close out."); + var notReturned = active.Where(f => f.Status != (int)RmsDeploymentFillStatus.Returned).Select(f => f.RequestNumber).ToList(); + if (notReturned.Count > 0) throw new InvalidOperationException("Closeout needs every resource back at its home unit; still out: " + string.Join(", ", notReturned) + ". An external release flag does not return a resource."); + + var now = DateTime.UtcNow; + await InTransactionAsync(async () => + { + order.Status = (int)RmsExternalOrderStatus.ClosedOut; order.ClosedOutOn = now; order.ClosedOutByUserId = userId; order.CloseoutNotes = notes; + await TouchAsync(order, userId, cancellationToken); + await AuditAsync(departmentId, userId, order, "Closeout deployment", cancellationToken); + }); + return order; + } + + private async Task RequireEditableAsync(int departmentId, string userId, string orderId, bool includeArtifact = false) + { + var order = await _orders.GetByIdForDepartmentAsync(departmentId, orderId, includeArtifact) ?? throw new ArgumentException("Unknown deployment.", nameof(orderId)); + if (!await _authorization.CanUserViewRecordAsync(userId, order.RecordId, departmentId) || !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord)) + throw new UnauthorizedAccessException("Editing this deployment is not authorized."); + if (order.Status == (int)RmsExternalOrderStatus.ClosedOut) throw new InvalidOperationException("The deployment is closed out; amend the Record to change it."); + return order; + } + + private async Task TouchAsync(RmsExternalOrder order, string userId, CancellationToken cancellationToken) + { + order.ModifiedOn = DateTime.UtcNow; order.ModifiedByUserId = userId; order.RowVersion += 1; + await _orders.UpdateAsync(order, cancellationToken, true); + } + + private Task AuditAsync(int departmentId, string userId, RmsExternalOrder order, string purpose, CancellationToken cancellationToken) + => _audits.InsertAsync(new RmsAccessAudit { DepartmentId = departmentId, RecordId = order.RecordId, Action = (int)RmsAccessAuditAction.Change, ActorUserId = userId, Purpose = purpose, OriginClient = (int)RmsOriginClient.Web, Successful = true, OccurredOn = DateTime.UtcNow, CorrelationId = order.RmsExternalOrderId }, cancellationToken, true); + + private async Task InTransactionAsync(Func work) + { + _unitOfWork.CreateOrGetConnection(); + try { await work(); _unitOfWork.CommitChanges(); } + catch { _unitOfWork.DiscardChanges(); throw; } + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs b/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs index 928448ba..3631aa40 100644 --- a/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs +++ b/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs @@ -92,6 +92,12 @@ public async Task GetAsync(int departmentId, string use if (await _sourceAuthorization.Value.CanUserViewPersonAsync(userId, person.UserId, departmentId)) selection.Choices.Add(new RecordEvidenceChoice { Id = person.UserId, Label = person.Name }); } + else if (sourceKind == RmsEvidenceKind.ModuleProjection) + { + // RMS-1C pack projections: one choice per composable owning-module projection. + foreach (var projection in Evidence.RecordPackProjectionKinds.All) + selection.Choices.Add(new RecordEvidenceChoice { Id = projection, Label = projection }); + } else if (sourceKind == RmsEvidenceKind.ChatPromotion && context.CallId.HasValue) { foreach (var channel in (await _channels.GetByCallIdAsync(context.CallId.Value) ?? Enumerable.Empty()) diff --git a/Core/Resgrid.Services/Records/RecordSavedReportsService.cs b/Core/Resgrid.Services/Records/RecordSavedReportsService.cs new file mode 100644 index 00000000..d20591a8 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordSavedReportsService.cs @@ -0,0 +1,333 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Department saved reports over one definition (RMS plan section 4.1 "Reporting and presentation", RMS-1B): + /// allowlisted typed columns, bounded filters, one group-by and count/sum/avg/min/max where the pinned field + /// allows it. Runs go through the visibility-filtered projection query, never raw SQL, and are capped at + /// . Cross-version columns need an explicit mapping; unmapped + /// versions are reported, never coerced. + /// + public class RecordSavedReportsService : IRecordSavedReportsService + { + public static readonly IReadOnlyDictionary BuiltInColumns = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["record.number"] = "Record number", ["record.draft_reference"] = "Draft reference", ["record.state"] = "State", ["record.definition_version"] = "Definition version", + ["record.started_on"] = "Started", ["record.ended_on"] = "Ended", ["record.finalized_on"] = "Finalized", ["record.author"] = "Author", ["record.group"] = "Station / group", ["record.call_id"] = "Call" + }; + + private readonly IRmsSavedReportDefinitionsRepository _reports; + private readonly IRecordDefinitionsService _definitions; + private readonly IRecordsService _records; + private readonly IRmsOperationalRecordsRepository _recordRows; + private readonly IRmsRecordValuesRepository _values; + private readonly IRmsRecordValueGroupsRepository _groups; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRmsAccessAuditsRepository _audits; + + public RecordSavedReportsService(IRmsSavedReportDefinitionsRepository reports, IRecordDefinitionsService definitions, IRecordsService records, IRmsOperationalRecordsRepository recordRows, + IRmsRecordValuesRepository values, IRmsRecordValueGroupsRepository groups, IRecordsAuthorizationService authorization, IRmsAccessAuditsRepository audits) + { + _reports = reports; + _definitions = definitions; + _records = records; + _recordRows = recordRows; + _values = values; + _groups = groups; + _authorization = authorization; + _audits = audits; + } + + public async Task> GetForDepartmentAsync(int departmentId) => (await _reports.GetForDepartmentAsync(departmentId))?.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase).ToList() ?? new List(); + + public Task GetAsync(int departmentId, string reportId) => _reports.GetByIdForDepartmentAsync(departmentId, reportId); + + public async Task ValidateAsync(int departmentId, RmsSavedReportDefinition report) + { + var result = new RecordReportValidation(); + var issues = result.Issues; + if (report == null) { issues.Add(RecordDefinitionIssue.Error("", "missing", "Nothing to validate.")); return result; } + if (string.IsNullOrWhiteSpace(report.Name) || report.Name.Length > 200) issues.Add(RecordDefinitionIssue.Error("name", "required", "A report name of at most 200 characters is required.")); + var aggregate = string.IsNullOrWhiteSpace(report.DefinitionKey) ? null : await _definitions.GetAsync(departmentId, report.DefinitionKey); + if (aggregate == null) { issues.Add(RecordDefinitionIssue.Error("definitionKey", "unknown_definition", "Saved reports run over one department definition.")); return result; } + var version = report.DefinitionVersion.HasValue ? aggregate.Versions.FirstOrDefault(v => v.Version == report.DefinitionVersion) : aggregate.Published; + if (version == null) { issues.Add(RecordDefinitionIssue.Error("definitionVersion", "unknown_version", "The definition has no such published version.")); return result; } + var schema = version.Schema; + var spec = report.Spec; + if (spec.Columns.Count == 0) issues.Add(RecordDefinitionIssue.Error("columns", "no_columns", "Choose at least one column.")); + if (spec.Columns.Count > RmsSavedReportDefinition.MaxColumns) issues.Add(RecordDefinitionIssue.Error("columns", "too_many", $"At most {RmsSavedReportDefinition.MaxColumns} columns.")); + if (spec.Filters.Count > RmsSavedReportDefinition.MaxFilters) issues.Add(RecordDefinitionIssue.Error("filters", "too_many", $"At most {RmsSavedReportDefinition.MaxFilters} filters.")); + if (spec.WindowDays.HasValue && (spec.WindowDays < 1 || spec.WindowDays > 3660)) issues.Add(RecordDefinitionIssue.Error("windowDays", "out_of_range", "The window is 1 to 3660 days.")); + if (report.MaxRowsPerRun < 1 || report.MaxRowsPerRun > RmsSavedReportDefinition.MaxRows) issues.Add(RecordDefinitionIssue.Error("maxRowsPerRun", "out_of_range", $"Rows per run is 1 to {RmsSavedReportDefinition.MaxRows}.")); + foreach (var column in spec.Columns) + { + if (BuiltInColumns.ContainsKey(column)) continue; + var field = schema.FindField(column); + if (field == null) { issues.Add(RecordDefinitionIssue.Error("columns", "unknown_field", $"'{column}' is not a field of version {version.Version}.")); continue; } + if (!field.Exportable) issues.Add(RecordDefinitionIssue.Error("columns", "not_exportable", $"'{column}' is not exportable.")); + if (field.Classification == RmsFieldClassification.Protected) issues.Add(RecordDefinitionIssue.Error("columns", "protected", $"'{column}' is protected; protected values never enter a saved report.")); + if (field.Classification == RmsFieldClassification.Restricted && !report.IncludeRestricted) issues.Add(RecordDefinitionIssue.Error("columns", "restricted", $"'{column}' is restricted; enable IncludeRestricted (RecordRestricted_View is checked at run time).")); + } + foreach (var filter in spec.Filters) + { + var field = schema.FindField(filter.FieldKey); + if (field == null && !BuiltInColumns.ContainsKey(filter.FieldKey ?? string.Empty)) { issues.Add(RecordDefinitionIssue.Error("filters", "unknown_field", $"Filter '{filter.FieldKey}' names no field.")); continue; } + if (field != null && !field.Filterable) issues.Add(RecordDefinitionIssue.Error("filters", "not_filterable", $"'{filter.FieldKey}' is not filterable.")); + if (field != null && field.Classification != RmsFieldClassification.Standard && !report.IncludeRestricted) issues.Add(RecordDefinitionIssue.Error("filters", "restricted", $"'{filter.FieldKey}' is restricted.")); + if (filter.Operator == RmsRuleOperator.And || filter.Operator == RmsRuleOperator.Or) issues.Add(RecordDefinitionIssue.Error("filters", "bad_operator", "Report filters combine with AND; nested AND/OR is not a filter.")); + } + if (!string.IsNullOrWhiteSpace(spec.GroupByFieldKey)) + { + var field = schema.FindField(spec.GroupByFieldKey); + if (field == null && !BuiltInColumns.ContainsKey(spec.GroupByFieldKey)) issues.Add(RecordDefinitionIssue.Error("groupBy", "unknown_field", $"'{spec.GroupByFieldKey}' names no field.")); + else if (field != null && !field.Groupable) issues.Add(RecordDefinitionIssue.Error("groupBy", "not_groupable", $"'{spec.GroupByFieldKey}' cannot group a report.")); + } + foreach (var aggregate2 in spec.Aggregates) + { + if (aggregate2.Aggregate == RmsReportAggregate.Count) continue; + var field = schema.FindField(aggregate2.FieldKey); + if (field == null) issues.Add(RecordDefinitionIssue.Error("aggregates", "unknown_field", $"Aggregate '{aggregate2.FieldKey}' names no field.")); + else if (!field.Aggregatable) issues.Add(RecordDefinitionIssue.Error("aggregates", "not_aggregatable", $"'{aggregate2.FieldKey}' cannot be summed or averaged.")); + } + foreach (var mapping in spec.VersionMappings) + if (!aggregate.Versions.Any(v => v.Version == mapping.Key)) issues.Add(RecordDefinitionIssue.Warning("versionMappings", "unknown_version", $"Mapping for version {mapping.Key} names a version that does not exist.")); + return result; + } + + public async Task SaveAsync(int departmentId, string userId, RmsSavedReportDefinition report, CancellationToken cancellationToken = default) + { + if (report == null) throw new ArgumentNullException(nameof(report)); + await RequireManageAsync(userId, departmentId); + if (report.IncludeRestricted && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) + throw new UnauthorizedAccessException("Including restricted fields needs RecordRestricted_View."); + var validation = await ValidateAsync(departmentId, report); + if (!validation.IsValid) throw new ArgumentException(string.Join(" ", validation.Issues.Where(i => i.Severity == "error").Select(i => i.Message))); + + var now = DateTime.UtcNow; + var existing = string.IsNullOrWhiteSpace(report.RmsSavedReportDefinitionId) ? null : await _reports.GetByIdForDepartmentAsync(departmentId, report.RmsSavedReportDefinitionId); + if (existing == null) + { + report.RmsSavedReportDefinitionId = Guid.NewGuid().ToString(); + report.DepartmentId = departmentId; + report.ProtectionId = Guid.NewGuid().ToString(); + report.CreatedOn = now; report.CreatedByUserId = userId; report.ModifiedOn = now; report.ModifiedByUserId = userId; report.RowVersion = 1; + await _reports.InsertAsync(report, cancellationToken, true); + await AuditAsync(departmentId, userId, report, "Create saved report", cancellationToken); + return report; + } + if (existing.RowVersion != report.RowVersion) throw new RecordConcurrencyException(existing.RmsSavedReportDefinitionId, report.RowVersion, existing.RowVersion); + existing.Name = report.Name; existing.Description = report.Description; existing.DefinitionKey = report.DefinitionKey; existing.DefinitionVersion = report.DefinitionVersion; + existing.SpecJson = report.SpecJson; existing.MaxRowsPerRun = report.MaxRowsPerRun; existing.IncludeRestricted = report.IncludeRestricted; + existing.ModifiedOn = now; existing.ModifiedByUserId = userId; existing.RowVersion += 1; + await _reports.UpdateAsync(existing, cancellationToken, true); + await AuditAsync(departmentId, userId, existing, "Update saved report", cancellationToken); + return existing; + } + + public async Task DeleteAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default) + { + await RequireManageAsync(userId, departmentId); + var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId); + if (report == null) return false; + report.DeletedOn = DateTime.UtcNow; report.ModifiedOn = report.DeletedOn.Value; report.ModifiedByUserId = userId; report.RowVersion += 1; + await _reports.UpdateAsync(report, cancellationToken, true); + await AuditAsync(departmentId, userId, report, "Delete saved report", cancellationToken); + return true; + } + + public async Task RunAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default) + { + var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId) ?? throw new ArgumentException("Unknown report.", nameof(reportId)); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord) && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordReports)) + throw new UnauthorizedAccessException("Running Record reports is not authorized."); + var canViewRestricted = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); + if (report.IncludeRestricted && !canViewRestricted) throw new UnauthorizedAccessException("This report includes restricted fields; RecordRestricted_View is required to run it."); + var validation = await ValidateAsync(departmentId, report); + if (!validation.IsValid) throw new InvalidOperationException("The report no longer validates: " + string.Join(" ", validation.Issues.Where(i => i.Severity == "error").Select(i => i.Message))); + + var aggregate = await _definitions.GetAsync(departmentId, report.DefinitionKey); + var versions = aggregate.Versions.ToDictionary(v => v.Version); + var reportVersion = report.DefinitionVersion.HasValue ? versions[report.DefinitionVersion.Value] : aggregate.Published; + var spec = report.Spec; + var take = Math.Min(report.MaxRowsPerRun <= 0 ? RmsSavedReportDefinition.MaxRows : report.MaxRowsPerRun, RmsSavedReportDefinition.MaxRows); + + var states = spec.IncludeDrafts + ? new[] { RmsRecordState.Draft, RmsRecordState.ReadyForReview, RmsRecordState.Returned, RmsRecordState.Approved, RmsRecordState.Finalized, RmsRecordState.Amended, RmsRecordState.Submitted, RmsRecordState.Accepted, RmsRecordState.Rejected, RmsRecordState.Corrected } + : new[] { RmsRecordState.Finalized, RmsRecordState.Amended, RmsRecordState.Submitted, RmsRecordState.Accepted, RmsRecordState.Rejected, RmsRecordState.Corrected }; + var query = new RmsRecordQuery + { + DefinitionKey = aggregate.Definition.DefinitionKey, States = states.Select(s => (int)s).ToList(), ViewerUserId = userId, Skip = 0, Take = take + 1, + VisibleGroupIds = await _authorization.IsGroupScopedAsync(departmentId) ? await _authorization.GetVisibleGroupIdsAsync(userId, departmentId) : null + }; + var projections = await _records.QueryAsync(departmentId, query); + var since = spec.WindowDays.HasValue ? DateTime.UtcNow.AddDays(-spec.WindowDays.Value) : (DateTime?)null; + if (since.HasValue) projections = projections.Where(p => (p.FinalizedOn ?? p.OccurredOn ?? p.RecordCreatedOn) >= since.Value).ToList(); + var truncated = projections.Count > take; + projections = projections.Take(take).ToList(); + + var rows = (await _recordRows.GetByIdsAsync(departmentId, projections.Select(p => p.SourceId)))?.ToList() ?? new List(); + var revisionIds = rows.Where(r => r.CurrentRevisionId != null && !RmsLifecycle.IsEditable((RmsRecordState)r.State)).Select(r => r.CurrentRevisionId).ToList(); + var draftIds = rows.Where(r => r.CurrentRevisionId == null || RmsLifecycle.IsEditable((RmsRecordState)r.State)).Select(r => r.RmsOperationalRecordId).ToList(); + var valueRows = new List(); var groupRows = new List(); + if (revisionIds.Count > 0) { valueRows.AddRange(await _values.GetForRevisionsAsync(departmentId, revisionIds) ?? Enumerable.Empty()); groupRows.AddRange(await _groups.GetForRevisionsAsync(departmentId, revisionIds) ?? Enumerable.Empty()); } + if (draftIds.Count > 0) { valueRows.AddRange(await _values.GetForRecordsAsync(departmentId, draftIds, true) ?? Enumerable.Empty()); groupRows.AddRange(await _groups.GetForRecordsAsync(departmentId, draftIds, true) ?? Enumerable.Empty()); } + + var result = new RecordReportResult { ReportId = report.RmsSavedReportDefinitionId, Name = report.Name, DefinitionKey = report.DefinitionKey, DefinitionVersion = reportVersion?.Version, RanOn = DateTime.UtcNow, Columns = spec.Columns.ToList(), Truncated = truncated }; + result.ColumnLabels = spec.Columns.Select(c => BuiltInColumns.TryGetValue(c, out var l) ? l : reportVersion?.Schema.FindField(c)?.Label ?? c).ToList(); + + var shaped = new List<(RmsOperationalRecord Record, RecordValueSet Values, RecordDefinitionSchema Schema, Dictionary Map)>(); + foreach (var record in rows) + { + if (!versions.TryGetValue(record.DefinitionVersion, out var version)) { if (!result.UnmappedVersions.Contains(record.DefinitionVersion)) result.UnmappedVersions.Add(record.DefinitionVersion); continue; } + var isDraft = draftIds.Contains(record.RmsOperationalRecordId); + var set = RecordTypedValuesService.Shape(version.Schema, + groupRows.Where(g => g.RecordId == record.RmsOperationalRecordId && (isDraft ? g.RevisionId == null : g.RevisionId == record.CurrentRevisionId)), + valueRows.Where(v => v.RecordId == record.RmsOperationalRecordId && (isDraft ? v.RevisionId == null : v.RevisionId == record.CurrentRevisionId)), canViewRestricted && report.IncludeRestricted); + Dictionary map = null; + if (reportVersion != null && record.DefinitionVersion != reportVersion.Version) + { + if (!spec.VersionMappings.TryGetValue(record.DefinitionVersion, out map)) + { + // Same keys carry over; anything else is unmapped and the version is reported. + map = spec.Columns.Concat(spec.Filters.Select(f => f.FieldKey)).Concat(new[] { spec.GroupByFieldKey }).Concat(spec.Aggregates.Select(a => a.FieldKey)).Where(k => k != null && version.Schema.FindField(k) != null).Distinct(StringComparer.OrdinalIgnoreCase).ToDictionary(k => k, k => k, StringComparer.OrdinalIgnoreCase); + if (spec.Columns.Any(c => !BuiltInColumns.ContainsKey(c) && !map.ContainsKey(c)) && !result.UnmappedVersions.Contains(record.DefinitionVersion)) result.UnmappedVersions.Add(record.DefinitionVersion); + } + } + shaped.Add((record, set, version.Schema, map)); + } + + var matched = shaped.Where(s => spec.Filters.All(f => Matches(f, Cell(s, f.FieldKey), s.Record))).ToList(); + result.TotalMatched = matched.Count; + IEnumerable<(RmsOperationalRecord Record, RecordValueSet Values, RecordDefinitionSchema Schema, Dictionary Map)> ordered = matched; + if (!string.IsNullOrWhiteSpace(spec.SortFieldKey)) + { + Func<(RmsOperationalRecord Record, RecordValueSet Values, RecordDefinitionSchema Schema, Dictionary Map), object> key = s => { var c = Cell(s, spec.SortFieldKey); return c?.Number ?? (object)(c?.Value ?? BuiltIn(s.Record, spec.SortFieldKey) ?? string.Empty); }; + ordered = spec.SortDescending ? matched.OrderByDescending(key, Comparer.Create(CompareValues)) : matched.OrderBy(key, Comparer.Create(CompareValues)); + } + foreach (var item in ordered) + result.Rows.Add(spec.Columns.Select(c => BuiltInColumns.ContainsKey(c) ? BuiltIn(item.Record, c) : Cell(item, c)?.Display ?? string.Empty).ToList()); + + if (!string.IsNullOrWhiteSpace(spec.GroupByFieldKey) || spec.Aggregates.Count > 0) + { + var groups = string.IsNullOrWhiteSpace(spec.GroupByFieldKey) + ? new[] { new { Key = "(all)", Items = matched } }.Select(g => (g.Key, g.Items.AsEnumerable())) + : matched.GroupBy(s => BuiltInColumns.ContainsKey(spec.GroupByFieldKey) ? BuiltIn(s.Record, spec.GroupByFieldKey) ?? "(blank)" : Cell(s, spec.GroupByFieldKey)?.Display ?? "(blank)", StringComparer.OrdinalIgnoreCase).Select(g => (g.Key, g.AsEnumerable())); + foreach (var (groupKey, items) in groups.OrderBy(g => g.Item1, StringComparer.OrdinalIgnoreCase)) + { + var list = items.ToList(); + var group = new RecordReportGroup { GroupKey = groupKey, GroupLabel = groupKey, Count = list.Count }; + foreach (var aggregate2 in spec.Aggregates) + { + var name = aggregate2.Aggregate == RmsReportAggregate.Count ? "count" : aggregate2.Aggregate.ToString().ToLowerInvariant() + ":" + aggregate2.FieldKey; + if (aggregate2.Aggregate == RmsReportAggregate.Count) { group.Aggregates[name] = list.Count; continue; } + var numbers = list.Select(s => Cell(s, aggregate2.FieldKey)).Where(c => c?.Number.HasValue == true).Select(c => c.CanonicalNumber ?? c.Number.Value).ToList(); + group.Aggregates[name] = numbers.Count == 0 ? (decimal?)null : aggregate2.Aggregate switch + { + RmsReportAggregate.Sum => numbers.Sum(), + RmsReportAggregate.Average => decimal.Round(numbers.Average(), 4), + RmsReportAggregate.Minimum => numbers.Min(), + RmsReportAggregate.Maximum => numbers.Max(), + _ => null + }; + } + result.Groups.Add(group); + } + } + if (result.UnmappedVersions.Count > 0) result.Warnings.Add("Definition versions without a column mapping: " + string.Join(", ", result.UnmappedVersions.OrderBy(v => v)) + ". Declare a mapping to include them."); + if (truncated) result.Warnings.Add($"The run stopped at {take} records; narrow the window or filters."); + + report.LastRunOn = result.RanOn; report.LastRunByUserId = userId; + await _reports.UpdateAsync(report, cancellationToken, true); + await AuditAsync(departmentId, userId, report, $"Run saved report ({result.TotalMatched} records)", cancellationToken); + return result; + } + + private static RecordValueCell Cell((RmsOperationalRecord Record, RecordValueSet Values, RecordDefinitionSchema Schema, Dictionary Map) item, string key) + { + if (string.IsNullOrWhiteSpace(key) || BuiltInColumns.ContainsKey(key)) return null; + var mapped = item.Map == null ? key : item.Map.TryGetValue(key, out var m) ? m : null; + if (mapped == null) return null; + return item.Values.Scalar(mapped) ?? item.Values.Sections.Where(s => s.Repeating).SelectMany(s => s.Rows).SelectMany(r => r.Cells).FirstOrDefault(c => string.Equals(c.FieldKey, mapped, StringComparison.OrdinalIgnoreCase)); + } + + private static string BuiltIn(RmsOperationalRecord record, string key) + { + switch ((key ?? string.Empty).ToLowerInvariant()) + { + case "record.number": return record.RecordNumber; + case "record.draft_reference": return record.DraftReference; + case "record.state": return ((RmsRecordState)record.State).ToString(); + case "record.definition_version": return record.DefinitionVersion.ToString(CultureInfo.InvariantCulture); + case "record.started_on": return record.StartedOn?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + case "record.ended_on": return record.EndedOn?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + case "record.finalized_on": return record.FinalizedOn?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + case "record.author": return record.AuthorUserId; + case "record.group": return record.StationGroupId?.ToString(CultureInfo.InvariantCulture); + case "record.call_id": return record.CallId?.ToString(CultureInfo.InvariantCulture); + default: return null; + } + } + + private static bool Matches(RecordReportFilter filter, RecordValueCell cell, RmsOperationalRecord record) + { + var value = cell?.Value ?? BuiltIn(record, filter.FieldKey); + var values = cell?.Values ?? (value == null ? new List() : new List { value }); + switch (filter.Operator) + { + case RmsRuleOperator.IsEmpty: return values.Count == 0 && string.IsNullOrWhiteSpace(value); + case RmsRuleOperator.IsNotEmpty: return values.Count > 0 && !string.IsNullOrWhiteSpace(value); + case RmsRuleOperator.Equals: return values.Any(v => string.Equals(v, filter.Value, StringComparison.OrdinalIgnoreCase)) || cell != null && string.Equals(cell.Display, filter.Value, StringComparison.OrdinalIgnoreCase); + case RmsRuleOperator.NotEquals: return !(values.Any(v => string.Equals(v, filter.Value, StringComparison.OrdinalIgnoreCase)) || cell != null && string.Equals(cell.Display, filter.Value, StringComparison.OrdinalIgnoreCase)); + case RmsRuleOperator.InSet: return values.Any(v => (filter.Values ?? new List()).Contains(v, StringComparer.OrdinalIgnoreCase)); + case RmsRuleOperator.NotInSet: return !values.Any(v => (filter.Values ?? new List()).Contains(v, StringComparer.OrdinalIgnoreCase)); + case RmsRuleOperator.InRange: + if (cell?.Number.HasValue == true) { var n = cell.CanonicalNumber ?? cell.Number.Value; return (!filter.Min.HasValue || n >= filter.Min) && (!filter.Max.HasValue || n <= filter.Max); } + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var when)) return (!filter.MinDate.HasValue || when >= filter.MinDate) && (!filter.MaxDate.HasValue || when <= filter.MaxDate); + return false; + default: return true; + } + } + + private static int CompareValues(object a, object b) + { + if (a is decimal x && b is decimal y) return x.CompareTo(y); + return string.Compare(Convert.ToString(a, CultureInfo.InvariantCulture), Convert.ToString(b, CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); + } + + public string ToCsv(RecordReportResult result) + { + var sb = new StringBuilder(); + sb.Append((char)0xFEFF); + sb.AppendLine(string.Join(",", result.ColumnLabels.Select(l => RecordsExportRenderer.Cell(l, ",")))); + foreach (var row in result.Rows) sb.AppendLine(string.Join(",", row.Select(c => RecordsExportRenderer.Cell(c, ",")))); + if (result.Groups.Count > 0) + { + sb.AppendLine(); + var aggregateNames = result.Groups.SelectMany(g => g.Aggregates.Keys).Distinct().ToList(); + sb.AppendLine(string.Join(",", new[] { "Group", "Count" }.Concat(aggregateNames).Select(l => RecordsExportRenderer.Cell(l, ",")))); + foreach (var group in result.Groups) + sb.AppendLine(string.Join(",", new[] { group.GroupLabel, group.Count.ToString(CultureInfo.InvariantCulture) }.Concat(aggregateNames.Select(n => group.Aggregates.TryGetValue(n, out var v) && v.HasValue ? v.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)).Select(c => RecordsExportRenderer.Cell(c, ",")))); + } + return sb.ToString(); + } + + private async Task RequireManageAsync(string userId, int departmentId) + { + if (string.IsNullOrWhiteSpace(userId) || !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordReports)) + throw new UnauthorizedAccessException("Managing Record reports is not authorized."); + } + + private Task AuditAsync(int departmentId, string userId, RmsSavedReportDefinition report, string purpose, CancellationToken cancellationToken) + => _audits.InsertAsync(new RmsAccessAudit { DepartmentId = departmentId, RecordId = report.RmsSavedReportDefinitionId, Action = (int)RmsAccessAuditAction.Export, ActorUserId = userId, Purpose = purpose + " " + report.Name, OriginClient = (int)RmsOriginClient.Web, Successful = true, OccurredOn = DateTime.UtcNow, CorrelationId = report.RmsSavedReportDefinitionId }, cancellationToken, true); + } +} diff --git a/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs b/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs index ec4ab334..9288bcae 100644 --- a/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs +++ b/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs @@ -144,10 +144,52 @@ public static List Diff(RecordSnapshot from, RecordSnapshot to, DiffSet(diffs, "Units", from.Units.Select(u => $"{u.UnitId}|{Iso(u.Dispatched)}|{Iso(u.Enroute)}|{Iso(u.OnScene)}|{Iso(u.Released)}|{Iso(u.InQuarters)}"), to.Units.Select(u => $"{u.UnitId}|{Iso(u.Dispatched)}|{Iso(u.Enroute)}|{Iso(u.OnScene)}|{Iso(u.Released)}|{Iso(u.InQuarters)}")); DiffSet(diffs, "Attachments", from.Attachments.Select(a => a.RmsRecordAttachmentId + ":" + a.Checksum), to.Attachments.Select(a => a.RmsRecordAttachmentId + ":" + a.Checksum)); + DiffValues(diffs, from.Values, to.Values, canViewRestricted); return diffs; } + /// Suffix ToSnapshot puts on a restricted typed value's label so history and diffs can withhold it without the schema. + public const string RestrictedValueSuffix = " [restricted]"; + + /// Department-definition values (RMS-1B): flattened "Section / Field" (or "Section / Item n / Field") paths, pinned to the version's labels. + public static void DiffValues(List diffs, Dictionary from, Dictionary to, bool canViewRestricted) + { + var a = FlattenValues(from); var b = FlattenValues(to); + foreach (var key in a.Keys.Union(b.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + a.TryGetValue(key, out var oldValue); b.TryGetValue(key, out var newValue); + var slash = key.IndexOf(" / ", StringComparison.Ordinal); + var section = slash < 0 ? key : key.Substring(0, slash); + var field = slash < 0 ? key : key.Substring(slash + 3); + Compare(diffs, section, field.EndsWith(RestrictedValueSuffix, StringComparison.Ordinal) ? field.Substring(0, field.Length - RestrictedValueSuffix.Length) : field, oldValue, newValue, field.EndsWith(RestrictedValueSuffix, StringComparison.Ordinal), canViewRestricted); + } + } + + public static Dictionary FlattenValues(Dictionary values) + { + var flat = new Dictionary(StringComparer.Ordinal); + if (values == null) return flat; + var token = Newtonsoft.Json.Linq.JToken.FromObject(values); + void Walk(Newtonsoft.Json.Linq.JToken node, string path) + { + switch (node) + { + case Newtonsoft.Json.Linq.JObject obj: + foreach (var property in obj.Properties()) Walk(property.Value, path.Length == 0 ? property.Name : path + " / " + property.Name); + break; + case Newtonsoft.Json.Linq.JArray array: + for (var i = 0; i < array.Count; i++) Walk(array[i], path + " / Item " + (i + 1)); + break; + default: + flat[path] = node.Type == Newtonsoft.Json.Linq.JTokenType.Null ? null : node.ToString(); + break; + } + } + Walk(token, string.Empty); + return flat; + } + private static void Compare(List diffs, string section, string field, string oldValue, string newValue, bool restricted, bool canViewRestricted) { if (string.Equals(oldValue ?? string.Empty, newValue ?? string.Empty, StringComparison.Ordinal)) diff --git a/Core/Resgrid.Services/Records/RecordTemplateCatalog.cs b/Core/Resgrid.Services/Records/RecordTemplateCatalog.cs new file mode 100644 index 00000000..8efa3189 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordTemplateCatalog.cs @@ -0,0 +1,461 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model; + +namespace Resgrid.Services.Records +{ + /// A product pack as shipped in code: metadata plus the definitions it carries. + public sealed class RecordTemplatePack + { + public string Key { get; set; } + public int Version { get; set; } = 1; + public string Name { get; set; } + public string Category { get; set; } + public string Description { get; set; } + public bool IsPreview { get; set; } + public RmsArtifactStatus ArtifactStatus { get; set; } = RmsArtifactStatus.Compatible; + public List SupportedProfiles { get; set; } = new List { "generic", "us", "ca" }; + public List SupportedLocales { get; set; } = new List { "en-US", "en-CA", "fr-CA" }; + public DateTime? ReviewedOn { get; set; } = new DateTime(2026, 9, 5, 0, 0, 0, DateTimeKind.Utc); + public string ReleaseNotes { get; set; } + public List Sources { get; set; } = new List(); + public List Definitions { get; set; } = new List(); + } + + /// + /// Product-managed template packs (RMS plan section 4.1 table; RMS-1B launch templates and RMS-1C operational + /// packs) and the locked jurisdiction profiles. Content lives here, in code, so a pack update ships as a product + /// release with a diff; it never mutates a department clone. Nothing here is labeled an exact named form. + /// + public static class RecordTemplateCatalog + { + public const int CatalogVersion = 1; + + // ---- profiles --------------------------------------------------------------------------------- + + public static readonly IReadOnlyList Profiles = new List + { + Profile("generic", "Generic (no jurisdiction)", "XX", null, "en-US", "en-US,en-CA,fr-CA", "metric", null, RmsArtifactStatus.DepartmentLocal, + new Dictionary>()), + Profile("us", "United States", "US", null, "en-US", "en-US", "customary", "USD", RmsArtifactStatus.Compatible, + new Dictionary> + { + ["en-US"] = new Dictionary { ["subdivision"] = "State", ["postal_code"] = "ZIP code", ["municipality"] = "City", ["province"] = "State", ["mileage"] = "Mileage (mi)", ["fuel"] = "Fuel (gal)", ["temperature"] = "Temperature (°F)", ["area"] = "Area (acres)" } + }, + Source("NIMS/ICS forms", "FEMA National Incident Management System ICS forms", "FEMA", "2023", "https://www.fema.gov/emergency-managers/nims/components"), + Source("NIFC mobilization guide", "NIFC mobilization and resource-order guidance", "NIFC", "2026", "https://www.nifc.gov/nicc/logistics/reference-documents"), + Source("NWCG PMS 310-1", "NWCG Standards for Wildland Fire Position Qualifications", "NWCG", "2025", "https://www.nwcg.gov/publications/pms310-1")), + Profile("ca", "Canada", "CA", null, "en-CA", "en-CA,fr-CA", "metric", "CAD", RmsArtifactStatus.Compatible, + new Dictionary> + { + ["en-CA"] = new Dictionary { ["subdivision"] = "Province/territory", ["postal_code"] = "Postal code", ["municipality"] = "Municipality", ["province"] = "Province/territory", ["mileage"] = "Distance (km)", ["fuel"] = "Fuel (L)", ["temperature"] = "Temperature (°C)", ["area"] = "Area (ha)" }, + ["fr-CA"] = new Dictionary { ["subdivision"] = "Province/territoire", ["postal_code"] = "Code postal", ["municipality"] = "Municipalité", ["province"] = "Province/territoire", ["mileage"] = "Distance (km)", ["fuel"] = "Carburant (L)", ["temperature"] = "Température (°C)", ["area"] = "Superficie (ha)" } + }, + Source("ICS Canada forms", "ICS Canada incident forms (204, 209, 214, 221)", "ICS Canada", "2024", "https://www.icscanada.ca/"), + Source("CIFFC MARS", "Mutual Aid and Resource Sharing agreement and guidelines", "CIFFC", "2025", "https://dev.ciffc.ca/download/mutual-aid-and-resource-sharing/")), + Profile("us-ca", "U.S.–Canada cross-border", "US-CA", null, "en-US", "en-US,en-CA,fr-CA", "metric", null, RmsArtifactStatus.Compatible, + new Dictionary>(), + Source("International Mobilization Guide", "NIFC International Mobilization Guide", "NIFC", "2026", "https://www.nifc.gov/sites/default/files/NICC/3-Logistics/Reference%20Documents/2026_International_Mobilization_Guide_FINAL.pdf")) + }; + + // ---- packs ------------------------------------------------------------------------------------ + + public static readonly IReadOnlyList Packs = new List + { + LaunchPack(), + CertPack(), + SarPack(), + DisasterPack(), + EocPack(), + HazmatPack(), + IndustrialPack(), + ExercisePack(), + MutualAidPack() + }; + + public static IEnumerable AllTemplates => Packs.SelectMany(p => p.Definitions); + + public static RecordTemplateDefinition Find(string key) => AllTemplates.FirstOrDefault(t => string.Equals(t.Key, key, StringComparison.OrdinalIgnoreCase)); + + public static RecordTemplatePack PackOf(string templateKey) => Packs.FirstOrDefault(p => p.Definitions.Any(d => string.Equals(d.Key, templateKey, StringComparison.OrdinalIgnoreCase))); + + public static RmsJurisdictionProfileVersion FindProfile(string key) => Profiles.FirstOrDefault(p => string.Equals(p.ProfileKey, key, StringComparison.OrdinalIgnoreCase)); + + // ---- builders --------------------------------------------------------------------------------- + + private static RmsJurisdictionProfileVersion Profile(string key, string name, string country, string subdivision, string locale, string locales, string measurement, string currency, RmsArtifactStatus status, Dictionary> terminology, params RmsSourceProvenance[] sources) + { + return new RmsJurisdictionProfileVersion + { + RmsJurisdictionProfileVersionId = "profile:" + key + ":1", DepartmentId = RmsTemplatePackVersion.ProductDepartmentId, ProtectionId = "profile:" + key, + ProfileKey = key, Version = 1, Name = name, Country = country, Subdivision = subdivision, DefaultLocale = locale, SupportedLocales = locales, + MeasurementSystem = measurement, CurrencyCode = currency, ArtifactStatus = (int)status, ReviewedOn = new DateTime(2026, 9, 5, 0, 0, 0, DateTimeKind.Utc), + TerminologyJson = Newtonsoft.Json.JsonConvert.SerializeObject(terminology), StandardsJson = Newtonsoft.Json.JsonConvert.SerializeObject(sources.ToList()), + ClassificationDefault = (int)RmsFieldClassification.Standard, CreatedOn = new DateTime(2026, 9, 5, 0, 0, 0, DateTimeKind.Utc), ModifiedOn = new DateTime(2026, 9, 5, 0, 0, 0, DateTimeKind.Utc), RowVersion = 1 + }; + } + + private static RmsSourceProvenance Source(string identifier, string title, string publisher, string version, string url, string kind = "operational-aid") + => new RmsSourceProvenance { Identifier = identifier, Title = title, Publisher = publisher, Version = version, Url = url, ReviewedOn = new DateTime(2026, 9, 5, 0, 0, 0, DateTimeKind.Utc), Kind = kind }; + + private static RecordSectionSchema Section(string key, string label, params RecordFieldSchema[] fields) => new RecordSectionSchema { Key = key, Label = label, Fields = fields.ToList() }; + private static RecordSectionSchema Rows(string key, string label, int? min, int? max, params RecordFieldSchema[] fields) => new RecordSectionSchema { Key = key, Label = label, Repeating = true, MinRows = min, MaxRows = max, Fields = fields.ToList() }; + + private static RecordFieldSchema F(string key, string label, RmsFieldType type, bool required = false, bool searchable = false, bool workflow = false, RmsFieldClassification classification = RmsFieldClassification.Standard) + { + var filterable = type != RmsFieldType.LongText && type != RmsFieldType.Attachment && type != RmsFieldType.Signature; + return new RecordFieldSchema + { + Key = key, Label = label, Type = type, RequiredToFinalize = required, Classification = classification, + Searchable = searchable && classification == RmsFieldClassification.Standard && type != RmsFieldType.Attachment && type != RmsFieldType.Signature, + Filterable = filterable, Sortable = filterable, Groupable = RecordDefinitionsService.IsGroupable(type) && classification == RmsFieldClassification.Standard, + Aggregatable = RecordDefinitionsService.IsNumeric(type) && classification == RmsFieldClassification.Standard, + WorkflowExposed = workflow && classification == RmsFieldClassification.Standard, Exportable = true + }; + } + + private static RecordFieldSchema Select(string key, string label, bool required, bool workflow, params string[] options) + { + var field = F(key, label, RmsFieldType.SingleSelect, required, true, workflow); + field.Options = options.Select(o => new RecordOptionSchema { Key = o.ToLowerInvariant().Replace(' ', '-'), Label = o }).ToList(); + return field; + } + + private static RecordFieldSchema Multi(string key, string label, params string[] options) + { + var field = F(key, label, RmsFieldType.MultiSelect, false, true, false); + field.Options = options.Select(o => new RecordOptionSchema { Key = o.ToLowerInvariant().Replace(' ', '-'), Label = o }).ToList(); + return field; + } + + private static RecordFieldSchema Quantity(string key, string label, string family, string unit, bool required = false) + { + var field = F(key, label, RmsFieldType.Quantity, required, false, true); + field.UnitFamily = family; field.DefaultUnit = unit; + return field; + } + + private static RecordFieldSchema Money(string key, string label, string currency = "USD") + { + var field = F(key, label, RmsFieldType.Currency, false, false, true); + field.DefaultCurrency = currency; + return field; + } + + private static RecordFieldSchema Ext(string key, string label, string scheme, bool required = false) + { + var field = F(key, label, RmsFieldType.ExternalReference, required, true, true); + field.ReferenceType = scheme; + return field; + } + + private static RecordFieldSchema Decimal(string key, string label, string unitLabel, bool workflow = true) + { + var field = F(key, label, RmsFieldType.Decimal, false, false, workflow); + field.FixedUnitLabel = unitLabel; field.Min = 0; + return field; + } + + private static RecordFieldSchema Count(string key, string label, bool workflow = true) + { + var field = F(key, label, RmsFieldType.Integer, false, false, workflow); + field.Min = 0; + return field; + } + + /// A pack protected-data policy: the fields of one category carry a classification floor in every rendering and clone (RMS-1C). + private static RecordTemplateDefinition Policy(RecordTemplateDefinition template, string category, RmsFieldClassification floor, string rationale, params string[] fieldKeys) + { + template.ProtectedDataPolicies.Add(new RecordTemplateFieldPolicy { Category = category, Floor = floor, Rationale = rationale, FieldKeys = fieldKeys.ToList() }); + return template; + } + + private static RecordRuleSchema ShowWhen(string fieldKey, string value) => new RecordRuleSchema { Effect = RmsRuleEffect.Show, Condition = new RecordConditionSchema { Operator = RmsRuleOperator.Equals, FieldKey = fieldKey, Value = value } }; + private static RecordRuleSchema ShowWhenIn(string fieldKey, params string[] values) => new RecordRuleSchema { Effect = RmsRuleEffect.Show, Condition = new RecordConditionSchema { Operator = RmsRuleOperator.InSet, FieldKey = fieldKey, Values = values.ToList() } }; + private static RecordRuleSchema RequireWhen(string fieldKey, string value) => new RecordRuleSchema { Effect = RmsRuleEffect.Require, Condition = new RecordConditionSchema { Operator = RmsRuleOperator.Equals, FieldKey = fieldKey, Value = value } }; + + private static RecordTemplateDefinition Template(string key, string packKey, string name, string category, string description, RmsLifecyclePreset preset, string prefix, string subjects, params RecordSectionSchema[] sections) + => new RecordTemplateDefinition { Key = key, PackKey = packKey, Name = name, Category = category, Description = description, LifecyclePreset = preset, NumberPrefix = prefix, PermittedSubjectTypes = subjects, Schema = new RecordDefinitionSchema { Sections = sections.ToList() } }; + + private static RecordTemplateDefinition WithOverlays(RecordTemplateDefinition template, Dictionary frCa = null, Dictionary usUnits = null, Dictionary caUnits = null, params string[] lockedClassification) + { + template.Overlays["generic"] = new RecordTemplateOverlay { ProfileKey = "generic", ArtifactStatus = RmsArtifactStatus.DepartmentLocal }; + // Sources come from the owning pack at render time (the pack list is still being built here). + template.Overlays["us"] = new RecordTemplateOverlay { ProfileKey = "us", CurrencyCode = "USD", DefaultUnits = usUnits ?? new Dictionary(StringComparer.OrdinalIgnoreCase), ArtifactStatus = RmsArtifactStatus.Compatible }; + template.Overlays["ca"] = new RecordTemplateOverlay { ProfileKey = "ca", CurrencyCode = "CAD", DefaultUnits = caUnits ?? new Dictionary(StringComparer.OrdinalIgnoreCase), ArtifactStatus = RmsArtifactStatus.Compatible, Labels = frCa == null ? new Dictionary>(StringComparer.OrdinalIgnoreCase) : new Dictionary>(StringComparer.OrdinalIgnoreCase) { ["fr-CA"] = frCa } }; + template.Overlays["us-ca"] = new RecordTemplateOverlay { ProfileKey = "us-ca", DefaultUnits = caUnits ?? new Dictionary(StringComparer.OrdinalIgnoreCase), ArtifactStatus = RmsArtifactStatus.Compatible, Labels = frCa == null ? new Dictionary>(StringComparer.OrdinalIgnoreCase) : new Dictionary>(StringComparer.OrdinalIgnoreCase) { ["fr-CA"] = frCa } }; + template.LockedClassificationFieldKeys = lockedClassification.ToList(); + return template; + } + + private static Dictionary Fr(params string[] pairs) + { + var d = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i + 1 < pairs.Length; i += 2) d[pairs[i]] = pairs[i + 1]; + return d; + } + + // ---- RMS-1B launch templates ---------------------------------------------------------------- + + private static RecordTemplatePack LaunchPack() + { + var pack = new RecordTemplatePack + { + Key = "template.launch", Name = "Operational report templates", Category = "Operations", Description = "Cross-vertical starting points: security patrol, security incident, delivery run, bus/route end-of-day, shift summary and job completion.", + SupportedProfiles = new List { "generic", "us", "ca" }, ArtifactStatus = RmsArtifactStatus.DepartmentLocal, ReleaseNotes = "Initial RMS-1B release." + }; + + var patrol = Template("template.security-patrol", pack.Key, "Security Patrol Log", "Security", "Site patrol with checkpoint rows, observations, exceptions and supervisor handoff.", RmsLifecyclePreset.QuickEntry, "PAT", "contact,unit", + Section("assignment", "Assignment", + F("client_site", "Client / site", RmsFieldType.Contact, true, true, true), F("shift_start", "Shift start", RmsFieldType.DateTime, true), F("shift_end", "Shift end", RmsFieldType.DateTime), + F("officer", "Officer", RmsFieldType.Person, true, true, true), F("unit", "Unit / vehicle", RmsFieldType.Unit, false, true, true), F("post", "Post / route", RmsFieldType.ShortText, false, true, true)), + Rows("checkpoints", "Patrol checkpoints", null, 200, + F("checkpoint", "Checkpoint", RmsFieldType.ShortText, true, true), F("time", "Time", RmsFieldType.DateTime, true), Select("status", "Status", true, true, "Clear", "Exception"), F("notes", "Notes", RmsFieldType.LongText)), + Section("observations", "Observations", + F("observations", "Observations", RmsFieldType.LongText), F("exception_reported", "Exception reported", RmsFieldType.Boolean, false, false, true)), + Rows("exceptions", "Exceptions", null, 50, + Select("type", "Type", true, true, "Trespass", "Damage", "Alarm", "Safety hazard", "Access control", "Other"), Select("severity", "Severity", true, true, "Low", "Medium", "High"), + F("description", "Description", RmsFieldType.LongText, true), F("action_taken", "Action taken", RmsFieldType.LongText), F("photo", "Photo", RmsFieldType.Attachment)), + Section("handoff", "Supervisor handoff", + F("supervisor", "Supervisor", RmsFieldType.Person, false, true), F("handoff_notes", "Handoff notes", RmsFieldType.LongText), F("acknowledgement", "Officer acknowledgement", RmsFieldType.Signature, true))); + patrol.Schema.FindSection("exceptions").Rules.Add(ShowWhen("exception_reported", "true")); + pack.Definitions.Add(WithOverlays(patrol, Fr("client_site", "Client / site", "shift_start", "Début du quart", "shift_end", "Fin du quart", "officer", "Agent", "observations", "Observations"))); + + var incident = Template("template.security-incident", pack.Key, "Security Incident Report", "Security", "Incident class and severity, time and place, involved persons, narrative, actions, evidence, notifications and review.", RmsLifecyclePreset.ReviewRequired, "SIR", "contact,call", + Section("classification", "Classification", + Select("incident_class", "Incident class", true, true, "Theft", "Assault", "Trespass", "Vandalism", "Medical", "Fire alarm", "Suspicious activity", "Policy violation", "Other"), + Select("severity", "Severity", true, true, "Low", "Medium", "High", "Critical"), F("occurred_at", "Occurred at", RmsFieldType.DateTime, true), F("location", "Location", RmsFieldType.Address, true, true), F("site", "Site", RmsFieldType.Contact, false, true, true)), + Rows("involved", "Involved persons", null, 50, + F("name", "Name", RmsFieldType.ShortText, true, false, false, RmsFieldClassification.Restricted), Select("role", "Role", true, false, "Complainant", "Suspect", "Witness", "Employee", "Visitor"), F("contact", "Contact details", RmsFieldType.ShortText, false, false, false, RmsFieldClassification.Restricted)), + Section("narrative", "Narrative", F("narrative", "Narrative", RmsFieldType.LongText, true)), + Section("actions", "Actions and escalation", + F("actions_taken", "Actions taken", RmsFieldType.LongText, true), F("escalated", "Escalated", RmsFieldType.Boolean, false, false, true), F("escalated_to", "Escalated to", RmsFieldType.ShortText)), + Rows("evidence", "Evidence references", null, 50, Ext("reference", "Reference", "evidence"), F("description", "Description", RmsFieldType.ShortText), F("file", "File", RmsFieldType.Attachment)), + Section("notifications", "Notifications", + F("police_notified", "Police notified", RmsFieldType.Boolean, false, false, true), F("police_reference", "Police reference", RmsFieldType.ShortText), F("client_notified", "Client notified", RmsFieldType.Boolean, false, false, true)), + Section("review", "Review", F("reviewer", "Reviewer", RmsFieldType.Person), F("review_notes", "Review notes", RmsFieldType.LongText))); + incident.Schema.FindField("escalated_to").Rules.Add(ShowWhen("escalated", "true")); + incident.Schema.FindField("escalated_to").Rules.Add(RequireWhen("escalated", "true")); + incident.Schema.FindField("police_reference").Rules.Add(ShowWhen("police_notified", "true")); + Policy(incident, "subject-clue-recovery", RmsFieldClassification.Restricted, "Involved persons are identifiable subjects; the report may be disclosed without them.", "name", "contact"); + pack.Definitions.Add(WithOverlays(incident, Fr("incident_class", "Catégorie d'incident", "severity", "Gravité", "occurred_at", "Survenu le", "location", "Lieu", "narrative", "Narratif"), null, null, "name", "contact")); + + var delivery = Template("template.delivery-run", pack.Key, "Delivery Run Report", "Delivery", "Route/run, driver, vehicle, service window, stops, completed and failed deliveries, mileage, exceptions and acknowledgement.", RmsLifecyclePreset.QuickEntry, "DLV", "unit,contact", + Section("route", "Route", + Ext("route_id", "Route / run", "route", true), F("driver", "Driver", RmsFieldType.Person, true, true, true), F("vehicle", "Vehicle", RmsFieldType.Unit, true, true, true), + F("window_start", "Service window start", RmsFieldType.DateTime, true), F("window_end", "Service window end", RmsFieldType.DateTime)), + Rows("stops", "Stops", null, 300, + Count("stop_number", "Stop", false), F("customer", "Customer", RmsFieldType.Contact, false, true), F("arrived", "Arrived", RmsFieldType.DateTime), F("delivered", "Delivered", RmsFieldType.Boolean, true, false, true), + Select("failure_reason", "Failure reason", false, true, "Not home", "Refused", "Address not found", "Damaged", "Other"), Ext("proof_of_delivery", "Proof of delivery", "pod"), F("notes", "Notes", RmsFieldType.ShortText)), + Section("totals", "Totals", + Count("stops_planned", "Stops planned"), Count("stops_completed", "Stops completed"), Count("stops_failed", "Stops failed"), Decimal("mileage", "Mileage", "mi")), + Section("exceptions", "Exceptions and damage", + F("damage_reported", "Damage reported", RmsFieldType.Boolean, false, false, true), F("damage_description", "Damage description", RmsFieldType.LongText), F("photos", "Photos", RmsFieldType.Attachment)), + Section("acknowledgement", "Acknowledgement", F("dispatcher", "Dispatcher", RmsFieldType.Person), F("acknowledgement", "Driver acknowledgement", RmsFieldType.Signature, true))); + delivery.Schema.FindField("failure_reason").Rules.Add(ShowWhen("delivered", "false")); // per-row: the stop's own delivered flag + delivery.Schema.FindField("damage_description").Rules.Add(ShowWhen("damage_reported", "true")); + delivery.Schema.FindField("damage_description").Rules.Add(RequireWhen("damage_reported", "true")); + pack.Definitions.Add(WithOverlays(delivery, Fr("driver", "Chauffeur", "vehicle", "Véhicule", "mileage", "Distance (km)", "stops_completed", "Arrêts complétés"))); + + var bus = Template("template.bus-route-eod", pack.Key, "Bus/Route End-of-Day Summary", "Transit", "Route, operator, vehicle, service window, mileage and ridership, delays, incidents, defects, lost property and handoff.", RmsLifecyclePreset.QuickEntry, "EOD", "unit", + Section("route", "Route", + Ext("route_id", "Route", "route", true), F("operator", "Operator", RmsFieldType.Person, true, true, true), F("vehicle", "Vehicle", RmsFieldType.Unit, true, true, true), + F("service_start", "Service start", RmsFieldType.DateTime, true), F("service_end", "Service end", RmsFieldType.DateTime, true)), + Section("counts", "Mileage and ridership", Decimal("mileage", "Mileage", "mi"), Count("ridership", "Ridership"), Count("trips_completed", "Trips completed"), Count("trips_missed", "Trips missed")), + Rows("delays", "Delays", null, 100, F("time", "Time", RmsFieldType.DateTime, true), F("duration", "Duration", RmsFieldType.Duration, true), Select("cause", "Cause", true, true, "Traffic", "Mechanical", "Passenger", "Weather", "Detour", "Other"), F("notes", "Notes", RmsFieldType.ShortText)), + Rows("incidents", "Incidents", null, 50, F("incident_time", "Time", RmsFieldType.DateTime, true), Select("type", "Type", true, true, "Passenger", "Collision", "Medical", "Fare", "Other"), F("incident_description", "Description", RmsFieldType.LongText, true), F("report_reference", "Report reference", RmsFieldType.ShortText)), + Rows("defects", "Vehicle defects", null, 50, F("component", "Component", RmsFieldType.ShortText, true, true), Select("severity", "Severity", true, true, "Minor", "Major", "Out of service"), F("defect_description", "Description", RmsFieldType.LongText)), + Rows("lost_property", "Lost property", null, 50, F("item", "Item", RmsFieldType.ShortText, true), F("found_at", "Found at", RmsFieldType.ShortText), F("turned_in_to", "Turned in to", RmsFieldType.ShortText)), + Section("handoff", "Relief / maintenance handoff", F("relief_operator", "Relief operator", RmsFieldType.Person), F("maintenance_notes", "Maintenance notes", RmsFieldType.LongText), F("acknowledgement", "Operator acknowledgement", RmsFieldType.Signature, true))); + pack.Definitions.Add(WithOverlays(bus, Fr("operator", "Opérateur", "vehicle", "Véhicule", "mileage", "Distance (km)", "ridership", "Achalandage"))); + + var shift = Template("template.shift-summary", pack.Key, "General Shift/Activity Summary", "Operations", "Team/site, shift window, activities, outcomes and counts, safety observations, unresolved items and handoff.", RmsLifecyclePreset.QuickEntry, "SFT", "contact", + Section("shift", "Shift", F("team", "Team / group", RmsFieldType.Group, false, true, true), F("site", "Site", RmsFieldType.Contact, false, true, true), F("shift_start", "Shift start", RmsFieldType.DateTime, true), F("shift_end", "Shift end", RmsFieldType.DateTime), F("lead", "Shift lead", RmsFieldType.Person, true, true, true)), + Rows("activities", "Activities", null, 200, F("time", "Time", RmsFieldType.DateTime), F("activity", "Activity", RmsFieldType.ShortText, true, true), F("outcome", "Outcome", RmsFieldType.ShortText)), + Section("counts", "Counts", Count("calls_handled", "Calls handled"), Count("tasks_completed", "Tasks completed"), Count("tasks_open", "Tasks open")), + Section("safety", "Safety observations", F("safety_observations", "Safety observations", RmsFieldType.LongText), F("injury_reported", "Injury reported", RmsFieldType.Boolean, false, false, true)), + Rows("unresolved", "Unresolved items", null, 50, F("item", "Item", RmsFieldType.ShortText, true), F("owner", "Owner", RmsFieldType.Person), F("due", "Due", RmsFieldType.Date)), + Section("handoff", "Handoff", F("handoff_to", "Handoff to", RmsFieldType.Person), F("handoff_notes", "Handoff notes", RmsFieldType.LongText), F("acknowledgement", "Acknowledgement", RmsFieldType.Signature))); + pack.Definitions.Add(WithOverlays(shift, Fr("team", "Équipe", "site", "Site", "shift_start", "Début du quart", "shift_end", "Fin du quart"))); + + var job = Template("template.job-completion", pack.Key, "Job/Service Completion", "Field service", "Customer/site, job or work-order link, work performed, labor and duration, materials, exceptions, photos and customer acknowledgement.", RmsLifecyclePreset.ApprovalAcknowledgement, "JOB", "contact", + Section("customer", "Customer / site", F("customer", "Customer", RmsFieldType.Contact, true, true, true), F("site_address", "Site address", RmsFieldType.Address, false, true), Ext("work_order", "Work order / job", "workorder", true)), + Section("work", "Work performed", F("work_performed", "Work performed", RmsFieldType.LongText, true), F("completed_on", "Completed on", RmsFieldType.DateTime, true), Select("outcome", "Outcome", true, true, "Complete", "Partial", "Return visit required")), + Rows("labor", "Labor", 1, 50, F("technician", "Technician", RmsFieldType.Person, true, true), F("hours", "Time on site", RmsFieldType.Duration, true)), + Rows("materials", "Materials", null, 100, F("item", "Item", RmsFieldType.ShortText, true, true), Count("quantity", "Quantity", false), F("reference", "Reference", RmsFieldType.ShortText)), + Section("exceptions", "Exceptions", F("exceptions", "Exceptions", RmsFieldType.LongText), F("photos", "Photos", RmsFieldType.Attachment)), + Section("acknowledgement", "Customer acknowledgement", F("customer_name", "Customer representative", RmsFieldType.ShortText, true), F("acknowledgement", "Acknowledgement", RmsFieldType.Signature, true))); + pack.Definitions.Add(WithOverlays(job, Fr("customer", "Client", "work_performed", "Travaux effectués", "completed_on", "Terminé le"))); + return pack; + } + + // ---- RMS-1C operational packs ----------------------------------------------------------------- + + private static RecordTemplatePack CertPack() + { + var pack = new RecordTemplatePack { Key = "pack.cert", Name = "CERT Operations Pack", Category = "Emergency management", IsPreview = true, Description = "Damage assessment, assignment tracking and activity log for Community Emergency Response Teams. Personnel check-in and equipment summaries compose from their owning modules.", + Sources = { Source("CERT Basic Training", "FEMA CERT Basic Training participant manual", "FEMA", "2019", "https://www.fema.gov/emergency-managers/individuals-communities/preparedness-activities-webinars/community-emergency-response-team") } }; + var damage = Template("pack.cert.damage-assessment", pack.Key, "CERT Damage Assessment", "CERT", "Windshield/rapid damage assessment by team and area.", RmsLifecyclePreset.ReviewRequired, "CDA", "call,group", + Section("assessment", "Assessment", F("team", "Team", RmsFieldType.Group, true, true, true), F("area", "Area / sector", RmsFieldType.ShortText, true, true, true), F("assessed_at", "Assessed at", RmsFieldType.DateTime, true), F("subdivision", "State / province", RmsFieldType.CountrySubdivision)), + Rows("structures", "Structures", null, 300, F("address", "Address", RmsFieldType.Address, true), Select("damage", "Damage level", true, true, "None", "Affected", "Minor", "Major", "Destroyed"), Select("occupancy", "Occupancy", false, true, "Residential", "Commercial", "Public", "Other"), F("hazards", "Hazards", RmsFieldType.ShortText), F("photo", "Photo", RmsFieldType.Attachment)), + Section("summary", "Summary", Count("structures_affected", "Structures affected"), Count("structures_destroyed", "Structures destroyed"), F("utilities_down", "Utilities down", RmsFieldType.Boolean, false, false, true), F("notes", "Notes", RmsFieldType.LongText))); + Policy(damage, "facility-security", RmsFieldClassification.Restricted, "The address of a damaged structure identifies its residents; occupancy type stays a groupable code.", "address"); + pack.Definitions.Add(WithOverlays(damage, Fr("team", "Équipe", "area", "Secteur", "assessed_at", "Évalué le", "subdivision", "Province/territoire"))); + var assignment = Template("pack.cert.assignment-tracking", pack.Key, "CERT Assignment Tracking", "CERT", "Team assignments with briefing, status and completion.", RmsLifecyclePreset.QuickEntry, "CAT", "call,group", + Section("briefing", "Team briefing", F("team", "Team", RmsFieldType.Group, true, true, true), F("leader", "Team leader", RmsFieldType.Person, true, true), F("briefed_at", "Briefed at", RmsFieldType.DateTime, true), F("objectives", "Objectives", RmsFieldType.LongText, true), F("safety_message", "Safety message", RmsFieldType.LongText)), + Rows("assignments", "Assignments", 1, 100, F("assignment", "Assignment", RmsFieldType.ShortText, true, true), F("assigned_to", "Assigned to", RmsFieldType.Person), Select("status", "Status", true, true, "Assigned", "In progress", "Complete", "Cancelled"), F("completed_at", "Completed at", RmsFieldType.DateTime)), + Section("demobilization", "Demobilization", F("released_at", "Team released at", RmsFieldType.DateTime), F("notes", "Notes", RmsFieldType.LongText))); + pack.Definitions.Add(WithOverlays(assignment)); + var activity = Template("pack.cert.activity-log", pack.Key, "CERT Activity / Communications Log", "CERT", "Chronological activity and communications log (ICS 214 compatible).", RmsLifecyclePreset.QuickEntry, "CAL", "call,group", + Section("header", "Log", F("team", "Team", RmsFieldType.Group, true, true, true), F("operational_period_start", "Operational period start", RmsFieldType.DateTime, true), F("operational_period_end", "Operational period end", RmsFieldType.DateTime)), + Rows("entries", "Entries", 1, 500, F("time", "Time", RmsFieldType.DateTime, true), F("entry", "Notable activity", RmsFieldType.LongText, true), F("from_to", "From / to", RmsFieldType.ShortText)), + Section("prepared", "Prepared by", F("prepared_by", "Prepared by", RmsFieldType.Person, true), F("signature", "Signature", RmsFieldType.Signature))); + pack.Definitions.Add(WithOverlays(activity)); + return pack; + } + + private static RecordTemplatePack SarPack() + { + var pack = new RecordTemplatePack { Key = "pack.sar", Name = "SAR Mission Pack", Category = "Search and rescue", Description = "Mission summary, segment debrief with coverage/POD, and clue reports. Subject and clue details are restricted.", + Sources = { Source("NASAR SAR forms", "NASAR search and rescue mission documentation", "NASAR", "2024", "https://nasar.org/") } }; + var mission = Template("pack.sar.mission-summary", pack.Key, "SAR Mission Summary", "SAR", "Mission identity, subject profile (restricted), resources and outcome.", RmsLifecyclePreset.ReviewRequired, "SAR", "call,group", + Section("mission", "Mission", Ext("mission_number", "Mission number", "sar-mission", true), F("incident_commander", "Incident commander", RmsFieldType.Person, true, true), F("started_at", "Started", RmsFieldType.DateTime, true), F("ended_at", "Ended", RmsFieldType.DateTime), F("base_location", "Base location", RmsFieldType.Address), F("subdivision", "State / province", RmsFieldType.CountrySubdivision)), + Section("subject", "Subject profile", F("subject_name", "Subject name", RmsFieldType.ShortText, false, false, false, RmsFieldClassification.Restricted), F("subject_age", "Age", RmsFieldType.Integer, false, false, false, RmsFieldClassification.Restricted), F("subject_description", "Description", RmsFieldType.LongText, false, false, false, RmsFieldClassification.Restricted), F("medical_concerns", "Medical concerns", RmsFieldType.LongText, false, false, false, RmsFieldClassification.Restricted), F("point_last_seen", "Point last seen", RmsFieldType.Address, false, false, false, RmsFieldClassification.Restricted)), + Section("resources", "Resources", Count("searchers", "Searchers"), Count("teams", "Teams"), F("k9", "K9 support", RmsFieldType.Boolean, false, false, true), F("uas", "UAS support", RmsFieldType.Boolean, false, false, true), Quantity("area_searched", "Area searched", "area", "ha")), + Section("outcome", "Outcome", Select("outcome", "Outcome", true, true, "Found alive", "Found deceased", "Not found", "Suspended", "Stood down"), F("found_at", "Found at", RmsFieldType.DateTime), F("outcome_notes", "Notes", RmsFieldType.LongText))); + Policy(mission, "subject-clue-recovery", RmsFieldClassification.Restricted, "The subject's identity and last-known position are disclosed only under the restricted grant.", "subject_name", "subject_age", "subject_description", "point_last_seen"); + Policy(mission, "treatment-casualty", RmsFieldClassification.Protected, "Medical concerns are health information; sealed under ADP where enrolled.", "medical_concerns"); + pack.Definitions.Add(WithOverlays(mission, Fr("mission_number", "Numéro de mission", "incident_commander", "Commandant d'intervention", "outcome", "Résultat", "area_searched", "Superficie fouillée"), new Dictionary(StringComparer.OrdinalIgnoreCase) { ["area_searched"] = "ac" }, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["area_searched"] = "ha" }, "subject_name", "subject_age", "subject_description", "medical_concerns", "point_last_seen")); + var debrief = Template("pack.sar.segment-debrief", pack.Key, "SAR Segment Debrief", "SAR", "Team briefing/debrief with coverage and probability of detection.", RmsLifecyclePreset.QuickEntry, "SEG", "call,group", + Section("segment", "Segment", Ext("mission_number", "Mission number", "sar-mission", true), F("segment", "Segment", RmsFieldType.ShortText, true, true, true), F("team", "Team", RmsFieldType.Group, true, true), F("team_leader", "Team leader", RmsFieldType.Person, true)), + Section("times", "Times", F("briefed_at", "Briefed", RmsFieldType.DateTime), F("departed_at", "Departed", RmsFieldType.DateTime), F("returned_at", "Returned", RmsFieldType.DateTime), F("time_searching", "Time searching", RmsFieldType.Duration)), + Section("coverage", "Coverage", Select("search_type", "Search type", true, true, "Hasty", "Grid", "Sweep", "Track", "Containment"), Quantity("track_spacing", "Track spacing", "length", "m"), Count("pod_percent", "Probability of detection (%)"), F("coverage_notes", "Coverage notes", RmsFieldType.LongText)), + Section("debrief", "Debrief", F("clues_found", "Clues found", RmsFieldType.Boolean, false, false, true), F("hazards", "Hazards encountered", RmsFieldType.LongText), F("recommendations", "Recommendations", RmsFieldType.LongText))); + pack.Definitions.Add(WithOverlays(debrief, null, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["track_spacing"] = "ft" }, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["track_spacing"] = "m" })); + var clue = Template("pack.sar.clue-report", pack.Key, "SAR Clue Report", "SAR", "A found clue, its location and disposition (restricted).", RmsLifecyclePreset.QuickEntry, "CLU", "call,group", + Section("clue", "Clue", Ext("mission_number", "Mission number", "sar-mission", true), Count("clue_number", "Clue number", false), F("found_at", "Found at", RmsFieldType.DateTime, true), F("found_by", "Found by", RmsFieldType.Person, true), F("location", "Location", RmsFieldType.Address, true, false, false, RmsFieldClassification.Restricted), F("description", "Description", RmsFieldType.LongText, true, false, false, RmsFieldClassification.Restricted), F("photo", "Photo", RmsFieldType.Attachment)), + Section("disposition", "Disposition", Select("disposition", "Disposition", true, false, "Left in place", "Collected", "Turned over to law enforcement", "Discounted"), F("notes", "Notes", RmsFieldType.LongText))); + Policy(clue, "subject-clue-recovery", RmsFieldClassification.Restricted, "Clue location and description can identify a subject or a recovery site.", "location", "description", "found_by"); + pack.Definitions.Add(WithOverlays(clue, null, null, null, "location", "description")); + return pack; + } + + private static RecordTemplatePack DisasterPack() + { + var pack = new RecordTemplatePack { Key = "pack.disaster-assessment", Name = "Disaster Field Assessment and Mass Care Pack", Category = "Emergency management", Description = "Rapid needs / initial damage assessment with infrastructure impact and Community Lifeline status.", + Sources = { Source("FEMA Community Lifelines", "FEMA Community Lifelines toolkit", "FEMA", "2023", "https://www.fema.gov/emergency-managers/practitioners/lifelines") } }; + var rapid = Template("pack.disaster.rapid-needs-assessment", pack.Key, "Rapid Needs / Initial Damage Assessment", "Disaster", "Field-team rapid needs and initial damage assessment for one area.", RmsLifecyclePreset.ReviewRequired, "RNA", "call,group", + Section("area", "Area", F("team", "Team", RmsFieldType.Group, true, true, true), F("area", "Area / jurisdiction", RmsFieldType.ShortText, true, true, true), F("subdivision", "State / province", RmsFieldType.CountrySubdivision, true), F("assessed_at", "Assessed at", RmsFieldType.DateTime, true), Count("population_affected", "Population affected")), + Section("lifelines", "Community Lifelines", Select("safety_security", "Safety and security", true, true, "Green", "Yellow", "Red", "Unknown"), Select("food_water_shelter", "Food, water, shelter", true, true, "Green", "Yellow", "Red", "Unknown"), Select("health_medical", "Health and medical", true, true, "Green", "Yellow", "Red", "Unknown"), Select("energy", "Energy", true, true, "Green", "Yellow", "Red", "Unknown"), Select("communications", "Communications", true, true, "Green", "Yellow", "Red", "Unknown"), Select("transportation", "Transportation", true, true, "Green", "Yellow", "Red", "Unknown"), Select("hazardous_materials", "Hazardous materials", true, true, "Green", "Yellow", "Red", "Unknown"), Select("water_systems", "Water systems", true, true, "Green", "Yellow", "Red", "Unknown")), + Rows("routes", "Infrastructure / route impact", null, 100, F("route", "Route / facility", RmsFieldType.ShortText, true, true), Select("status", "Status", true, true, "Open", "Restricted", "Closed"), F("notes", "Notes", RmsFieldType.ShortText)), + Section("needs", "Needs", Count("shelter_needed", "Shelter needed (persons)"), Quantity("water_needed", "Water needed", "volume", "L"), Count("meals_needed", "Meals needed (per day)"), F("medical_needs", "Medical needs", RmsFieldType.LongText), F("priority_needs", "Priority needs", RmsFieldType.LongText, true)), + Section("summary", "Summary", Count("structures_affected", "Structures affected"), Count("structures_destroyed", "Structures destroyed"), Money("estimated_damage", "Estimated damage"), F("photos", "Photos", RmsFieldType.Attachment))); + Policy(rapid, "treatment-casualty", RmsFieldClassification.Protected, "Medical needs are health information.", "medical_needs"); + pack.Definitions.Add(WithOverlays(rapid, Fr("team", "Équipe", "area", "Secteur", "subdivision", "Province/territoire", "priority_needs", "Besoins prioritaires", "water_needed", "Eau requise"), new Dictionary(StringComparer.OrdinalIgnoreCase) { ["water_needed"] = "gal" }, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["water_needed"] = "L" })); + return pack; + } + + private static RecordTemplatePack EocPack() + { + var pack = new RecordTemplatePack { Key = "pack.eoc", Name = "EOC Coordination Pack", Category = "Emergency management", Description = "Duty-officer / EOC shift log and agency (ESF) status reporting.", + Sources = { Source("NIMS EOC guidance", "FEMA NIMS Emergency Operations Center How-To Quick Reference Guide", "FEMA", "2020", "https://www.fema.gov/sites/default/files/2020-07/fema_nims_eoc-how-to-guide.pdf") } }; + var duty = Template("pack.eoc.duty-shift-log", pack.Key, "EOC Duty / Shift Log", "EOC", "Duty officer or EOC position log with significant events, decisions and handoff.", RmsLifecyclePreset.QuickEntry, "EOC", "call,group", + Section("shift", "Shift", Select("activation_level", "Activation level", true, true, "Monitoring", "Partial", "Full"), F("position", "Position", RmsFieldType.ShortText, true, true, true), F("officer", "Officer", RmsFieldType.Person, true, true, true), F("shift_start", "Shift start", RmsFieldType.DateTime, true), F("shift_end", "Shift end", RmsFieldType.DateTime)), + Rows("events", "Significant events / decisions", null, 500, F("time", "Time", RmsFieldType.DateTime, true), Select("kind", "Kind", true, true, "Event", "Decision", "Request", "Message"), F("entry", "Entry", RmsFieldType.LongText, true), F("action_owner", "Action owner", RmsFieldType.Person)), + Rows("open_actions", "Open action tracker", null, 100, F("action", "Action", RmsFieldType.ShortText, true), F("owner", "Owner", RmsFieldType.Person), F("due", "Due", RmsFieldType.DateTime), Select("status", "Status", true, true, "Open", "In progress", "Closed")), + Section("handoff", "Handoff", F("handoff_to", "Handoff to", RmsFieldType.Person), F("handoff_summary", "Handoff summary", RmsFieldType.LongText), F("signature", "Signature", RmsFieldType.Signature))); + pack.Definitions.Add(WithOverlays(duty, Fr("activation_level", "Niveau d'activation", "position", "Poste", "officer", "Officier"))); + var agency = Template("pack.eoc.agency-status", pack.Key, "Agency / ESF Status Report", "EOC", "Periodic agency or emergency support function status for a situation report.", RmsLifecyclePreset.ReviewRequired, "ESF", "group", + Section("report", "Report", F("agency", "Agency / ESF", RmsFieldType.ShortText, true, true, true), F("reporting_period_start", "Period start", RmsFieldType.DateTime, true), F("reporting_period_end", "Period end", RmsFieldType.DateTime, true), F("liaison", "Liaison", RmsFieldType.Person, true), Select("status", "Overall status", true, true, "Normal", "Stressed", "Degraded", "Failed")), + Section("situation", "Situation", F("current_situation", "Current situation", RmsFieldType.LongText, true), F("actions_taken", "Actions taken", RmsFieldType.LongText), F("planned_actions", "Planned actions", RmsFieldType.LongText), F("unmet_needs", "Unmet needs", RmsFieldType.LongText)), + Rows("resource_requests", "Resource requests", null, 50, F("resource", "Resource", RmsFieldType.ShortText, true), Count("quantity", "Quantity", false), F("needed_by", "Needed by", RmsFieldType.DateTime), Ext("request_number", "Request number", "resource-request"))); + pack.Definitions.Add(WithOverlays(agency)); + return pack; + } + + private static RecordTemplatePack HazmatPack() + { + var pack = new RecordTemplatePack { Key = "pack.hazmat", Name = "HAZMAT Response Pack", Category = "Hazardous materials", Description = "Non-NERIS spill/release response: size-up, product, monitoring, decontamination, notifications and critique. Exposure details are restricted.", + Sources = { Source("EPA release reporting", "EPA emergency release notification requirements (CERCLA/EPCRA)", "EPA", "2024", "https://www.epa.gov/epcra"), Source("ICS 208 HM", "ICS 208 HM Site Safety and Control Plan", "FEMA", "2023", "https://training.fema.gov/icsresource/icsforms.aspx") } }; + var release = Template("pack.hazmat.release-response", pack.Key, "HAZMAT Release / Response", "HAZMAT", "A hazardous-materials release and the response to it, outside the NERIS incident record.", RmsLifecyclePreset.ApprovalAcknowledgement, "HZM", "call,contact", + Section("sizeup", "Initial size-up", F("call", "Related call", RmsFieldType.CallReference, false, true, true), F("location", "Location", RmsFieldType.Address, true, true), F("discovered_at", "Discovered at", RmsFieldType.DateTime, true), F("facility", "Facility / responsible party", RmsFieldType.Contact, false, true, true), Select("release_type", "Release type", true, true, "Spill", "Leak", "Vapor", "Fire", "Explosion", "Unknown")), + Section("product", "Product / container", F("product_name", "Product name", RmsFieldType.ShortText, true, true, true), Ext("un_number", "UN/NA number", "un"), F("container_type", "Container type", RmsFieldType.ShortText), Quantity("quantity_released", "Quantity released", "volume", "L"), Quantity("area_affected", "Area affected", "area", "ha")), + Rows("monitoring", "Atmospheric monitoring", null, 200, F("time", "Time", RmsFieldType.DateTime, true), F("monitoring_location", "Location", RmsFieldType.ShortText, true), F("instrument", "Instrument", RmsFieldType.ShortText), F("reading", "Reading", RmsFieldType.ShortText, true), Select("zone", "Zone", false, true, "Hot", "Warm", "Cold")), + Rows("entries", "Entry control", null, 50, F("entrant", "Entrant", RmsFieldType.Person, true), F("entry_at", "Entry", RmsFieldType.DateTime, true), F("exit_at", "Exit", RmsFieldType.DateTime), Select("ppe_level", "PPE level", true, true, "A", "B", "C", "D"), F("air_start_psi", "Air at entry", RmsFieldType.Integer)), + Section("decon", "Decontamination", Select("decon_type", "Decontamination type", false, true, "Emergency", "Technical", "Mass", "None"), Count("persons_deconned", "Persons decontaminated"), F("decon_notes", "Notes", RmsFieldType.LongText)), + Section("exposure", "Responder exposure", F("exposure_reported", "Exposure reported", RmsFieldType.Boolean, false, false, true), F("exposure_details", "Exposure details", RmsFieldType.LongText, false, false, false, RmsFieldClassification.Restricted)), + Rows("notifications", "Notification log", null, 50, F("agency", "Agency", RmsFieldType.ShortText, true), F("notified_at", "Notified at", RmsFieldType.DateTime, true), Ext("reference", "Reference", "notification"), F("contact", "Contact", RmsFieldType.ShortText)), + Section("critique", "Critique", F("critique", "Critique", RmsFieldType.LongText), F("approved_by", "Approved by", RmsFieldType.Person), F("signature", "Signature", RmsFieldType.Signature))); + release.Schema.FindField("exposure_details").Rules.Add(ShowWhen("exposure_reported", "true")); + Policy(release, "exposure-health", RmsFieldClassification.Protected, "Exposure details are health information.", "exposure_details"); + Policy(release, "exposure-health", RmsFieldClassification.Restricted, "Entrants are identifiable persons; decontamination counts stay Workflow-exposed.", "entrant"); + Policy(release, "facility-security", RmsFieldClassification.Restricted, "A notification contact identifies a person at the site; the facility party and reference numbers stay searchable.", "contact"); + pack.Definitions.Add(WithOverlays(release, Fr("product_name", "Nom du produit", "release_type", "Type de déversement", "quantity_released", "Quantité déversée"), new Dictionary(StringComparer.OrdinalIgnoreCase) { ["quantity_released"] = "gal", ["area_affected"] = "ac" }, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["quantity_released"] = "L", ["area_affected"] = "ha" }, "exposure_details")); + return pack; + } + + private static RecordTemplatePack IndustrialPack() + { + var pack = new RecordTemplatePack { Key = "pack.industrial", Name = "Industrial Operations and Process Safety Pack", Category = "Industrial", Description = "Operator/control-room shift handover and incident/near-miss reporting with process-safety and regulatory fields.", + Sources = { Source("OSHA 29 CFR 1910.119", "Process Safety Management of Highly Hazardous Chemicals", "OSHA", "2024", "https://www.osha.gov/process-safety-management") } }; + var handover = Template("pack.industrial.shift-handover", pack.Key, "Operator / Control-Room Shift Handover", "Industrial", "Unit status, abnormal conditions, permits and outstanding items at shift change.", RmsLifecyclePreset.QuickEntry, "SHO", "unit,group", + Section("shift", "Shift", F("unit_area", "Unit / area", RmsFieldType.ShortText, true, true, true), F("outgoing", "Outgoing operator", RmsFieldType.Person, true, true), F("incoming", "Incoming operator", RmsFieldType.Person, true, true), F("handover_at", "Handover at", RmsFieldType.DateTime, true)), + Rows("status", "Equipment / unit status", null, 100, F("equipment", "Equipment", RmsFieldType.ShortText, true, true), Select("status", "Status", true, true, "Normal", "Abnormal", "Down", "Maintenance"), F("notes", "Notes", RmsFieldType.ShortText)), + Rows("permits", "Active permits / isolations", null, 100, Ext("permit", "Permit", "permit"), Select("type", "Type", true, true, "Hot work", "Confined space", "Isolation", "Excavation", "Other"), F("expires", "Expires", RmsFieldType.DateTime)), + Section("readings", "Key readings", Quantity("temperature", "Temperature", "temperature", "C"), Quantity("throughput", "Throughput", "volume", "L"), F("abnormal_conditions", "Abnormal conditions", RmsFieldType.LongText)), + Section("handoff", "Handoff", F("outstanding", "Outstanding items", RmsFieldType.LongText), F("incoming_acknowledgement", "Incoming acknowledgement", RmsFieldType.Signature, true))); + pack.Definitions.Add(WithOverlays(handover, Fr("unit_area", "Unité / zone", "handover_at", "Relève à", "temperature", "Température"), new Dictionary(StringComparer.OrdinalIgnoreCase) { ["temperature"] = "F", ["throughput"] = "gal" }, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["temperature"] = "C", ["throughput"] = "L" })); + var nearMiss = Template("pack.industrial.incident-near-miss", pack.Key, "Incident / Near Miss", "Industrial", "Incident, near miss or unsafe condition with classification, causes, actions and regulatory notification.", RmsLifecyclePreset.ApprovalAcknowledgement, "INM", "unit,group,contact", + Section("event", "Event", Select("event_type", "Event type", true, true, "Injury", "Near miss", "Unsafe condition", "Process upset", "Environmental release", "Property damage"), F("occurred_at", "Occurred at", RmsFieldType.DateTime, true), F("location", "Location", RmsFieldType.Address, true), F("unit_area", "Unit / area", RmsFieldType.ShortText, true, true, true), F("reported_by", "Reported by", RmsFieldType.Person, true), F("contractor", "Contractor involved", RmsFieldType.Contact, false, true)), + Section("description", "Description", F("description", "Description", RmsFieldType.LongText, true), Select("potential_severity", "Potential severity", true, true, "Low", "Medium", "High", "Catastrophic"), F("injury", "Injury occurred", RmsFieldType.Boolean, false, false, true), F("injury_details", "Injury details", RmsFieldType.LongText, false, false, false, RmsFieldClassification.Restricted)), + Rows("causes", "Causes", null, 20, Select("category", "Category", true, true, "Procedure", "Equipment", "Human factors", "Environment", "Management system"), F("cause", "Cause", RmsFieldType.ShortText, true)), + Rows("actions", "Corrective actions", null, 50, F("action", "Action", RmsFieldType.ShortText, true), F("owner", "Owner", RmsFieldType.Person), F("due", "Due", RmsFieldType.Date), F("work_order", "Work order", RmsFieldType.ChecklistWorkOrderReference)), + Section("regulatory", "Regulatory", F("reportable", "Regulator-reportable", RmsFieldType.Boolean, false, false, true), Ext("regulator_reference", "Regulator reference", "regulator"), Money("estimated_cost", "Estimated cost")), + Section("approval", "Approval", F("investigated_by", "Investigated by", RmsFieldType.Person), F("approver_signature", "Approver signature", RmsFieldType.Signature, true))); + nearMiss.Schema.FindField("injury_details").Rules.Add(ShowWhen("injury", "true")); + nearMiss.Schema.FindField("regulator_reference").Rules.Add(ShowWhen("reportable", "true")); + foreach (var field in nearMiss.Schema.FindSection("actions").Fields.Where(f => f.Key == "work_order")) field.ReferenceType = "workorder"; + Policy(nearMiss, "exposure-health", RmsFieldClassification.Protected, "Injury details are health information.", "injury_details"); + Policy(nearMiss, "subject-clue-recovery", RmsFieldClassification.Restricted, "The reporter is an identifiable person; the contractor party stays searchable.", "reported_by"); + pack.Definitions.Add(WithOverlays(nearMiss, Fr("event_type", "Type d'événement", "occurred_at", "Survenu le", "description", "Description"), null, null, "injury_details")); + return pack; + } + + private static RecordTemplatePack ExercisePack() + { + var pack = new RecordTemplatePack { Key = "pack.exercise", Name = "Exercise, Drill and AAR/IP Pack", Category = "Preparedness", Description = "Exercise/drill record, evaluator observations, hotwash and after-action report with improvement plan linked to Work Orders, Checklists and training.", + Sources = { Source("HSEEP", "Homeland Security Exercise and Evaluation Program", "FEMA", "2020", "https://www.fema.gov/emergency-managers/national-preparedness/exercises/hseep") } }; + var aar = Template("pack.exercise.aar-improvement-plan", pack.Key, "After-Action Report / Improvement Plan", "Exercise", "Exercise record, observations, strengths and areas for improvement, and the corrective-action plan.", RmsLifecyclePreset.ApprovalAcknowledgement, "AAR", "group", + Section("exercise", "Exercise", F("exercise_name", "Exercise name", RmsFieldType.ShortText, true, true, true), Select("exercise_type", "Type", true, true, "Drill", "Tabletop", "Functional", "Full-scale", "Real-world"), F("conducted_on", "Conducted on", RmsFieldType.Date, true), F("lead_evaluator", "Lead evaluator", RmsFieldType.Person, true), Multi("capabilities", "Core capabilities", "Planning", "Operational communications", "Situational assessment", "On-scene security", "Mass care", "Public health", "Fire management", "Search and rescue")), + Rows("observations", "Evaluator observations", null, 200, F("evaluator", "Evaluator", RmsFieldType.Person), F("capability", "Capability", RmsFieldType.ShortText, true), Select("rating", "Rating", true, true, "Performed without challenges", "Performed with some challenges", "Performed with major challenges", "Unable to be performed"), F("observation", "Observation", RmsFieldType.LongText, true)), + Section("hotwash", "Hotwash", F("strengths", "Strengths", RmsFieldType.LongText), F("areas_for_improvement", "Areas for improvement", RmsFieldType.LongText, true)), + Rows("corrective_actions", "Corrective actions", null, 100, F("action", "Corrective action", RmsFieldType.ShortText, true), F("owner", "Owner", RmsFieldType.Person), F("due", "Due", RmsFieldType.Date), F("tracked_in", "Tracked in", RmsFieldType.ChecklistWorkOrderReference), Select("status", "Status", true, true, "Open", "In progress", "Complete")), + Section("approval", "Approval", F("approved_by", "Approved by", RmsFieldType.Person), F("approval_signature", "Approval signature", RmsFieldType.Signature, true))); + pack.Definitions.Add(WithOverlays(aar, Fr("exercise_name", "Nom de l'exercice", "exercise_type", "Type", "conducted_on", "Tenu le", "areas_for_improvement", "Points à améliorer"))); + return pack; + } + + private static RecordTemplatePack MutualAidPack() + { + var pack = new RecordTemplatePack { Key = "pack.mutual-aid", Name = "Mutual Aid and Deployment Pack", Category = "Mutual aid", IsPreview = true, Description = "Create Deployment from External Order: the supplied resources, mobilization, daily activity, release and return-to-home-unit closeout. Preview: no claim of NWCG, CIFFC or member-agency acceptance until a real order has been filled and reconciled.", + SupportedProfiles = new List { "generic", "us", "ca", "us-ca" }, + Sources = { Source("NIFC mobilization guide", "NIFC mobilization and resource-order guidance", "NIFC", "2026", "https://www.nifc.gov/nicc/logistics/reference-documents"), Source("CIFFC MARS", "Mutual Aid and Resource Sharing agreement and guidelines", "CIFFC", "2025", "https://dev.ciffc.ca/download/mutual-aid-and-resource-sharing/"), Source("International Mobilization Guide", "NIFC International Mobilization Guide", "NIFC", "2026", "https://www.nifc.gov/sites/default/files/NICC/3-Logistics/Reference%20Documents/2026_International_Mobilization_Guide_FINAL.pdf") } }; + var deployment = Template("pack.mutual-aid.deployment", pack.Key, "Deployment (External Order)", "Mutual aid", "One deployment filling one or more external requests: order facts, mobilization briefing, roster/manifest, daily activity, release and closeout.", RmsLifecyclePreset.ApprovalAcknowledgement, "DEP", "call,unit,group", + Section("order", "External order", + Select("profile", "Ordering profile", true, true, "Generic", "US wildland", "CA wildland", "US-CA cross-border", "EMAC compact", "Local mutual aid"), Ext("order_number", "Order number", "order", true), Ext("incident_number", "Incident number", "incident"), + F("incident_name", "Incident name", RmsFieldType.ShortText, true, true, true), F("incident_subdivision", "Incident state / province", RmsFieldType.CountrySubdivision), F("ordering_office", "Ordering office", RmsFieldType.ShortText, false, true, true), F("requesting_agency", "Requesting agency", RmsFieldType.ShortText, false, true, true), F("sending_agency", "Sending agency", RmsFieldType.ShortText, false, true), Ext("agreement", "Agreement / contract", "agreement"), Ext("cost_code", "Cost / fire / project code", "cost-code")), + Section("mobilization", "Mobilization briefing", F("coordinator", "Coordinator", RmsFieldType.Person, true, true, true), F("briefed_at", "Briefed at", RmsFieldType.DateTime), F("etd", "Estimated departure", RmsFieldType.DateTime), F("eta", "Estimated arrival", RmsFieldType.DateTime), F("travel_instructions", "Travel instructions", RmsFieldType.LongText), F("point_of_hire", "Point of hire", RmsFieldType.Address)), + Rows("roster", "Roster / manifest", null, 200, F("member", "Member", RmsFieldType.Person, true), F("position", "Position", RmsFieldType.ShortText, true, true), F("trainee", "Trainee", RmsFieldType.Boolean), Ext("request_number", "Request number", "request", true), F("unit", "Unit / equipment", RmsFieldType.Unit)), + Rows("activity", "Daily activity and resource status", null, 200, F("date", "Date", RmsFieldType.Date, true), Select("status", "Resource status", true, true, "Mobilizing", "Assigned", "Available", "Out of service", "Released", "Returning"), F("summary", "Summary", RmsFieldType.LongText), Quantity("hours", "Hours worked", "time", "h")), + Section("evidence", "Time, equipment and expense evidence", Ext("time_evidence", "Personnel time evidence", "dtr"), Ext("equipment_evidence", "Equipment time evidence", "equipment-time"), Money("expenses", "Expenses"), F("receipts", "Receipts", RmsFieldType.Attachment)), + Section("release", "Release / demobilization", F("released_at", "Released", RmsFieldType.DateTime), Ext("release_order", "Release order", "release"), F("return_travel", "Return travel", RmsFieldType.LongText), F("equipment_reconciled", "Equipment / property reconciled", RmsFieldType.Boolean, false, false, true), F("returned_at", "Actual return to home unit", RmsFieldType.DateTime), F("outstanding_finance", "Outstanding finance / documents", RmsFieldType.LongText)), + Section("closeout", "Closeout", F("performance_notes", "Performance / AAR notes", RmsFieldType.LongText), F("closeout_approver", "Closeout approver", RmsFieldType.Person), F("closeout_signature", "Closeout signature", RmsFieldType.Signature, true))); + Policy(deployment, "manifest-travel", RmsFieldClassification.Restricted, "Travel instructions, point of hire and receipts are minimum-necessary for finance; expense totals stay aggregatable.", "travel_instructions", "point_of_hire", "receipts"); + pack.Definitions.Add(WithOverlays(deployment, + Fr("order_number", "Numéro de commande", "incident_number", "Numéro d'incident", "incident_name", "Nom de l'incident", "incident_subdivision", "Province/territoire de l'incident", "ordering_office", "Bureau de commande", "requesting_agency", "Agence demanderesse", "sending_agency", "Agence expéditrice", "coordinator", "Coordonnateur", "roster", "Liste / manifeste", "released_at", "Libéré le", "returned_at", "Retour réel à l'unité d'attache", "expenses", "Dépenses"), + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["hours"] = "h" }, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["hours"] = "h" })); + return pack; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordTemplatePacksService.cs b/Core/Resgrid.Services/Records/RecordTemplatePacksService.cs new file mode 100644 index 00000000..fad8557e --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordTemplatePacksService.cs @@ -0,0 +1,185 @@ +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.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Product template packs and jurisdiction profiles (RMS plan section 4.1; RMS-1B launch templates, RMS-1C + /// packs). The content is ; this service renders a template for a profile and + /// locale (labels, units, currency, provenance statement), mirrors the catalog into the product-scope tables, + /// and diffs a department clone against the current product template for the deliberate-incorporate flow. + /// + public class RecordTemplatePacksService : IRecordTemplatePacksService + { + private readonly IRmsTemplatePackVersionsRepository _packs; + private readonly IRmsJurisdictionProfileVersionsRepository _profiles; + private static int _catalogEnsured; + + public RecordTemplatePacksService(IRmsTemplatePackVersionsRepository packs, IRmsJurisdictionProfileVersionsRepository profiles) + { + _packs = packs; + _profiles = profiles; + } + + public async Task> GetCatalogAsync() + { + await EnsureCatalogAsync(); + return RecordTemplateCatalog.Packs.Select(p => new RecordTemplatePackSummary + { + PackKey = p.Key, Version = p.Version, Name = p.Name, Category = p.Category, Description = p.Description, IsPreview = p.IsPreview, ArtifactStatus = p.ArtifactStatus.ToString(), + SupportedProfiles = p.SupportedProfiles.ToList(), SupportedLocales = p.SupportedLocales.ToList(), ReviewedOn = p.ReviewedOn, Sources = p.Sources.ToList(), + Definitions = p.Definitions.Select(d => new RecordTemplateSummary + { + Key = d.Key, Name = d.Name, Category = d.Category, Description = d.Description, LifecyclePreset = d.LifecyclePreset.ToString(), + SectionCount = d.Schema.Sections.Count, FieldCount = d.Schema.AllFields().Count(), MinimumClientCapability = RecordsClientCapabilities.Derive(d.Schema), + ArtifactStatus = p.ArtifactStatus.ToString(), IsPreview = p.IsPreview + }).ToList() + }).ToList(); + } + + public async Task> GetProfilesAsync() + { + await EnsureCatalogAsync(); + return RecordTemplateCatalog.Profiles.ToList(); + } + + public Task GetProfileAsync(string profileKey) => Task.FromResult(RecordTemplateCatalog.FindProfile(string.IsNullOrWhiteSpace(profileKey) ? "generic" : profileKey)); + + public RecordTemplateDefinition GetTemplate(string templateKey) => RecordTemplateCatalog.Find(templateKey); + + public Task RenderAsync(string templateKey, string profileKey, string locale) + { + var template = RecordTemplateCatalog.Find(templateKey); + if (template == null) return Task.FromResult(null); + profileKey = string.IsNullOrWhiteSpace(profileKey) ? "generic" : profileKey.Trim().ToLowerInvariant(); + var profile = RecordTemplateCatalog.FindProfile(profileKey) ?? throw new ArgumentException($"'{profileKey}' is not a jurisdiction profile.", nameof(profileKey)); + var pack = RecordTemplateCatalog.PackOf(template.Key); + if (pack != null && !pack.SupportedProfiles.Contains(profileKey, StringComparer.OrdinalIgnoreCase)) + throw new ArgumentException($"Pack '{pack.Key}' does not support profile '{profileKey}'.", nameof(profileKey)); + locale = string.IsNullOrWhiteSpace(locale) ? profile.DefaultLocale : locale.Trim(); + return Task.FromResult(Render(template, profile, profileKey, locale)); + } + + /// Applies the overlay: a deep copy of the base schema with profile labels, units and currency; the base is never mutated. + public static RecordTemplateRendering Render(RecordTemplateDefinition template, RmsJurisdictionProfileVersion profile, string profileKey, string locale) + { + var schema = JsonConvert.DeserializeObject(RecordDefinitionSchema.Serialize(template.Schema)); + template.Overlays.TryGetValue(profileKey, out var overlay); + var terminology = profile.Terminology.TryGetValue(locale, out var terms) ? terms : new Dictionary(); + var labels = overlay != null && overlay.Labels.TryGetValue(locale, out var l) ? l : new Dictionary(); + + foreach (var section in schema.Sections) + { + if (labels.TryGetValue(section.Key, out var sectionLabel)) section.Label = sectionLabel; + foreach (var field in section.Fields) + { + if (labels.TryGetValue(field.Key, out var fieldLabel)) field.Label = fieldLabel; + else if (terminology.TryGetValue(field.Key, out var term)) field.Label = term; + if (field.Type == RmsFieldType.Quantity) + { + if (overlay != null && overlay.DefaultUnits.TryGetValue(field.Key, out var unit) && RmsUnits.Find(unit)?.Family == field.UnitFamily) field.DefaultUnit = unit; + else field.DefaultUnit = RmsUnits.PreferredUnit(field.UnitFamily, profile.MeasurementSystem) ?? field.DefaultUnit; + } + if (field.Type == RmsFieldType.Decimal && !string.IsNullOrWhiteSpace(field.FixedUnitLabel)) + { + // Delivery Run's mileage ships as a decimal with a fixed unit label (plan 4.1); the label follows the profile. + if (field.FixedUnitLabel == "mi" && string.Equals(profile.MeasurementSystem, "metric", StringComparison.OrdinalIgnoreCase)) field.FixedUnitLabel = "km"; + if (field.FixedUnitLabel == "km" && string.Equals(profile.MeasurementSystem, "customary", StringComparison.OrdinalIgnoreCase)) field.FixedUnitLabel = "mi"; + } + if (field.Type == RmsFieldType.Currency) + field.DefaultCurrency = overlay?.CurrencyCode ?? profile.CurrencyCode ?? field.DefaultCurrency ?? "USD"; + foreach (var option in field.Options) + if (option.Labels != null && option.Labels.TryGetValue(locale, out var optionLabel)) option.Label = optionLabel; + // Pack-locked classification and the pack's protected-data policies can only tighten in a clone; the + // rendering carries the floor, and a department definition may raise it but never lower it. + var floor = template.FloorFor(field.Key); + if (floor.HasValue && field.Classification < floor.Value) + { + // The floor also fixes the safe projection: a non-Standard value is never indexed, grouped, + // summed or handed to Workflow (plan 4.1), whatever the base field declared. + field.Classification = floor.Value; + field.Searchable = false; + field.Groupable = false; + field.Aggregatable = false; + field.WorkflowExposed = false; + } + } + } + + var status = overlay?.ArtifactStatus ?? (profileKey == "generic" ? RmsArtifactStatus.DepartmentLocal : RmsArtifactStatus.Compatible); + var sources = overlay?.Sources?.Count > 0 ? overlay.Sources : RecordTemplateCatalog.PackOf(template.Key)?.Sources ?? new List(); + var pack = RecordTemplateCatalog.PackOf(template.Key); + var statement = status == RmsArtifactStatus.Exact + ? $"Exact form output per {string.Join(", ", sources.Select(s => s.Identifier))}." + : status == RmsArtifactStatus.Compatible + ? $"Compatible with {string.Join(", ", sources.Select(s => s.Identifier).DefaultIfEmpty("the generic template"))} ({profile.Name}, reviewed {(sources.FirstOrDefault()?.ReviewedOn ?? pack?.ReviewedOn)?.ToString("yyyy-MM-dd") ?? "n/a"}); not an exact named form." + (pack?.IsPreview == true ? " Preview: no claim of agency acceptance." : string.Empty) + : "Department-local template; carries no jurisdiction or agency provenance."; + return new RecordTemplateRendering + { + Template = template, ProfileKey = profileKey, Locale = locale, MeasurementSystem = profile.MeasurementSystem, CurrencyCode = overlay?.CurrencyCode ?? profile.CurrencyCode, + Schema = schema, ArtifactStatus = status, Sources = sources.ToList(), ProvenanceStatement = statement, Policies = template.ProtectedDataPolicies.ToList() + }; + } + + public async Task EnsureCatalogAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.CompareExchange(ref _catalogEnsured, 1, 0) != 0) return 0; + var written = 0; + try + { + var existingPacks = (await _packs.GetCatalogAsync())?.ToList() ?? new List(); + foreach (var pack in RecordTemplateCatalog.Packs) + { + var checksum = RecordSnapshotSerializer.Checksum(string.Join("|", pack.Definitions.Select(d => d.Key + ":" + RecordSnapshotSerializer.Checksum(d.Schema.Canonical())))); + var row = existingPacks.FirstOrDefault(p => p.PackKey == pack.Key && p.Version == pack.Version); + if (row != null && row.ContentChecksum == checksum) continue; + var now = DateTime.UtcNow; + row ??= new RmsTemplatePackVersion { RmsTemplatePackVersionId = Guid.NewGuid().ToString(), DepartmentId = RmsTemplatePackVersion.ProductDepartmentId, ProtectionId = Guid.NewGuid().ToString(), PackKey = pack.Key, Version = pack.Version, CreatedOn = now, RowVersion = 0 }; + row.Name = pack.Name; row.Category = pack.Category; row.Description = pack.Description; row.IsPreview = pack.IsPreview; + row.DefinitionKeys = string.Join(",", pack.Definitions.Select(d => d.Key)); row.SupportedProfiles = string.Join(",", pack.SupportedProfiles); row.SupportedLocales = string.Join(",", pack.SupportedLocales); + row.ReleaseNotes = pack.ReleaseNotes; row.SourceProvenanceJson = JsonConvert.SerializeObject(pack.Sources); row.ReviewedOn = pack.ReviewedOn; row.ArtifactStatus = (int)pack.ArtifactStatus; + row.ContentChecksum = checksum; row.ModifiedOn = now; row.RowVersion += 1; + await _packs.SaveOrUpdateAsync(row, cancellationToken, true); + written++; + } + var existingProfiles = (await _profiles.GetCatalogAsync())?.ToList() ?? new List(); + foreach (var profile in RecordTemplateCatalog.Profiles) + { + var row = existingProfiles.FirstOrDefault(p => p.ProfileKey == profile.ProfileKey && p.Version == profile.Version); + if (row != null && row.TerminologyJson == profile.TerminologyJson && row.StandardsJson == profile.StandardsJson && row.Name == profile.Name) continue; + var copy = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(profile)); + copy.RmsJurisdictionProfileVersionId = row?.RmsJurisdictionProfileVersionId ?? Guid.NewGuid().ToString(); + copy.ProtectionId = row?.ProtectionId ?? Guid.NewGuid().ToString(); + copy.ModifiedOn = DateTime.UtcNow; copy.RowVersion = (row?.RowVersion ?? 0) + 1; + await _profiles.SaveOrUpdateAsync(copy, cancellationToken, true); + written++; + } + } + catch (Exception ex) + { + // The code catalog is authoritative; a mirroring failure (a repository not yet migrated) must not block browsing. + Interlocked.Exchange(ref _catalogEnsured, 0); + Resgrid.Framework.Logging.LogException(ex, "Template pack catalog mirror failed; browsing continues from code."); + } + return written; + } + + public RecordDefinitionDiff DiffAgainstTemplate(string templateKey, string profileKey, string locale, RecordDefinitionSchema departmentSchema, string definitionKey, int version) + { + var template = RecordTemplateCatalog.Find(templateKey); + if (template == null) return null; + var profile = RecordTemplateCatalog.FindProfile(string.IsNullOrWhiteSpace(profileKey) ? "generic" : profileKey) ?? RecordTemplateCatalog.FindProfile("generic"); + var rendering = Render(template, profile, profile.ProfileKey, locale ?? profile.DefaultLocale); + var diff = new RecordDefinitionDiff { DefinitionKey = definitionKey, FromVersion = version, ToVersion = version }; + RecordDefinitionsService.DiffSchemas(diff, departmentSchema, rendering.Schema); + return diff; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordTypedValuesService.cs b/Core/Resgrid.Services/Records/RecordTypedValuesService.cs new file mode 100644 index 00000000..ada6b517 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordTypedValuesService.cs @@ -0,0 +1,1041 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +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 typed value seam for department definitions (RMS plan section 5.3, RMS-1B/1C). Every posted value is parsed + /// against the pinned field type into exactly one populated column group of RmsRecordValues; repeating sections + /// become RmsRecordValueGroups rows; references are validated against the department and carry a server-authored + /// snapshot. The bounded rule language (equals, sets, empty, ranges, AND/OR) only controls visibility and + /// requiredness. Locked system definitions never come through here. + /// + public class RecordTypedValuesService : IRecordTypedValuesService + { + public const int MaxLongText = 100000; + public const int MaxShortText = 400; + public const int MaxRowsPerSection = 500; + public const int MaxSummaryLength = 120; + public const string Redacted = "REDACTED"; + + private readonly IRmsRecordValuesRepository _values; + private readonly IRmsRecordValueGroupsRepository _groups; + private readonly IRmsRecordAttachmentsRepository _attachments; + private readonly IDepartmentsService _departments; + private readonly IUnitsService _units; + private readonly IDepartmentGroupsService _departmentGroups; + private readonly IContactsService _contacts; + private readonly ICallsService _calls; + private readonly IInventoryService _inventory; + private readonly IRecordsProtectionService _protection; + + public RecordTypedValuesService(IRmsRecordValuesRepository values, IRmsRecordValueGroupsRepository groups, IRmsRecordAttachmentsRepository attachments, + IDepartmentsService departments, IUnitsService units, IDepartmentGroupsService departmentGroups, IContactsService contacts, ICallsService calls, IInventoryService inventory, + IRecordsProtectionService protection) + { + _values = values; + _groups = groups; + _attachments = attachments; + _departments = departments; + _units = units; + _departmentGroups = departmentGroups; + _contacts = contacts; + _calls = calls; + _inventory = inventory; + _protection = protection; + } + + // ------------------------------------------------------------------------------------------------ + // Validation and parsing + // ------------------------------------------------------------------------------------------------ + + public async Task ValidateAsync(int departmentId, RmsRecordDefinitionVersion version, List inputs, bool finalizing) + { + var context = new ParseContext(departmentId, null, version?.Schema ?? new RecordDefinitionSchema(), this); + var parsed = await ParseAllAsync(context, inputs ?? new List(), null); + if (finalizing) + ApplyFinalizationRules(context, parsed); + return new RecordValueValidation { Issues = context.Issues }; + } + + public RecordRuleEvaluation EvaluateRules(RecordDefinitionSchema schema, RecordValueSet values) + { + var evaluation = new RecordRuleEvaluation(); + schema = schema ?? new RecordDefinitionSchema(); + values = values ?? new RecordValueSet(); + foreach (var section in schema.Sections) + { + // Section rules see scalars only (the validator enforces it), so a section shows or hides as a whole. + var visible = section.Rules.Where(r => r.Effect == RmsRuleEffect.Show).All(r => Evaluate(r.Condition, schema, values, 0, null)); + if (!visible) + { + evaluation.HiddenSectionKeys.Add(section.Key); + foreach (var field in section.Fields) evaluation.HiddenFieldKeys.Add(field.Key); + continue; + } + if (!section.Repeating) + { + foreach (var field in section.Fields) + { + if (!field.Rules.Where(r => r.Effect == RmsRuleEffect.Show).All(r => Evaluate(r.Condition, schema, values, 0, null))) + { + evaluation.HiddenFieldKeys.Add(field.Key); + continue; + } + if (field.Required || field.RequiredToFinalize || field.Rules.Where(r => r.Effect == RmsRuleEffect.Require).Any(r => Evaluate(r.Condition, schema, values, 0, null))) + evaluation.RequiredFieldKeys.Add(field.Key); + } + continue; + } + + // Repeating sections evaluate per row: a field rule may reference its own row's cells (per-row rules) and + // any scalar; the outcome lands on the row so two rows of the same section can differ. + var rows = values.Section(section.Key)?.Rows ?? new List(); + foreach (var field in section.Fields) + { + if (field.Required || field.RequiredToFinalize) evaluation.RequiredFieldKeys.Add(field.Key); + if (field.Rules.Count == 0) continue; + if (rows.Count == 0) + { + // No rows yet: evaluate against scalars alone so an empty section still renders its default state. + if (!field.Rules.Where(r => r.Effect == RmsRuleEffect.Show).All(r => Evaluate(r.Condition, schema, values, 0, null))) evaluation.HiddenFieldKeys.Add(field.Key); + else if (field.Rules.Where(r => r.Effect == RmsRuleEffect.Require).Any(r => Evaluate(r.Condition, schema, values, 0, null))) evaluation.RequiredFieldKeys.Add(field.Key); + continue; + } + foreach (var row in rows) + { + var outcome = evaluation.Row(section.Key, row.RowKey ?? row.GroupId); + if (!field.Rules.Where(r => r.Effect == RmsRuleEffect.Show).All(r => Evaluate(r.Condition, schema, values, 0, row))) + { + outcome.HiddenFieldKeys.Add(field.Key); + continue; + } + if (field.Rules.Where(r => r.Effect == RmsRuleEffect.Require).Any(r => Evaluate(r.Condition, schema, values, 0, row))) + outcome.RequiredFieldKeys.Add(field.Key); + } + } + } + return evaluation; + } + + private static bool Evaluate(RecordConditionSchema condition, RecordDefinitionSchema schema, RecordValueSet values, int depth, RecordValueRow row) + { + if (condition == null) return true; + if (depth > RecordDefinitionSchema.MaxRuleDepth) throw new InvalidOperationException("Rule nesting exceeds the supported depth."); + switch (condition.Operator) + { + case RmsRuleOperator.And: + return (condition.Conditions ?? new List()).All(c => Evaluate(c, schema, values, depth + 1, row)); + case RmsRuleOperator.Or: + return (condition.Conditions ?? new List()).Any(c => Evaluate(c, schema, values, depth + 1, row)); + } + + // The row's own cell wins when the referenced field lives in the same repeating section; everything else is a scalar. + var cell = row?.Cell(condition.FieldKey) ?? values.Scalar(condition.FieldKey); + var field = schema.FindField(condition.FieldKey); + var current = cell?.Value; + var currentSet = cell?.Values ?? (current == null ? new List() : new List { current }); + switch (condition.Operator) + { + case RmsRuleOperator.IsEmpty: + return string.IsNullOrWhiteSpace(current) && currentSet.Count == 0 && cell?.ReferenceId == null; + case RmsRuleOperator.IsNotEmpty: + return !(string.IsNullOrWhiteSpace(current) && currentSet.Count == 0 && cell?.ReferenceId == null); + case RmsRuleOperator.Equals: + return currentSet.Any(v => ValueEquals(field, v, condition.Value)) || cell?.ReferenceId != null && string.Equals(cell.ReferenceId, condition.Value, StringComparison.OrdinalIgnoreCase); + case RmsRuleOperator.NotEquals: + return !(currentSet.Any(v => ValueEquals(field, v, condition.Value)) || cell?.ReferenceId != null && string.Equals(cell.ReferenceId, condition.Value, StringComparison.OrdinalIgnoreCase)); + case RmsRuleOperator.InSet: + return currentSet.Any(v => (condition.Values ?? new List()).Any(x => ValueEquals(field, v, x))); + case RmsRuleOperator.NotInSet: + return !currentSet.Any(v => (condition.Values ?? new List()).Any(x => ValueEquals(field, v, x))); + case RmsRuleOperator.InRange: + if (cell?.Number.HasValue == true) + return (!condition.Min.HasValue || cell.Number >= condition.Min) && (!condition.Max.HasValue || cell.Number <= condition.Max); + if (DateTime.TryParse(current, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var when)) + return (!condition.MinDate.HasValue || when >= condition.MinDate) && (!condition.MaxDate.HasValue || when <= condition.MaxDate); + return false; + } + return false; + } + + private static bool ValueEquals(RecordFieldSchema field, string a, string b) + { + if (a == null || b == null) return a == b; + if (field != null && (field.Type == RmsFieldType.Integer || field.Type == RmsFieldType.Decimal || field.Type == RmsFieldType.Currency || field.Type == RmsFieldType.Quantity) + && decimal.TryParse(a, NumberStyles.Any, CultureInfo.InvariantCulture, out var x) && decimal.TryParse(b, NumberStyles.Any, CultureInfo.InvariantCulture, out var y)) + return x == y; + if (field != null && field.Type == RmsFieldType.Boolean) + return ParseBool(a) == ParseBool(b); + return string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private sealed class ParseContext + { + public ParseContext(int departmentId, string recordId, RecordDefinitionSchema schema, RecordTypedValuesService owner) + { + DepartmentId = departmentId; RecordId = recordId; Schema = schema; Owner = owner; + } + public int DepartmentId { get; } + public string RecordId { get; } + public RecordDefinitionSchema Schema { get; } + public RecordTypedValuesService Owner { get; } + public List Issues { get; } = new List(); + public string UserId { get; set; } + public DateTime Now { get; set; } = DateTime.UtcNow; + public List Names { get; set; } + public HashSet AttachmentIds { get; set; } + public void Error(RecordValueInput input, string code, string message) => Issues.Add(new RecordValueIssue { SectionKey = input?.SectionKey, FieldKey = input?.FieldKey, RowKey = input?.RowKey, Code = code, Message = message }); + public void Error(string sectionKey, string fieldKey, string rowKey, string code, string message) => Issues.Add(new RecordValueIssue { SectionKey = sectionKey, FieldKey = fieldKey, RowKey = rowKey, Code = code, Message = message }); + } + + /// A parsed row set: groups (repeating rows) and the value rows that point at them. + private sealed class ParsedValues + { + public List Groups { get; } = new List(); + public List Rows { get; } = new List(); + } + + private async Task ParseAllAsync(ParseContext context, List inputs, RmsRecordDefinitionVersion version) + { + var parsed = new ParsedValues(); + var schema = context.Schema; + var groupsByKey = new Dictionary(StringComparer.OrdinalIgnoreCase); + var seenScalars = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var input in inputs.Where(i => i != null)) + { + var fieldKey = RecordDefinitionKeys.NormalizeKey(input.FieldKey); + var field = schema.FindField(fieldKey); + if (field == null) + { + context.Error(input, "unknown_field", $"'{input.FieldKey}' is not a field of this definition version."); + continue; + } + var section = schema.SectionOf(fieldKey); + if (!string.IsNullOrWhiteSpace(input.SectionKey) && !string.Equals(section.Key, input.SectionKey, StringComparison.OrdinalIgnoreCase)) + { + context.Error(input, "wrong_section", $"'{input.FieldKey}' belongs to section '{section.Key}'."); + continue; + } + + string groupId = null; + if (section.Repeating) + { + var rowKey = string.IsNullOrWhiteSpace(input.RowKey) ? "row-" + input.Ordinal : input.RowKey.Trim(); + var groupKey = section.Key + "|" + rowKey; + if (!groupsByKey.TryGetValue(groupKey, out var group)) + { + if (groupsByKey.Count(g => string.Equals(g.Value.SectionKey, section.Key, StringComparison.OrdinalIgnoreCase)) >= Math.Min(MaxRowsPerSection, section.MaxRows ?? MaxRowsPerSection)) + { + context.Error(input, "too_many_rows", $"Section '{section.Label ?? section.Key}' accepts at most {section.MaxRows ?? MaxRowsPerSection} rows."); + continue; + } + group = new RmsRecordValueGroup + { + RmsRecordValueGroupId = Guid.NewGuid().ToString(), + DepartmentId = context.DepartmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = context.RecordId, + RecordKind = (int)RmsRecordKind.Operational, + RmsRecordDefinitionVersionId = version?.RmsRecordDefinitionVersionId, + SectionKey = section.Key, + Ordinal = input.Ordinal, + ClientRowKey = rowKey.Length > 64 ? rowKey.Substring(0, 64) : rowKey, + CreatedOn = context.Now, + ModifiedOn = context.Now, + RowVersion = 1 + }; + groupsByKey[groupKey] = group; + parsed.Groups.Add(group); + } + groupId = group.RmsRecordValueGroupId; + } + else if (!seenScalars.Add(fieldKey)) + { + context.Error(input, "duplicate_value", $"'{input.FieldKey}' was posted more than once."); + continue; + } + + var rows = await ParseFieldAsync(context, field, input); + if (rows == null) continue; + var ordinal = 0; + foreach (var row in rows) + { + row.RmsRecordValueId = Guid.NewGuid().ToString(); + row.DepartmentId = context.DepartmentId; + row.ProtectionId = Guid.NewGuid().ToString(); + row.RecordId = context.RecordId; + row.RecordKind = (int)RmsRecordKind.Operational; + row.RmsRecordDefinitionVersionId = version?.RmsRecordDefinitionVersionId; + row.FieldKey = field.Key; + row.RmsRecordValueGroupId = groupId; + row.Ordinal = rows.Count > 1 ? ordinal++ : 0; + row.ValueType = (int)field.Type; + // ADP catalog v11: a Protected-classified field's row is sealed by the seam and swept by the engine. + row.ProtectionRequired = field.Classification == RmsFieldClassification.Protected; + row.CreatedOn = context.Now; + row.ModifiedOn = context.Now; + row.RowVersion = 1; + if (row.PopulatedColumnGroups() != 1) + throw new InvalidOperationException($"Field '{field.Key}' produced {row.PopulatedColumnGroups()} column groups; exactly one is allowed."); + parsed.Rows.Add(row); + } + } + + // Ordinals of repeating rows follow the posted order, densely. + foreach (var sectionGroups in parsed.Groups.GroupBy(g => g.SectionKey, StringComparer.OrdinalIgnoreCase)) + { + var ordinal = 0; + foreach (var group in sectionGroups.OrderBy(g => g.Ordinal).ThenBy(g => g.ClientRowKey, StringComparer.Ordinal)) + group.Ordinal = ordinal++; + } + return parsed; + } + + private static bool IsBlank(RecordValueInput input) => string.IsNullOrWhiteSpace(input.Value) && (input.Values == null || input.Values.All(string.IsNullOrWhiteSpace)) && string.IsNullOrWhiteSpace(input.ReferenceId); + + /// Null = the input was blank (nothing stored) or invalid (issue recorded); otherwise the rows to store. + private async Task> ParseFieldAsync(ParseContext context, RecordFieldSchema field, RecordValueInput input) + { + if (IsBlank(input)) return null; + var value = input.Value?.Trim(); + var row = new RmsRecordValue(); + switch (field.Type) + { + case RmsFieldType.ShortText: + { + var max = Math.Min(field.MaxLength ?? MaxShortText, MaxShortText); + if (value.Length > max) { context.Error(input, "too_long", $"'{field.Label ?? field.Key}' accepts at most {max} characters."); return null; } + row.TextValue = value; + return One(row); + } + case RmsFieldType.LongText: + { + var max = Math.Min(field.MaxLength ?? MaxLongText, MaxLongText); + if (value.Length > max) { context.Error(input, "too_long", $"'{field.Label ?? field.Key}' accepts at most {max} characters."); return null; } + row.LongTextValue = value; + return One(row); + } + case RmsFieldType.Integer: + { + if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) { context.Error(input, "not_integer", $"'{field.Label ?? field.Key}' must be a whole number."); return null; } + if (!InRange(field, n)) { context.Error(input, "out_of_range", RangeMessage(field)); return null; } + row.NumberValue = n; + return One(row); + } + case RmsFieldType.Decimal: + { + if (!decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var d)) { context.Error(input, "not_number", $"'{field.Label ?? field.Key}' must be a number."); return null; } + if (!InRange(field, d)) { context.Error(input, "out_of_range", RangeMessage(field)); return null; } + row.NumberValue = d; + return One(row); + } + case RmsFieldType.Boolean: + { + var b = ParseBool(value); + if (b == null) { context.Error(input, "not_boolean", $"'{field.Label ?? field.Key}' must be yes or no."); return null; } + row.BoolValue = b; + return One(row); + } + case RmsFieldType.Date: + { + if (!DateTime.TryParseExact(value, new[] { "yyyy-MM-dd", "yyyy-MM-ddTHH:mm:ss", "yyyy-MM-ddTHH:mm" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) { context.Error(input, "not_date", $"'{field.Label ?? field.Key}' must be a date (yyyy-MM-dd)."); return null; } + row.DateTimeValue = DateTime.SpecifyKind(date.Date, DateTimeKind.Utc); + return One(row); + } + case RmsFieldType.DateTime: + { + if (!DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var when)) { context.Error(input, "not_datetime", $"'{field.Label ?? field.Key}' must be a date and time."); return null; } + row.DateTimeValue = when.UtcDateTime; + row.DateTimeOffsetMinutes = input.OffsetMinutes ?? (int)when.Offset.TotalMinutes; + return One(row); + } + case RmsFieldType.Duration: + { + var seconds = ParseDuration(value); + if (seconds == null || seconds < 0) { context.Error(input, "not_duration", $"'{field.Label ?? field.Key}' must be a duration (hh:mm, minutes, or ISO 8601)."); return null; } + if (!InRange(field, seconds.Value)) { context.Error(input, "out_of_range", RangeMessage(field)); return null; } + row.DurationSeconds = seconds; + return One(row); + } + case RmsFieldType.SingleSelect: + { + var option = field.Options.FirstOrDefault(o => string.Equals(o.Key, value, StringComparison.OrdinalIgnoreCase)); + if (option == null) { context.Error(input, "unknown_option", $"'{value}' is not an option of '{field.Label ?? field.Key}'."); return null; } + row.OptionKey = option.Key; + return One(row); + } + case RmsFieldType.MultiSelect: + { + var keys = (input.Values ?? new List()).Concat(string.IsNullOrWhiteSpace(value) ? new string[0] : value.Split(',')).Select(k => k?.Trim()).Where(k => !string.IsNullOrEmpty(k)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var rows = new List(); + foreach (var key in keys) + { + var option = field.Options.FirstOrDefault(o => string.Equals(o.Key, key, StringComparison.OrdinalIgnoreCase)); + if (option == null) { context.Error(input, "unknown_option", $"'{key}' is not an option of '{field.Label ?? field.Key}'."); return null; } + rows.Add(new RmsRecordValue { OptionKey = option.Key }); + } + return rows.Count == 0 ? null : rows; + } + case RmsFieldType.Address: + { + if (value != null && value.Length > 2000) { context.Error(input, "too_long", "Address text accepts at most 2000 characters."); return null; } + // One column group: the text is the value; coordinates ride in the reference snapshot (not a counted column). + row.LongTextValue = value ?? string.Empty; + if (!string.IsNullOrWhiteSpace(input.ReferenceId)) + { + if (!TryParseCoordinates(input.ReferenceId, out var lat, out var lng)) { context.Error(input, "not_coordinates", "Coordinates must be 'latitude, longitude'."); return null; } + row.ReferenceType = "geo"; + row.ReferenceSnapshotJson = Snapshot(new { coordinates = lat.ToString("0.######", CultureInfo.InvariantCulture) + "," + lng.ToString("0.######", CultureInfo.InvariantCulture) }); + } + return One(row); + } + case RmsFieldType.Person: + { + var id = input.ReferenceId ?? value; + context.Names ??= await _departments.GetAllPersonnelNamesForDepartmentAsync(context.DepartmentId) ?? new List(); + var person = context.Names.FirstOrDefault(n => string.Equals(n.UserId, id, StringComparison.OrdinalIgnoreCase)); + if (person == null) { context.Error(input, "unknown_person", $"'{field.Label ?? field.Key}' must name a member of this department."); return null; } + row.ReferenceType = "user"; row.ReferenceId = person.UserId; row.ReferenceSnapshotJson = Snapshot(new { name = person.Name }); + return One(row); + } + case RmsFieldType.Unit: + { + if (!int.TryParse(input.ReferenceId ?? value, out var unitId)) { context.Error(input, "unknown_unit", $"'{field.Label ?? field.Key}' must reference a unit."); return null; } + var unit = await _units.GetUnitByIdAsync(unitId); + if (unit == null || unit.DepartmentId != context.DepartmentId) { context.Error(input, "unknown_unit", $"'{field.Label ?? field.Key}' must reference a unit of this department."); return null; } + row.ReferenceType = "unit"; row.ReferenceId = unitId.ToString(CultureInfo.InvariantCulture); row.ReferenceSnapshotJson = Snapshot(new { name = unit.Name }); + return One(row); + } + case RmsFieldType.Group: + { + if (!int.TryParse(input.ReferenceId ?? value, out var groupId)) { context.Error(input, "unknown_group", $"'{field.Label ?? field.Key}' must reference a group or station."); return null; } + var group = await _departmentGroups.GetGroupByIdAsync(groupId); + if (group == null || group.DepartmentId != context.DepartmentId) { context.Error(input, "unknown_group", $"'{field.Label ?? field.Key}' must reference a group of this department."); return null; } + row.ReferenceType = "group"; row.ReferenceId = groupId.ToString(CultureInfo.InvariantCulture); row.ReferenceSnapshotJson = Snapshot(new { name = group.Name }); + return One(row); + } + case RmsFieldType.Contact: + { + var contactId = input.ReferenceId ?? value; + var contact = await _contacts.GetContactByIdAsync(contactId); + if (contact == null || contact.DepartmentId != context.DepartmentId) { context.Error(input, "unknown_contact", $"'{field.Label ?? field.Key}' must reference a contact or site of this department."); return null; } + var display = string.Join(" ", new[] { contact.FirstName, contact.LastName }.Where(s => !string.IsNullOrWhiteSpace(s))); + row.ReferenceType = "contact"; row.ReferenceId = contact.ContactId; row.ReferenceSnapshotJson = Snapshot(new { name = string.IsNullOrWhiteSpace(display) ? contact.CompanyName : display }); + return One(row); + } + case RmsFieldType.Attachment: + { + var attachmentId = input.ReferenceId ?? value; + if (context.RecordId != null) + { + context.AttachmentIds ??= new HashSet((await _attachments.GetMetadataForRecordAsync(context.DepartmentId, context.RecordId))?.Select(a => a.RmsRecordAttachmentId) ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase); + if (!context.AttachmentIds.Contains(attachmentId)) { context.Error(input, "unknown_attachment", $"'{field.Label ?? field.Key}' must reference an attachment of this Record."); return null; } + } + row.ReferenceType = "attachment"; row.ReferenceId = attachmentId; row.ReferenceSnapshotJson = Snapshot(new { }); + return One(row); + } + case RmsFieldType.Signature: + { + // An acknowledgement: who signed, when, and the statement they accepted. The signer is the acting user + // unless the field names another member (a customer acknowledgement records the name as text). + var signer = input.ReferenceId ?? context.UserId; + context.Names ??= await _departments.GetAllPersonnelNamesForDepartmentAsync(context.DepartmentId) ?? new List(); + var person = signer == null ? null : context.Names.FirstOrDefault(n => string.Equals(n.UserId, signer, StringComparison.OrdinalIgnoreCase)); + row.ReferenceType = "signature"; + row.ReferenceId = person?.UserId ?? "external"; + row.ReferenceSnapshotJson = Snapshot(new { name = person?.Name ?? value, statement = value, signed_on = context.Now, method = "web" }); + return One(row); + } + case RmsFieldType.ExternalReference: + { + var identifier = (input.ReferenceId ?? value)?.Trim(); + if (string.IsNullOrEmpty(identifier) || identifier.Length > 200) { context.Error(input, "bad_reference", $"'{field.Label ?? field.Key}' must be an identifier of at most 200 characters."); return null; } + var scheme = (input.ReferenceType ?? field.ReferenceType ?? "external").Trim(); + row.ReferenceType = "external:" + scheme; row.ReferenceId = identifier; row.ReferenceSnapshotJson = Snapshot(new { scheme }); + return One(row); + } + case RmsFieldType.Currency: + { + if (!decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount)) { context.Error(input, "not_number", $"'{field.Label ?? field.Key}' must be an amount."); return null; } + var code = (input.CurrencyCode ?? field.DefaultCurrency ?? "USD").ToUpperInvariant(); + if (!RmsCurrencies.IsSupported(code)) { context.Error(input, "unknown_currency", $"'{code}' is not a supported currency."); return null; } + if (!InRange(field, amount)) { context.Error(input, "out_of_range", RangeMessage(field)); return null; } + row.NumberValue = decimal.Round(amount, 2); row.CurrencyCode = code; + return One(row); + } + case RmsFieldType.Quantity: + { + if (!decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var quantity)) { context.Error(input, "not_number", $"'{field.Label ?? field.Key}' must be a number."); return null; } + var unitCode = input.UnitCode ?? field.DefaultUnit; + var unit = RmsUnits.Find(unitCode); + if (unit == null || !string.IsNullOrWhiteSpace(field.UnitFamily) && !string.Equals(unit.Family, field.UnitFamily, StringComparison.OrdinalIgnoreCase)) { context.Error(input, "unknown_unit", $"'{unitCode}' is not a unit of {field.UnitFamily ?? "this field"}."); return null; } + var canonical = RmsUnits.Canonicalize(quantity, unit.Code); + if (!InRange(field, canonical?.Value ?? quantity)) { context.Error(input, "out_of_range", RangeMessage(field)); return null; } + row.NumberValue = quantity; row.UnitCode = unit.Code; row.CanonicalNumberValue = canonical?.Value; row.CanonicalUnitCode = canonical?.Unit; + return One(row); + } + case RmsFieldType.CountrySubdivision: + { + var code = value.ToUpperInvariant(); + if (!RmsCountrySubdivisions.IsValid(code)) { context.Error(input, "unknown_subdivision", $"'{value}' is not a country/subdivision code (CC or CC-SUB)."); return null; } + row.TextValue = code; + return One(row); + } + case RmsFieldType.CallReference: + { + if (!int.TryParse(input.ReferenceId ?? value, out var callId)) { context.Error(input, "unknown_call", $"'{field.Label ?? field.Key}' must reference a call."); return null; } + var call = await _calls.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != context.DepartmentId) { context.Error(input, "unknown_call", $"'{field.Label ?? field.Key}' must reference a call of this department."); return null; } + row.ReferenceType = "call"; row.ReferenceId = callId.ToString(CultureInfo.InvariantCulture); row.ReferenceSnapshotJson = Snapshot(new { number = call.Number, name = call.Name }); + return One(row); + } + case RmsFieldType.InventoryReference: + { + if (!int.TryParse(input.ReferenceId ?? value, out var inventoryId)) { context.Error(input, "unknown_item", $"'{field.Label ?? field.Key}' must reference an inventory item."); return null; } + var item = await _inventory.GetInventoryByIdAsync(inventoryId); + if (item == null || item.DepartmentId != context.DepartmentId) { context.Error(input, "unknown_item", $"'{field.Label ?? field.Key}' must reference an inventory item of this department."); return null; } + row.ReferenceType = "inventory-item"; row.ReferenceId = inventoryId.ToString(CultureInfo.InvariantCulture); row.ReferenceSnapshotJson = Snapshot(new { name = item.Type?.Type ?? ("Inventory " + inventoryId) }); + return One(row); + } + case RmsFieldType.ChecklistWorkOrderReference: + { + var type = (input.ReferenceType ?? field.ReferenceType ?? "checklist").Trim().ToLowerInvariant(); + if (type != "checklist" && type != "workorder") { context.Error(input, "bad_reference", $"'{field.Label ?? field.Key}' must reference a checklist or work order."); return null; } + var id = (input.ReferenceId ?? value)?.Trim(); + if (string.IsNullOrEmpty(id) || id.Length > 64) { context.Error(input, "bad_reference", $"'{field.Label ?? field.Key}' must carry the referenced identifier."); return null; } + row.ReferenceType = type; row.ReferenceId = id; row.ReferenceSnapshotJson = Snapshot(new { type }); + return One(row); + } + } + context.Error(input, "unsupported_type", $"Field type {field.Type} is not supported on this server."); + return null; + } + + private static List One(RmsRecordValue row) => new List { row }; + private static string Snapshot(object o) => JsonConvert.SerializeObject(o); + private static bool InRange(RecordFieldSchema field, decimal n) => (!field.Min.HasValue || n >= field.Min.Value) && (!field.Max.HasValue || n <= field.Max.Value); + private static string RangeMessage(RecordFieldSchema field) => $"'{field.Label ?? field.Key}' must be between {field.Min?.ToString(CultureInfo.InvariantCulture) ?? "any"} and {field.Max?.ToString(CultureInfo.InvariantCulture) ?? "any"}."; + + public static bool? ParseBool(string value) + { + switch ((value ?? string.Empty).Trim().ToLowerInvariant()) + { + case "true": case "1": case "yes": case "on": return true; + case "false": case "0": case "no": case "off": return false; + default: return null; + } + } + + public static long? ParseDuration(string value) + { + value = (value ?? string.Empty).Trim(); + if (value.Length == 0) return null; + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var minutes)) return minutes * 60; + if (value.StartsWith("PT", StringComparison.OrdinalIgnoreCase)) + { + try { return (long)System.Xml.XmlConvert.ToTimeSpan(value).TotalSeconds; } catch (FormatException) { return null; } + } + if (TimeSpan.TryParseExact(value, new[] { @"h\:mm", @"hh\:mm", @"h\:mm\:ss", @"hh\:mm\:ss", @"d\.h\:mm" }, CultureInfo.InvariantCulture, out var span)) return (long)span.TotalSeconds; + return null; + } + + private static bool TryParseCoordinates(string text, out double lat, out double lng) + { + lat = lng = 0; + var parts = (text ?? string.Empty).Split(','); + return parts.Length == 2 && double.TryParse(parts[0].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out lat) && double.TryParse(parts[1].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out lng) + && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; + } + + private void ApplyFinalizationRules(ParseContext context, ParsedValues parsed) + { + var values = Shape(context.Schema, parsed.Groups, parsed.Rows, true); + var evaluation = EvaluateRules(context.Schema, values); + foreach (var section in context.Schema.Sections) + { + if (evaluation.HiddenSectionKeys.Contains(section.Key)) continue; + var set = values.Section(section.Key); + var rows = set?.Rows ?? new List(); + if (section.Repeating && section.MinRows.HasValue && rows.Count < section.MinRows.Value) + context.Error(section.Key, null, null, "too_few_rows", $"Section '{section.Label ?? section.Key}' needs at least {section.MinRows.Value} row(s)."); + foreach (var field in section.Fields) + { + if (!section.Repeating) + { + if (evaluation.IsRequired(section.Key, null, field.Key) && values.Scalar(field.Key)?.Display == null) + context.Error(section.Key, field.Key, null, "required", $"'{field.Label ?? field.Key}' is required before finalization."); + } + else + foreach (var row in rows) + if (evaluation.IsRequired(section.Key, row.RowKey ?? row.GroupId, field.Key) && row.Cell(field.Key)?.Display == null) + context.Error(section.Key, field.Key, row.RowKey, "required", $"'{field.Label ?? field.Key}' is required on every row of '{section.Label ?? section.Key}'."); + } + } + } + + // ------------------------------------------------------------------------------------------------ + // Storage + // ------------------------------------------------------------------------------------------------ + + public async Task SaveDraftValuesAsync(int departmentId, string userId, string recordId, RmsRecordDefinitionVersion version, List inputs, CancellationToken cancellationToken = default) + { + if (version == null) throw new ArgumentNullException(nameof(version)); + var context = new ParseContext(departmentId, recordId, version.Schema, this) { UserId = userId }; + var parsed = await ParseAllAsync(context, inputs ?? new List(), version); + if (context.Issues.Any(i => i.Severity == "error")) + throw new ArgumentException(string.Join(" ", context.Issues.Where(i => i.Severity == "error").Select(i => i.Message))); + + await CarryForwardSealedRowsAsync(departmentId, recordId, version, parsed, context.Now); + + // Seal Protected-classified rows in place for storage; the caller gets its plaintext back afterwards. + var snapshots = parsed.Rows.Where(r => r.ProtectionRequired && !r.IsSealed).Select(r => PlaintextSnapshot.Take(r, RmsProtectedFields.Values)).ToList(); + await _protection.ProtectValuesAsync(departmentId, parsed.Rows, userId, cancellationToken); + + await _values.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); + await _groups.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); + foreach (var group in parsed.Groups) + await _groups.InsertAsync(group, cancellationToken, true); + foreach (var row in parsed.Rows) + await _values.InsertAsync(row, cancellationToken, true); + foreach (var snapshot in snapshots) + snapshot.Restore(); + + return Shape(version.Schema, parsed.Groups, parsed.Rows, true); + } + + /// + /// The sentinel policy for typed values (ADP catalog v11): a sealed row the editor could not reveal posts nothing + /// back (the cell renders withheld and disabled), and dropping it would erase a value the editor never saw. Such + /// rows are carried into the new draft under their own identity, so the envelope's row-key binding still holds; + /// a posted value for the same field replaces the row, and a removed repeating row takes its cells with it. + /// + private async Task CarryForwardSealedRowsAsync(int departmentId, string recordId, RmsRecordDefinitionVersion version, ParsedValues parsed, DateTime now) + { + var existing = (await _values.GetForRecordAsync(departmentId, recordId, null))?.Where(v => v.IsSealed).ToList() ?? new List(); + if (existing.Count == 0) + return; + var oldGroups = (await _groups.GetForRecordAsync(departmentId, recordId, null))?.ToList() ?? new List(); + foreach (var sealedRow in existing) + { + string newGroupId = null; + if (sealedRow.RmsRecordValueGroupId != null) + { + var oldGroup = oldGroups.FirstOrDefault(g => g.RmsRecordValueGroupId == sealedRow.RmsRecordValueGroupId); + var newGroup = oldGroup == null ? null : parsed.Groups.FirstOrDefault(g => string.Equals(g.SectionKey, oldGroup.SectionKey, StringComparison.OrdinalIgnoreCase) && string.Equals(g.ClientRowKey, oldGroup.ClientRowKey, StringComparison.Ordinal)); + if (newGroup == null) + continue; + newGroupId = newGroup.RmsRecordValueGroupId; + } + if (parsed.Rows.Any(r => string.Equals(r.FieldKey, sealedRow.FieldKey, StringComparison.OrdinalIgnoreCase) && r.RmsRecordValueGroupId == newGroupId)) + continue; + if (version.Schema.FindField(sealedRow.FieldKey) == null) + continue; + sealedRow.RmsRecordValueGroupId = newGroupId; + sealedRow.RmsRecordDefinitionVersionId = version.RmsRecordDefinitionVersionId; + sealedRow.ModifiedOn = now; + parsed.Rows.Add(sealedRow); + } + } + + public async Task HydrateAsync(int departmentId, string recordId, string revisionId, RmsRecordDefinitionVersion version, bool canViewRestricted) + { + if (version == null) return new RecordValueSet(); + var groups = (await _groups.GetForRecordAsync(departmentId, recordId, revisionId))?.ToList() ?? new List(); + var rows = (await _values.GetForRecordAsync(departmentId, recordId, revisionId))?.ToList() ?? new List(); + await RevealSealedAsync(departmentId, rows); + var set = Shape(version.Schema, groups, rows, canViewRestricted); + set.DefinitionKey = version.DefinitionKey; + set.DefinitionVersion = version.Version; + set.DefinitionVersionId = version.RmsRecordDefinitionVersionId; + return set; + } + + /// Ambient reveal of sealed rows (catalog v11); a refused row keeps its envelope and shapes as the withheld cell. + private async Task RevealSealedAsync(int departmentId, List rows) + { + var sealedRows = rows.Where(r => r.IsSealed).ToList(); + return sealedRows.Count == 0 ? new ProtectedReadResult() : await _protection.RevealValuesAsync(departmentId, sealedRows); + } + + /// + /// A copied row gets a new identity, and the envelope's AAD is bound to the row key, so a sealed source must be + /// revealed first and the copy re-sealed under its own key (the caller's grant carries finalize under enforcement). + /// + private async Task ResealCopiesAsync(int departmentId, string userId, List sources, List copies, string operation, CancellationToken cancellationToken) + { + (await RevealSealedAsync(departmentId, sources)).RequireRevealed(operation); + foreach (var copy in copies) + { + var source = sources.FirstOrDefault(s => s.RmsRecordValueId == copy.RmsRecordValueId); + if (source == null || !source.IsSealed) continue; + RmsRecordValuePack.Unpack(copy, RmsRecordValuePack.Pack(source)); + copy.ProtectedEnvelope = null; copy.IsProtected = false; copy.ProtectedCatalogVersion = 0; + } + await _protection.ProtectValuesAsync(departmentId, copies, userId, cancellationToken); + } + + public async Task CopyDraftToRevisionAsync(int departmentId, string recordId, string revisionId, CancellationToken cancellationToken = default) + { + var groups = (await _groups.GetForRecordAsync(departmentId, recordId, null))?.ToList() ?? new List(); + var rows = (await _values.GetForRecordAsync(departmentId, recordId, null))?.ToList() ?? new List(); + var now = DateTime.UtcNow; + var copies = rows.Select(Clone).ToList(); + await ResealCopiesAsync(departmentId, null, rows, copies, "finalize", cancellationToken); + var groupMap = new Dictionary(StringComparer.Ordinal); + foreach (var group in groups) + { + var copy = Clone(group); + copy.RmsRecordValueGroupId = Guid.NewGuid().ToString(); + copy.ProtectionId = Guid.NewGuid().ToString(); + copy.RevisionId = revisionId; + copy.CreatedOn = now; copy.ModifiedOn = now; copy.RowVersion = 1; + groupMap[group.RmsRecordValueGroupId] = copy.RmsRecordValueGroupId; + await _groups.InsertAsync(copy, cancellationToken, true); + } + foreach (var copy in copies) + { + var row = rows.First(r => r.RmsRecordValueId == copy.RmsRecordValueId); + copy.RmsRecordValueId = Guid.NewGuid().ToString(); + copy.ProtectionId = Guid.NewGuid().ToString(); + copy.RevisionId = revisionId; + copy.RmsRecordValueGroupId = row.RmsRecordValueGroupId != null && groupMap.TryGetValue(row.RmsRecordValueGroupId, out var mapped) ? mapped : null; + copy.CreatedOn = now; copy.ModifiedOn = now; copy.RowVersion = 1; + } + await SealAndInsertAsync(departmentId, null, copies, cancellationToken); + } + + private async Task SealAndInsertAsync(int departmentId, string userId, List copies, CancellationToken cancellationToken) + { + await _protection.ProtectValuesAsync(departmentId, copies, userId, cancellationToken); + foreach (var copy in copies) + await _values.InsertAsync(copy, cancellationToken, true); + } + + public async Task RestoreDraftFromRevisionAsync(int departmentId, string userId, string recordId, string revisionId, RmsRecordDefinitionVersion version, CancellationToken cancellationToken = default) + { + var groups = (await _groups.GetForRecordAsync(departmentId, recordId, revisionId))?.ToList() ?? new List(); + var rows = (await _values.GetForRecordAsync(departmentId, recordId, revisionId))?.ToList() ?? new List(); + var copies = rows.Select(Clone).ToList(); + await ResealCopiesAsync(departmentId, userId, rows, copies, "restore draft", cancellationToken); + await _values.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); + await _groups.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); + var now = DateTime.UtcNow; + var groupMap = new Dictionary(StringComparer.Ordinal); + foreach (var group in groups) + { + var copy = Clone(group); + copy.RmsRecordValueGroupId = Guid.NewGuid().ToString(); + copy.ProtectionId = Guid.NewGuid().ToString(); + copy.RevisionId = null; + copy.CreatedOn = now; copy.ModifiedOn = now; copy.RowVersion = 1; + groupMap[group.RmsRecordValueGroupId] = copy.RmsRecordValueGroupId; + await _groups.InsertAsync(copy, cancellationToken, true); + } + foreach (var copy in copies) + { + var row = rows.First(r => r.RmsRecordValueId == copy.RmsRecordValueId); + copy.RmsRecordValueId = Guid.NewGuid().ToString(); + copy.ProtectionId = Guid.NewGuid().ToString(); + copy.RevisionId = null; + copy.RmsRecordValueGroupId = row.RmsRecordValueGroupId != null && groupMap.TryGetValue(row.RmsRecordValueGroupId, out var mapped) ? mapped : null; + copy.CreatedOn = now; copy.ModifiedOn = now; copy.RowVersion = 1; + } + await SealAndInsertAsync(departmentId, userId, copies, cancellationToken); + } + + public async Task DeleteDraftAsync(int departmentId, string recordId, CancellationToken cancellationToken = default) + { + var count = await _values.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); + await _groups.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); + return count; + } + + private static T Clone(T entity) => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(entity)); + + // ------------------------------------------------------------------------------------------------ + // Shaping and projections + // ------------------------------------------------------------------------------------------------ + + /// Rows -> sections/rows/cells against the pinned schema. Unknown keys (a value from a field later removed) are ignored; labels are the version's. + public static RecordValueSet Shape(RecordDefinitionSchema schema, IEnumerable groups, IEnumerable rows, bool canViewRestricted) + { + schema = schema ?? new RecordDefinitionSchema(); + var set = new RecordValueSet(); + var groupList = (groups ?? Enumerable.Empty()).ToList(); + var rowList = (rows ?? Enumerable.Empty()).ToList(); + foreach (var section in schema.Sections) + { + var sectionSet = new RecordValueSectionSet { SectionKey = section.Key, Label = section.Label ?? section.Key, Repeating = section.Repeating }; + if (section.Repeating) + { + foreach (var group in groupList.Where(g => string.Equals(g.SectionKey, section.Key, StringComparison.OrdinalIgnoreCase)).OrderBy(g => g.Ordinal)) + { + var row = new RecordValueRow { GroupId = group.RmsRecordValueGroupId, RowKey = group.ClientRowKey ?? group.RmsRecordValueGroupId, Ordinal = group.Ordinal }; + foreach (var field in section.Fields) + row.Cells.Add(ToCell(section, field, rowList.Where(v => v.RmsRecordValueGroupId == group.RmsRecordValueGroupId && string.Equals(v.FieldKey, field.Key, StringComparison.OrdinalIgnoreCase)).OrderBy(v => v.Ordinal).ToList(), row, canViewRestricted, set)); + sectionSet.Rows.Add(row); + } + } + else + { + var row = new RecordValueRow { Ordinal = 0 }; + foreach (var field in section.Fields) + row.Cells.Add(ToCell(section, field, rowList.Where(v => v.RmsRecordValueGroupId == null && string.Equals(v.FieldKey, field.Key, StringComparison.OrdinalIgnoreCase)).OrderBy(v => v.Ordinal).ToList(), row, canViewRestricted, set)); + sectionSet.Rows.Add(row); + } + set.Sections.Add(sectionSet); + } + return set; + } + + private static RecordValueCell ToCell(RecordSectionSchema section, RecordFieldSchema field, List stored, RecordValueRow row, bool canViewRestricted, RecordValueSet set) + { + var cell = new RecordValueCell { SectionKey = section.Key, FieldKey = field.Key, Label = field.Label ?? field.Key, Type = field.Type, Classification = field.Classification, GroupId = row.GroupId, RowKey = row.RowKey, Ordinal = row.Ordinal }; + if (stored.Count == 0) return cell; + if (field.Classification == RmsFieldClassification.Restricted && !canViewRestricted) + { + cell.Withheld = true; cell.Display = Redacted; + if (!set.WithheldFieldKeys.Contains(field.Key)) set.WithheldFieldKeys.Add(field.Key); + return cell; + } + if (stored.Any(v => v.IsProtected && !string.IsNullOrEmpty(v.ProtectedEnvelope) && v.TextValue == null && v.LongTextValue == null)) + { + // Sealed under ADP and not revealed for this caller (catalog v11): the sentinel, never ciphertext. + cell.Withheld = true; cell.Display = Redacted; + if (!set.WithheldFieldKeys.Contains(field.Key)) set.WithheldFieldKeys.Add(field.Key); + return cell; + } + var first = stored[0]; + switch (field.Type) + { + case RmsFieldType.MultiSelect: + cell.Values = stored.Select(v => v.OptionKey).ToList(); + cell.Value = string.Join(",", cell.Values); + cell.Display = string.Join(", ", stored.Select(v => field.Options.FirstOrDefault(o => o.Key == v.OptionKey)?.Label ?? v.OptionKey)); + break; + case RmsFieldType.SingleSelect: + cell.Value = first.OptionKey; + cell.Display = field.Options.FirstOrDefault(o => o.Key == first.OptionKey)?.Label ?? first.OptionKey; + break; + case RmsFieldType.ShortText: + case RmsFieldType.CountrySubdivision: + cell.Value = first.TextValue; + cell.Display = field.Type == RmsFieldType.CountrySubdivision ? RmsCountrySubdivisions.Label(first.TextValue) : first.TextValue; + break; + case RmsFieldType.LongText: + cell.Value = first.LongTextValue; cell.Display = first.LongTextValue; + break; + case RmsFieldType.Address: + { + var coordinates = AddressCoordinates(first); + cell.Value = first.LongTextValue; cell.ReferenceId = coordinates; + cell.Display = string.IsNullOrWhiteSpace(coordinates) ? first.LongTextValue : (string.IsNullOrWhiteSpace(first.LongTextValue) ? coordinates : first.LongTextValue + " (" + coordinates + ")"); + break; + } + case RmsFieldType.Integer: + case RmsFieldType.Decimal: + cell.Number = first.NumberValue; + cell.Value = first.NumberValue?.ToString(field.Type == RmsFieldType.Integer ? "0" : "0.############", CultureInfo.InvariantCulture); + cell.Display = cell.Value + (string.IsNullOrWhiteSpace(field.FixedUnitLabel) ? string.Empty : " " + field.FixedUnitLabel); + break; + case RmsFieldType.Currency: + cell.Number = first.NumberValue; cell.CurrencyCode = first.CurrencyCode; + cell.Value = first.NumberValue?.ToString("0.00", CultureInfo.InvariantCulture); + cell.Display = first.NumberValue.HasValue ? RmsCurrencies.Format(first.NumberValue.Value, first.CurrencyCode) : null; + break; + case RmsFieldType.Quantity: + cell.Number = first.NumberValue; cell.UnitCode = first.UnitCode; cell.CanonicalNumber = first.CanonicalNumberValue; cell.CanonicalUnitCode = first.CanonicalUnitCode; + cell.Value = first.NumberValue?.ToString("0.############", CultureInfo.InvariantCulture); + cell.Display = cell.Value + " " + first.UnitCode; + break; + case RmsFieldType.Boolean: + cell.Value = first.BoolValue == true ? "true" : "false"; cell.Display = first.BoolValue == true ? "Yes" : "No"; + break; + case RmsFieldType.Date: + cell.Value = first.DateTimeValue?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); cell.Display = cell.Value; + break; + case RmsFieldType.DateTime: + { + var utc = first.DateTimeValue.HasValue ? DateTime.SpecifyKind(first.DateTimeValue.Value, DateTimeKind.Utc) : (DateTime?)null; + cell.OffsetMinutes = first.DateTimeOffsetMinutes; + cell.Value = utc?.ToString("O", CultureInfo.InvariantCulture); + if (utc.HasValue) + { + var local = new DateTimeOffset(utc.Value).ToOffset(TimeSpan.FromMinutes(first.DateTimeOffsetMinutes ?? 0)); + cell.Display = local.ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture); + } + break; + } + case RmsFieldType.Duration: + { + var seconds = first.DurationSeconds ?? 0; + cell.Number = seconds; + cell.Value = (seconds / 60).ToString(CultureInfo.InvariantCulture); + cell.Display = TimeSpan.FromSeconds(seconds).ToString(seconds >= 86400 ? @"d\.hh\:mm" : @"hh\:mm", CultureInfo.InvariantCulture); + break; + } + default: + { + cell.ReferenceType = first.ReferenceType; cell.ReferenceId = first.ReferenceId; cell.Value = first.ReferenceId; + cell.Display = ReferenceDisplay(first); + break; + } + } + return cell; + } + + private static string AddressCoordinates(RmsRecordValue row) + { + if (string.IsNullOrWhiteSpace(row.ReferenceSnapshotJson)) return row.TextValue; + try { return JsonConvert.DeserializeObject>(row.ReferenceSnapshotJson)?.GetValueOrDefault("coordinates") ?? row.TextValue; } + catch (JsonException) { return row.TextValue; } + } + + private static string ReferenceDisplay(RmsRecordValue row) + { + if (string.IsNullOrWhiteSpace(row.ReferenceSnapshotJson)) return row.ReferenceId; + try + { + var snapshot = JsonConvert.DeserializeObject>(row.ReferenceSnapshotJson) ?? new Dictionary(); + if (row.ReferenceType == "signature") + return string.Join(" ", new[] { snapshot.TryGetValue("name", out var n) ? n?.ToString() : null, snapshot.TryGetValue("signed_on", out var s) && s != null ? "signed " + Convert.ToDateTime(s, CultureInfo.InvariantCulture).ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) : null }.Where(x => !string.IsNullOrWhiteSpace(x))); + if (row.ReferenceType == "call") + return string.Join(" ", new[] { snapshot.TryGetValue("number", out var num) ? num?.ToString() : null, snapshot.TryGetValue("name", out var nm) ? nm?.ToString() : null }.Where(x => !string.IsNullOrWhiteSpace(x))); + if (row.ReferenceType != null && row.ReferenceType.StartsWith("external:", StringComparison.Ordinal)) + return row.ReferenceId + " (" + row.ReferenceType.Substring(9) + ")"; + return snapshot.TryGetValue("name", out var name) && !string.IsNullOrWhiteSpace(name?.ToString()) ? name.ToString() : row.ReferenceId; + } + catch (JsonException) + { + return row.ReferenceId; + } + } + + public string ToSearchText(RecordDefinitionSchema schema, RecordValueSet values) + { + if (schema == null || values == null) return string.Empty; + var parts = new List(); + foreach (var cell in values.AllCells()) + { + var field = schema.FindField(cell.FieldKey); + if (field == null || !field.Searchable || field.Classification != RmsFieldClassification.Standard || cell.Withheld || cell.Display == null) continue; + // Long text never enters the projection text (plan section 5.10); everything else searchable does. + if (field.Type == RmsFieldType.LongText || field.Type == RmsFieldType.Address || field.Type == RmsFieldType.Signature) continue; + parts.Add(cell.Display); + } + var text = string.Join(" ", parts); + return text.Length > 4000 ? text.Substring(0, 4000) : text; + } + + public Dictionary ToWorkflowBlock(RecordDefinitionSchema schema, RecordValueSet values) + { + var block = new Dictionary(StringComparer.Ordinal); + if (schema == null || values == null) return block; + foreach (var section in values.Sections) + { + var sectionSchema = schema.FindSection(section.SectionKey); + if (sectionSchema == null) continue; + if (!section.Repeating) + { + foreach (var cell in section.Rows.SelectMany(r => r.Cells)) + { + var field = schema.FindField(cell.FieldKey); + if (field == null || !field.WorkflowExposed || field.Classification != RmsFieldClassification.Standard || cell.Withheld) continue; + block[cell.FieldKey] = WorkflowValue(field, cell); + } + } + else + { + var exposed = sectionSchema.Fields.Where(f => f.WorkflowExposed && f.Classification == RmsFieldClassification.Standard).ToList(); + if (exposed.Count == 0) continue; + var rows = new List>(); + foreach (var row in section.Rows) + { + var item = new Dictionary(StringComparer.Ordinal); + foreach (var field in exposed) + { + var cell = row.Cell(field.Key); + if (cell != null && !cell.Withheld) item[field.Key] = WorkflowValue(field, cell); + } + rows.Add(item); + } + block[section.SectionKey] = rows; + block[section.SectionKey + "_count"] = rows.Count; + } + } + return block; + } + + private static object WorkflowValue(RecordFieldSchema field, RecordValueCell cell) + { + switch (field.Type) + { + case RmsFieldType.Integer: return cell.Number.HasValue ? (object)(long)cell.Number.Value : null; + case RmsFieldType.Decimal: case RmsFieldType.Currency: case RmsFieldType.Quantity: return cell.Number; + case RmsFieldType.Boolean: return cell.Value == "true"; + case RmsFieldType.MultiSelect: return cell.Values ?? new List(); + default: return cell.Display; + } + } + + public Dictionary ToSnapshot(RecordDefinitionSchema schema, RecordValueSet values) + { + var snapshot = new Dictionary(StringComparer.Ordinal); + if (values == null) return snapshot; + foreach (var section in values.Sections) + { + var label = section.Label ?? section.SectionKey; + if (!section.Repeating) + { + var fields = new Dictionary(StringComparer.Ordinal); + foreach (var cell in section.Rows.SelectMany(r => r.Cells).Where(c => c.Display != null)) + fields[SnapshotKey(cell)] = cell.Withheld ? Redacted : cell.Display; + if (fields.Count > 0) snapshot[label] = fields; + } + else + { + var rows = new List>(); + foreach (var row in section.Rows) + { + var fields = new Dictionary(StringComparer.Ordinal); + foreach (var cell in row.Cells.Where(c => c.Display != null)) + fields[SnapshotKey(cell)] = cell.Withheld ? Redacted : cell.Display; + rows.Add(fields); + } + if (rows.Count > 0) snapshot[label] = rows; + } + } + return snapshot; + } + + /// Restricted cells carry a label suffix in snapshots so revisions, diffs and prints can withhold them without the schema. + private static string SnapshotKey(RecordValueCell cell) => (cell.Label ?? cell.FieldKey) + (cell.Classification == RmsFieldClassification.Restricted ? RecordSnapshotSerializer.RestrictedValueSuffix : string.Empty); + + public string ToDisplaySummary(RecordDefinitionSchema schema, RecordValueSet values) + { + if (schema == null || values == null) return null; + var parts = new List(); + foreach (var cell in values.AllCells()) + { + var field = schema.FindField(cell.FieldKey); + if (field == null || cell.Withheld || cell.Display == null || field.Classification != RmsFieldClassification.Standard) continue; + if (field.Type != RmsFieldType.ShortText && field.Type != RmsFieldType.SingleSelect && field.Type != RmsFieldType.Date && field.Type != RmsFieldType.Contact && field.Type != RmsFieldType.Unit && field.Type != RmsFieldType.ExternalReference) continue; + if (!field.Searchable) continue; + parts.Add(cell.Display); + if (parts.Count >= 3) break; + } + var summary = string.Join(" · ", parts); + return summary.Length > MaxSummaryLength ? summary.Substring(0, MaxSummaryLength - 1) + "…" : (summary.Length == 0 ? null : summary); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordWorkAssignmentsService.cs b/Core/Resgrid.Services/Records/RecordWorkAssignmentsService.cs new file mode 100644 index 00000000..63de1f1a --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordWorkAssignmentsService.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Work assignments on Records (RMS plan section 5.2, RMS-1D). Assigning needs the ReviewRecords permission, + /// draft ownership, or department administration; acknowledging and completing need the caller to be an + /// addressee in a verified context; the queue re-checks per-Record visibility on every read because an + /// assignment narrows a queue and never grants access. Every change is audited with the client origin. + /// + public class RecordWorkAssignmentsService : IRecordWorkAssignmentsService + { + private readonly IRmsRecordWorkAssignmentsRepository _assignments; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IUnitsService _units; + private readonly IDepartmentGroupsService _groups; + private readonly IIncidentCommandService _command; + + public RecordWorkAssignmentsService(IRmsRecordWorkAssignmentsRepository assignments, IRmsOperationalRecordsRepository records, IRecordsAuthorizationService authorization, + IRmsAccessAuditsRepository audits, IUnitsService units, IDepartmentGroupsService groups, IIncidentCommandService command) + { + _assignments = assignments; + _records = records; + _authorization = authorization; + _audits = audits; + _units = units; + _groups = groups; + _command = command; + } + + public async Task AssignAsync(int departmentId, string userId, RecordWorkAssignmentInput input, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + if (string.IsNullOrWhiteSpace(input.RecordId)) throw new ArgumentException("A record is required.", nameof(input)); + var purpose = (input.Purpose ?? RmsWorkAssignmentPurposes.Complete).Trim().ToLowerInvariant(); + if (!RmsWorkAssignmentPurposes.IsKnown(purpose)) throw new ArgumentException("Unknown assignment purpose '" + input.Purpose + "'.", nameof(input)); + + var record = await _records.GetByIdForDepartmentAsync(departmentId, input.RecordId); + if (record == null || record.DeletedOn.HasValue || record.PurgedOn.HasValue) throw new ArgumentException("The record was not found.", nameof(input)); + if (!await _authorization.CanUserViewRecordAsync(userId, record.RmsOperationalRecordId, departmentId)) throw new UnauthorizedAccessException("Record access is not authorized."); + if (!await CanManageAsync(departmentId, userId, record)) throw new UnauthorizedAccessException("Assigning work requires ReviewRecords, draft ownership or department administration."); + var state = (RmsRecordState)record.State; + if (RmsLifecycle.IsTerminal(state)) throw new RecordTransitionException(record.RmsOperationalRecordId, state, state, "work cannot be assigned on a voided or cancelled Record"); + + var row = new RmsRecordWorkAssignment + { + RmsRecordWorkAssignmentId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = record.RmsOperationalRecordId, + AssigneeKind = (int)input.AssigneeKind, Purpose = purpose, Note = Trim(input.Note, 1000), DueOn = input.DueOn, State = (int)RmsWorkAssignmentState.Open, + SourceContextJson = input.SourceContext == null || input.SourceContext.IsEmpty ? null : JsonConvert.SerializeObject(new { input.SourceContext.CallId, input.SourceContext.UnitId, input.SourceContext.GroupId, input.SourceContext.CommandRole }), + OriginClient = (int)input.OriginClient, CreatedOn = DateTime.UtcNow, CreatedByUserId = userId, ModifiedOn = DateTime.UtcNow, ModifiedByUserId = userId, RowVersion = 1 + }; + + switch (input.AssigneeKind) + { + case RmsWorkAssigneeKind.Person: + if (string.IsNullOrWhiteSpace(input.AssigneeUserId)) throw new ArgumentException("An assignee is required.", nameof(input)); + if (!await _authorization.IsActiveMemberAsync(input.AssigneeUserId, departmentId)) throw new ArgumentException("The assignee is not an active member of this department.", nameof(input)); + row.AssigneeUserId = input.AssigneeUserId; + break; + case RmsWorkAssigneeKind.Unit: + var unit = input.AssigneeUnitId.HasValue ? await _units.GetUnitByIdAsync(input.AssigneeUnitId.Value) : null; + if (unit == null || unit.DepartmentId != departmentId) throw new ArgumentException("The unit was not found in this department.", nameof(input)); + row.AssigneeUnitId = unit.UnitId; + break; + case RmsWorkAssigneeKind.Group: + var group = input.AssigneeGroupId.HasValue ? await _groups.GetGroupByIdAsync(input.AssigneeGroupId.Value) : null; + if (group == null || group.DepartmentId != departmentId) throw new ArgumentException("The group was not found in this department.", nameof(input)); + row.AssigneeGroupId = group.DepartmentGroupId; + break; + case RmsWorkAssigneeKind.CommandRole: + case RmsWorkAssigneeKind.DispatchRole: + if (string.IsNullOrWhiteSpace(input.AssigneeRole)) throw new ArgumentException("A role name is required.", nameof(input)); + row.AssigneeRole = Trim(input.AssigneeRole, 100); + break; + default: + throw new ArgumentException("Unknown assignee kind.", nameof(input)); + } + + await _assignments.InsertAsync(row, cancellationToken, true); + await AuditAsync(departmentId, userId, row, "Assign work", input.OriginClient, cancellationToken, new { row.AssigneeKind, row.AssigneeUserId, row.AssigneeUnitId, row.AssigneeGroupId, row.AssigneeRole, row.Purpose, row.DueOn }); + return row; + } + + public async Task AcknowledgeAsync(int departmentId, string userId, string assignmentId, long? expectedRowVersion, FieldRecordContext context, RmsOriginClient origin, CancellationToken cancellationToken = default) + { + var row = await LoadAsync(departmentId, userId, assignmentId); + if (!await IsAssigneeAsync(departmentId, userId, row, context)) throw new UnauthorizedAccessException("Only an addressee may acknowledge this assignment."); + if (row.State != (int)RmsWorkAssignmentState.Open) throw new InvalidOperationException("Only an open assignment can be acknowledged."); + Guard(row, expectedRowVersion); + row.State = (int)RmsWorkAssignmentState.Acknowledged; + row.AcknowledgedOn = DateTime.UtcNow; + row.AcknowledgedByUserId = userId; + await SaveAsync(row, userId, cancellationToken); + await AuditAsync(departmentId, userId, row, "Acknowledge work", origin, cancellationToken); + return row; + } + + public async Task CompleteAsync(int departmentId, string userId, string assignmentId, long? expectedRowVersion, FieldRecordContext context, RmsOriginClient origin, CancellationToken cancellationToken = default) + { + var row = await LoadAsync(departmentId, userId, assignmentId); + if (!await IsAssigneeAsync(departmentId, userId, row, context) && !await CanManageAsync(departmentId, userId, await _records.GetByIdForDepartmentAsync(departmentId, row.RecordId))) + throw new UnauthorizedAccessException("Only an addressee or the assigner may complete this assignment."); + if (!row.IsOpen) throw new InvalidOperationException("The assignment is already closed."); + Guard(row, expectedRowVersion); + row.State = (int)RmsWorkAssignmentState.Completed; + row.CompletedOn = DateTime.UtcNow; + row.CompletedByUserId = userId; + await SaveAsync(row, userId, cancellationToken); + await AuditAsync(departmentId, userId, row, "Complete work", origin, cancellationToken); + return row; + } + + public async Task CancelAsync(int departmentId, string userId, string assignmentId, long? expectedRowVersion, string reason, RmsOriginClient origin, CancellationToken cancellationToken = default) + { + var row = await LoadAsync(departmentId, userId, assignmentId); + if (!await CanManageAsync(departmentId, userId, await _records.GetByIdForDepartmentAsync(departmentId, row.RecordId)) && !string.Equals(row.CreatedByUserId, userId, StringComparison.OrdinalIgnoreCase)) + throw new UnauthorizedAccessException("Only the assigner, a reviewer or an administrator may cancel this assignment."); + if (!row.IsOpen) throw new InvalidOperationException("The assignment is already closed."); + Guard(row, expectedRowVersion); + row.State = (int)RmsWorkAssignmentState.Cancelled; + row.CancelledOn = DateTime.UtcNow; + row.CancelledByUserId = userId; + row.CancelReason = Trim(reason, 500); + await SaveAsync(row, userId, cancellationToken); + await AuditAsync(departmentId, userId, row, "Cancel work", origin, cancellationToken, new { reason = row.CancelReason }); + return row; + } + + public async Task GetAsync(int departmentId, string userId, string assignmentId) + { + if (string.IsNullOrWhiteSpace(assignmentId)) return null; + var row = await _assignments.GetByIdForDepartmentAsync(departmentId, assignmentId); + if (row == null || row.DeletedOn.HasValue) return null; + return await _authorization.CanUserViewRecordAsync(userId, row.RecordId, departmentId) ? row : null; + } + + public async Task> GetForRecordAsync(int departmentId, string userId, string recordId) + { + if (string.IsNullOrWhiteSpace(recordId) || !await _authorization.CanUserViewRecordAsync(userId, recordId, departmentId)) return new List(); + return (await _assignments.GetForRecordAsync(departmentId, recordId))?.Where(a => !a.DeletedOn.HasValue).OrderBy(a => a.State).ThenBy(a => a.DueOn ?? DateTime.MaxValue).ThenBy(a => a.CreatedOn).ToList() ?? new List(); + } + + public async Task> GetQueueAsync(int departmentId, string userId, FieldRecordContext context, int take) + { + take = Math.Max(1, Math.Min(RecordsFieldConfig.AssignmentsMax, take <= 0 ? RecordsFieldConfig.AssignmentsMax : take)); + var addressees = await AddresseesAsync(departmentId, userId, context ?? new FieldRecordContext()); + var rows = (await _assignments.GetOpenForAssigneesAsync(departmentId, userId, addressees.UnitIds, addressees.GroupIds, addressees.Roles, take * 2))?.Where(a => !a.DeletedOn.HasValue).ToList() ?? new List(); + var visible = new List(); + foreach (var row in rows) + { + // The queue narrows; live authorization decides. A row the caller cannot read is withheld, not tombstoned here. + if (await _authorization.CanUserViewRecordAsync(userId, row.RecordId, departmentId)) + visible.Add(row); + if (visible.Count >= take) break; + } + return visible; + } + + public async Task IsAssigneeAsync(int departmentId, string userId, RmsRecordWorkAssignment assignment, FieldRecordContext context) + { + if (assignment == null || string.IsNullOrWhiteSpace(userId)) return false; + switch ((RmsWorkAssigneeKind)assignment.AssigneeKind) + { + case RmsWorkAssigneeKind.Person: + return string.Equals(assignment.AssigneeUserId, userId, StringComparison.OrdinalIgnoreCase); + case RmsWorkAssigneeKind.Unit: + return assignment.AssigneeUnitId.HasValue && await IsStaffedOnUnitAsync(departmentId, userId, assignment.AssigneeUnitId.Value); + case RmsWorkAssigneeKind.Group: + var group = await _groups.GetGroupForUserAsync(userId, departmentId); + return group != null && assignment.AssigneeGroupId == group.DepartmentGroupId; + case RmsWorkAssigneeKind.CommandRole: + return await HoldsCommandRoleAsync(departmentId, userId, assignment, context); + case RmsWorkAssigneeKind.DispatchRole: + return await _authorization.CanCreateSourceCallAsync(userId, departmentId); + default: + return false; + } + } + + /// The caller is staffed on the unit when the unit's latest state lists them in a role. + public async Task IsStaffedOnUnitAsync(int departmentId, string userId, int unitId) + { + var unit = await _units.GetUnitByIdAsync(unitId); + if (unit == null || unit.DepartmentId != departmentId) return false; + var state = await _units.GetLastUnitStateByUnitIdAsync(unitId); + return state?.Roles != null && state.Roles.Any(r => string.Equals(r.UserId, userId, StringComparison.OrdinalIgnoreCase)); + } + + private async Task HoldsCommandRoleAsync(int departmentId, string userId, RmsRecordWorkAssignment assignment, FieldRecordContext context) + { + var record = await _records.GetByIdForDepartmentAsync(departmentId, assignment.RecordId); + var callId = record?.CallId ?? context?.CallId; + if (!callId.HasValue) return false; + var command = await _command.GetActiveCommandForCallAsync(departmentId, callId.Value); + if (command == null) return false; + if (string.Equals(command.CurrentCommanderUserId, userId, StringComparison.OrdinalIgnoreCase)) return true; + var board = await _command.GetCommandBoardAsync(departmentId, callId.Value); + return board?.Nodes != null && board.Nodes.Any(n => string.Equals(n.SupervisorUserId, userId, StringComparison.OrdinalIgnoreCase) + && (string.IsNullOrWhiteSpace(assignment.AssigneeRole) || string.Equals(n.Name, assignment.AssigneeRole, StringComparison.OrdinalIgnoreCase))); + } + + private async Task<(List UnitIds, List GroupIds, List Roles)> AddresseesAsync(int departmentId, string userId, FieldRecordContext context) + { + var unitIds = new List(); + var groupIds = new List(); + var roles = new List(); + if (context.UnitId.HasValue && await IsStaffedOnUnitAsync(departmentId, userId, context.UnitId.Value)) unitIds.Add(context.UnitId.Value); + var group = await _groups.GetGroupForUserAsync(userId, departmentId); + if (group != null) groupIds.Add(group.DepartmentGroupId); + if (context.CallId.HasValue) + { + var command = await _command.GetActiveCommandForCallAsync(departmentId, context.CallId.Value); + if (command != null) + { + var board = await _command.GetCommandBoardAsync(departmentId, context.CallId.Value); + if (string.Equals(command.CurrentCommanderUserId, userId, StringComparison.OrdinalIgnoreCase)) roles.Add("Command"); + if (board?.Nodes != null) roles.AddRange(board.Nodes.Where(n => string.Equals(n.SupervisorUserId, userId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(n.Name)).Select(n => n.Name)); + } + } + if (await _authorization.CanCreateSourceCallAsync(userId, departmentId)) roles.Add("Dispatch"); + return (unitIds, groupIds.Distinct().ToList(), roles.Distinct(StringComparer.OrdinalIgnoreCase).ToList()); + } + + private async Task CanManageAsync(int departmentId, string userId, RmsOperationalRecord record) + { + if (record != null && string.Equals(record.OwnerUserId, userId, StringComparison.OrdinalIgnoreCase)) return true; + if (await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ReviewRecords)) return true; + return await _authorization.IsDepartmentAdminAsync(userId, departmentId); + } + + private async Task LoadAsync(int departmentId, string userId, string assignmentId) + { + var row = await GetAsync(departmentId, userId, assignmentId); + if (row == null) throw new ArgumentException("The assignment was not found.", nameof(assignmentId)); + return row; + } + + private static void Guard(RmsRecordWorkAssignment row, long? expectedRowVersion) + { + if (expectedRowVersion.HasValue && expectedRowVersion.Value != row.RowVersion) + throw new RecordConcurrencyException(row.RecordId, expectedRowVersion.Value, row.RowVersion); + } + + private async Task SaveAsync(RmsRecordWorkAssignment row, string userId, CancellationToken cancellationToken) + { + row.RowVersion += 1; + row.ModifiedOn = DateTime.UtcNow; + row.ModifiedByUserId = userId; + await _assignments.UpdateAsync(row, cancellationToken, true); + } + + private Task AuditAsync(int departmentId, string userId, RmsRecordWorkAssignment row, string purpose, RmsOriginClient origin, CancellationToken cancellationToken, object detail = null) + { + return _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, RecordId = row.RecordId, Action = (int)RmsAccessAuditAction.Admin, ActorUserId = userId, Purpose = purpose, OriginClient = (int)origin, Successful = true, OccurredOn = DateTime.UtcNow, + DetailJson = JsonConvert.SerializeObject(new { assignment_id = row.RmsRecordWorkAssignmentId, state = ((RmsWorkAssignmentState)row.State).ToString(), origin_client = origin.ToString(), detail }) + }, cancellationToken, true); + } + + private static string Trim(string value, int max) => string.IsNullOrWhiteSpace(value) ? null : (value.Trim().Length > max ? value.Trim().Substring(0, max) : value.Trim()); + } +} diff --git a/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs b/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs new file mode 100644 index 00000000..c442d9e3 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Bulk packets and bulk assign-for-review (RMS plan section 4.7). A packet is compiled from each record's own + /// document rendering (pinned revision, provenance footer, layout) so it never becomes a second renderer; the result + /// is an RmsExportRun like any other department export (30-day retention, ADP-sealed bytes, per-record Export audit) + /// and optionally rides the scheduled-report email path. + /// + public class RecordsBulkPacketService : IRecordsBulkPacketService + { + public const string PacketTemplateKey = "bulk-packet"; + public const int RunRetentionDays = 30; + + private readonly IRecordsDocumentService _documents; + private readonly IRecordsService _records; + private readonly IRmsOperationalRecordsRepository _recordRows; + private readonly IRmsExportRunsRepository _runs; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsProtectionService _protection; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IDepartmentsService _departments; + private readonly IEmailService _email; + private readonly IPdfProvider _pdf; + private readonly IUnitOfWork _unitOfWork; + + public RecordsBulkPacketService(IRecordsDocumentService documents, IRecordsService records, IRmsOperationalRecordsRepository recordRows, IRmsExportRunsRepository runs, + IRecordsAuthorizationService authorization, IRecordsProtectionService protection, IRmsAccessAuditsRepository audits, IDepartmentsService departments, + IEmailService email, IPdfProvider pdf, IUnitOfWork unitOfWork) + { + _documents = documents; + _records = records; + _recordRows = recordRows; + _runs = runs; + _authorization = authorization; + _protection = protection; + _audits = audits; + _departments = departments; + _email = email; + _pdf = pdf; + _unitOfWork = unitOfWork; + } + + public async Task BuildPacketAsync(int departmentId, string userId, RecordsBulkPacketRequest request, CancellationToken cancellationToken = default) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentException("An acting user is required.", nameof(userId)); + var ids = (request.RecordIds ?? new List()).Where(id => !string.IsNullOrWhiteSpace(id)).Select(id => id.Trim()).Distinct(StringComparer.Ordinal).ToList(); + if (ids.Count == 0) throw new ArgumentException("Select at least one record.", nameof(request)); + if (ids.Count > RecordsBulkPacketRequest.MaxRecords) throw new ArgumentException($"A packet compiles at most {RecordsBulkPacketRequest.MaxRecords} records; narrow the selection.", nameof(request)); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ExportRecords)) + throw new UnauthorizedAccessException("Bulk packets require the ExportRecords permission."); + if (!string.IsNullOrWhiteSpace(request.DeliverToEmail) && !IsPlausibleEmail(request.DeliverToEmail)) + throw new ArgumentException("The delivery address is not a valid email address.", nameof(request)); + + var department = await _departments.GetDepartmentByIdAsync(departmentId, false); + var rows = (await _recordRows.GetByIdsAsync(departmentId, ids))?.ToDictionary(r => r.RmsOperationalRecordId, StringComparer.Ordinal) ?? new Dictionary(); + var result = new RecordsBulkResult(); + var entries = new List<(RmsOperationalRecord Record, RecordDocument Document, string Html)>(); + + foreach (var id in ids) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!rows.TryGetValue(id, out var record) || record.DeletedOn.HasValue || record.PurgedOn.HasValue) { Skip(result, id, "not_found"); continue; } + if (!await _authorization.CanUserViewRecordAsync(userId, id, departmentId)) { Skip(result, id, "not_visible"); continue; } + if (string.IsNullOrWhiteSpace(record.CurrentRevisionId)) { Skip(result, id, "no_revision"); continue; } + RecordDocument document; + try { document = await _documents.GetAsync(departmentId, userId, id, RmsRecordKind.Operational, record.CurrentRevisionId, exporting: true); } + catch (UnauthorizedAccessException) { Skip(result, id, "not_authorized"); continue; } + if (document == null) { Skip(result, id, "no_revision"); continue; } + entries.Add((record, document, await _documents.RenderHtmlAsync(departmentId, userId, document))); + } + + if (entries.Count == 0) + throw new InvalidOperationException("Nothing in the selection could be compiled: " + string.Join(", ", result.Skips.Select(s => s.RecordId + " (" + s.Reason + ")"))); + + var now = DateTime.UtcNow; + var title = string.IsNullOrWhiteSpace(request.Title) ? "Records packet" : request.Title.Trim(); + byte[] bytes; + string fileName, contentType; + if (request.Mode == RecordsBulkPacketMode.Bundle) + { + bytes = BuildBundle(entries, title, department, now, userId); + fileName = SafeFileName(title) + "-" + now.ToString("yyyyMMdd-HHmm") + ".zip"; + contentType = "application/zip"; + } + else + { + bytes = _pdf.ConvertHtmlToPdf(CompiledHtml(entries, title, department, now, userId), "Letter"); + if (bytes == null || bytes.Length < 4) throw new InvalidOperationException("The PDF provider did not produce a document."); + fileName = SafeFileName(title) + "-" + now.ToString("yyyyMMdd-HHmm") + ".pdf"; + contentType = "application/pdf"; + } + + var run = new RmsExportRun + { + RmsExportRunId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), + TemplateId = PacketTemplateKey, TemplateKey = PacketTemplateKey, Trigger = (int)RmsExportTrigger.Bulk, + RecordCount = entries.Count, FileName = fileName, ContentType = contentType, ByteSize = bytes.LongLength, + Checksum = RecordSnapshotSerializer.Checksum(bytes), Data = bytes, + Redacted = entries.Any(e => e.Document.WithheldFields.Count > 0), + RedactedFieldsJson = entries.Any(e => e.Document.WithheldFields.Count > 0) ? JsonConvert.SerializeObject(new { withheld_fields = entries.SelectMany(e => e.Document.WithheldFields).Distinct().OrderBy(f => f).ToList() }) : null, + GeneratedOn = now, GeneratedByUserId = userId, ExpiresOn = now.AddDays(RunRetentionDays) + }; + + // Stored bytes are sealed under ADP (RmsExportRuns.Data); the caller keeps the plaintext run for the download. + var stored = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(run)); + _unitOfWork.CreateOrGetConnection(); + try + { + await _protection.ProtectExportRunAsync(departmentId, stored, userId, cancellationToken); + await _runs.InsertAsync(stored, cancellationToken, true); + foreach (var entry in entries) + await _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, RecordId = entry.Record.RmsOperationalRecordId, RevisionId = entry.Document.RevisionId, Action = (int)RmsAccessAuditAction.Export, ActorUserId = userId, + Purpose = string.IsNullOrWhiteSpace(request.Purpose) ? "Bulk packet " + title : request.Purpose.Trim(), OriginClient = (int)request.OriginClient, Successful = true, OccurredOn = now, + DetailJson = JsonConvert.SerializeObject(new { run.RmsExportRunId, run.Checksum, mode = request.Mode.ToString(), records = entries.Count }) + }, cancellationToken, true); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + + run.IsProtected = stored.IsProtected; + run.ProtectedCatalogVersion = stored.ProtectedCatalogVersion; + result.Processed = entries.Count; + result.Run = run; + + if (!string.IsNullOrWhiteSpace(request.DeliverToEmail)) + { + // Same path the scheduled PDF reports take (ReportDeliveryLogic): one attachment, department-branded mail. + result.Delivered = await _email.SendReportDeliveryEmail(new EmailNotification + { + To = request.DeliverToEmail.Trim(), + Subject = $"Resgrid Records packet: {title} ({entries.Count} record(s))", + Body = $"The Records packet \"{title}\" compiled {entries.Count} record(s) for {department?.Name}. Checksum {run.Checksum}. The packet stays downloadable for {RunRetentionDays} days.", + AttachmentName = fileName, + AttachmentData = bytes + }); + } + return result; + } + + public async Task AssignForReviewAsync(int departmentId, string userId, RecordsBulkAssignRequest request, CancellationToken cancellationToken = default) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.ReviewerUserId)) throw new ArgumentException("A reviewer is required.", nameof(request)); + var ids = (request.RecordIds ?? new List()).Where(id => !string.IsNullOrWhiteSpace(id)).Select(id => id.Trim()).Distinct(StringComparer.Ordinal).ToList(); + if (ids.Count == 0) throw new ArgumentException("Select at least one record.", nameof(request)); + if (ids.Count > RecordsBulkPacketRequest.MaxRecords) throw new ArgumentException($"Assign at most {RecordsBulkPacketRequest.MaxRecords} records at a time.", nameof(request)); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ReviewRecords)) + throw new UnauthorizedAccessException("Bulk assign-for-review requires the ReviewRecords permission."); + if (!await _authorization.IsActiveMemberAsync(request.ReviewerUserId, departmentId)) + throw new ArgumentException("The reviewer is not an active member of this department.", nameof(request)); + + var result = new RecordsBulkResult(); + foreach (var id in ids) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await _records.AssignReviewerAsync(departmentId, userId, id, request.ReviewerUserId, request.Reason, cancellationToken); + result.Processed++; + } + catch (RecordTransitionException) { Skip(result, id, "not_awaiting_review"); } + catch (UnauthorizedAccessException) { Skip(result, id, "not_visible"); } + catch (ArgumentException) { Skip(result, id, "not_found"); } + } + return result; + } + + public async Task GetPacketAsync(int departmentId, string userId, string runId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(runId)) return null; + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ExportRecords)) + throw new UnauthorizedAccessException("Bulk packets require the ExportRecords permission."); + var run = await _runs.GetWithDataAsync(departmentId, runId); + if (run == null || run.DeletedOn.HasValue || run.ExpiresOn <= DateTime.UtcNow || !string.Equals(run.TemplateKey, PacketTemplateKey, StringComparison.Ordinal)) + return null; + (await _protection.RevealExportRunsAsync(departmentId, new[] { run }, true, cancellationToken)).RequireRevealed("packet download"); + await _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, Action = (int)RmsAccessAuditAction.Export, ActorUserId = userId, Purpose = "Bulk packet download", OriginClient = (int)RmsOriginClient.Web, + Successful = true, OccurredOn = DateTime.UtcNow, DetailJson = JsonConvert.SerializeObject(new { run.RmsExportRunId, run.Checksum, run.RecordCount }) + }, cancellationToken); + return run; + } + + private static void Skip(RecordsBulkResult result, string recordId, string reason) + { + result.Skipped++; + result.Skips.Add(new RecordsBulkSkip { RecordId = recordId, Reason = reason }); + } + + private static bool IsPlausibleEmail(string value) + { + try { return new System.Net.Mail.MailAddress(value.Trim()).Address.Length > 0; } catch (FormatException) { return false; } + } + + internal static string CompiledHtml(List<(RmsOperationalRecord Record, RecordDocument Document, string Html)> entries, string title, Department department, DateTime now, string userId) + { + var html = new StringBuilder("").Append(E(title)).Append(""); + html.Append("

").Append(E(title)).Append("

").Append(E(department?.Name ?? string.Empty)).Append(" · compiled ").Append(E(now.ToString("u"))).Append(" · ").Append(entries.Count).Append(" record(s)

"); + html.Append("

Manifest

"); + for (var i = 0; i < entries.Count; i++) + { + var (record, document, _) = entries[i]; + html.Append(""); + } + html.Append("
#RecordDefinitionRevisionFinalizedChecksum
").Append(i + 1).Append("").Append(E(document.RecordNumber)).Append("").Append(E(record.DefinitionKey)).Append(" v").Append(record.DefinitionVersion) + .Append("").Append(document.RevisionNumber).Append("").Append(E(document.FinalizedOn.ToString("u"))).Append("").Append(E(document.OriginalChecksum)).Append("

Bulk packet · every record renders from its pinned revision with its own provenance footer; the manifest checksums are the revision checksums.

"); + for (var i = 0; i < entries.Count; i++) + { + html.Append("

Packet item ").Append(i + 1).Append(" of ").Append(entries.Count).Append(" · ").Append(E(entries[i].Document.RecordNumber)).Append("

"); + html.Append(BodyOf(entries[i].Html)).Append("
"); + } + html.Append(""); + return html.ToString(); + } + + private byte[] BuildBundle(List<(RmsOperationalRecord Record, RecordDocument Document, string Html)> entries, string title, Department department, DateTime now, string userId) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var manifest = new List(); + for (var i = 0; i < entries.Count; i++) + { + var (record, document, html) = entries[i]; + var pdf = _pdf.ConvertHtmlToPdf(html, "Letter"); + if (pdf == null || pdf.Length < 4) throw new InvalidOperationException("The PDF provider did not produce a document."); + var name = (i + 1).ToString("D3") + "-" + SafeFileName(document.RecordNumber ?? record.RmsOperationalRecordId) + ".pdf"; + var entry = archive.CreateEntry(name, CompressionLevel.Optimal); + using (var target = entry.Open()) target.Write(pdf, 0, pdf.Length); + manifest.Add(new { index = i + 1, file = name, record_number = document.RecordNumber, definition_key = record.DefinitionKey, definition_version = record.DefinitionVersion, revision = document.RevisionNumber, finalized_on = document.FinalizedOn, checksum = document.OriginalChecksum, pdf_checksum = RecordSnapshotSerializer.Checksum(pdf) }); + } + var manifestEntry = archive.CreateEntry("manifest.json", CompressionLevel.Optimal); + using var writer = new StreamWriter(manifestEntry.Open(), new UTF8Encoding(false)); + writer.Write(JsonConvert.SerializeObject(new { title, department = department?.Name, compiled_on = now, compiled_by = userId, records = manifest }, Formatting.Indented)); + } + return stream.ToArray(); + } + + /// The inner body of a rendered record document, so the packet keeps one html/head. + internal static string BodyOf(string html) + { + if (string.IsNullOrEmpty(html)) return string.Empty; + var start = html.IndexOf("', start); + var end = html.LastIndexOf("", StringComparison.OrdinalIgnoreCase); + return start < 0 || end < 0 || end <= start ? html : html.Substring(start + 1, end - start - 1); + } + + private static string SafeFileName(string value) + { + var safe = new string((value ?? "packet").Select(c => char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '-').ToArray()).Trim('-'); + return string.IsNullOrEmpty(safe) ? "packet" : (safe.Length > 60 ? safe.Substring(0, 60) : safe); + } + + private static string E(string value) => WebUtility.HtmlEncode(value ?? string.Empty); + } +} diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs index dd60c046..66daee53 100644 --- a/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs @@ -77,6 +77,9 @@ async Task Add(string id, string number, string definition, string revisionId, J if (r.CurrentRevisionId != null) { var parentRevision = await _revisions.GetByIdForDepartmentAsync(departmentId, r.CurrentRevisionId); + // A report pointing at a revision that is gone is an integrity failure, not a null reference: + // fail it the way Add does instead of dereferencing the missing row below. + if (parentRevision == null) throw new InvalidOperationException("A disclosure source failed its revision integrity check."); (await _protection.RevealRevisionsAsync(departmentId, new[] { parentRevision })).RequireRevealed("disclosure scope"); parentHeader = (JObject)JObject.Parse(parentRevision.SnapshotJson)["Report"] ?? parentHeader; } diff --git a/Core/Resgrid.Services/Records/RecordsDocumentService.cs b/Core/Resgrid.Services/Records/RecordsDocumentService.cs index 5e0d4582..b3e23004 100644 --- a/Core/Resgrid.Services/Records/RecordsDocumentService.cs +++ b/Core/Resgrid.Services/Records/RecordsDocumentService.cs @@ -26,6 +26,7 @@ public sealed class RecordsDocumentService : IRecordsDocumentService private readonly IIncidentReportsService _incidents; private readonly IDepartmentProfileMediaService _branding; private readonly IRecordsPrintLayoutService _layouts; + private readonly IRecordDefinitionsService _definitions; private readonly IPdfProvider _pdf; private readonly IRecordsEvidenceService _evidence; private readonly IRecordsUdfService _udf; @@ -33,8 +34,9 @@ public sealed class RecordsDocumentService : IRecordsDocumentService public RecordsDocumentService(IRecordsAuthorizationService authorization, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository reports, IRmsIncidentAnalysesRepository analyses, IRmsRevisionsRepository revisions, IIncidentReportsService incidents, IDepartmentProfileMediaService branding, IRecordsPrintLayoutService layouts, IPdfProvider pdf, IRecordsEvidenceService evidence, IRecordsUdfService udf, - IRecordsProtectionService protection) + IRecordsProtectionService protection, IRecordDefinitionsService definitions) { + _definitions = definitions; _protection = protection; _authorization = authorization; _records = records; _reports = reports; _analyses = analyses; _revisions = revisions; _incidents = incidents; _branding = branding; _layouts = layouts; _pdf = pdf; _evidence = evidence; _udf = udf; } public async Task GetAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, string revisionId = null, bool exporting = false) @@ -97,6 +99,8 @@ public static void Project(JObject content, bool restricted, List withhe foreach (var field in ((content["CustomFields"] as JObject)?["Fields"] as JArray ?? new JArray()).OfType().ToList()) if ((int?)(field["Field"] as JObject)?["RmsClassification"] != 0) { withheld.Add(field.Path); field.Remove(); } foreach (var field in RecordSnapshotSerializer.RestrictedDetailFields) Hide(content["Details"] as JObject, field); + // Department-definition values (RMS-1B): a restricted cell is keyed with the restricted suffix by the snapshot. + foreach (var property in (content["Values"] as JObject ?? new JObject()).Descendants().OfType().Where(p => p.Name.EndsWith(RecordSnapshotSerializer.RestrictedValueSuffix, StringComparison.Ordinal)).ToList()) { withheld.Add(property.Path); property.Remove(); } foreach (var casualty in (content["Casualties"] as JArray ?? new JArray()).OfType()) foreach (var field in new[] { "PersonnelUserId", "Rank", "BirthMonthYear", "Gender", "Race", "InjuryDetailJson", "DetailJson" }) Hide(casualty, field); foreach (var vehicle in (content["Vehicles"] as JArray ?? new JArray()).OfType()) @@ -117,7 +121,9 @@ public async Task RenderHtmlAsync(int departmentId, string userId, Recor if (current == null || current.ContentChecksum != document.ContentChecksum) throw new UnauthorizedAccessException("Record access or content changed; reload the revision."); document = current; var branding = await _branding.GetBrandingAsync(departmentId); - var layout = await _layouts.GetDepartmentDefaultAsync(departmentId); var config = layout?.Config ?? RecordsPrintLayoutConfig.Default(); + var content = JObject.Parse(document.ContentJson); + var resolved = await ResolveLayoutAsync(departmentId, content); + var config = resolved.Layout.Branding ?? RecordsPrintLayoutConfig.Default(); var html = new StringBuilder("Department record"); if (config.ShowLogo && branding?.HasLogo == true) { @@ -130,15 +136,121 @@ public async Task RenderHtmlAsync(int departmentId, string userId, Recor html.Append("

Complete department record ").Append(E(document.RecordNumber)).Append(" — revision ").Append(document.RevisionNumber).Append("

Saved ").Append(E(document.FinalizedOn.ToString("u"))).Append(" · Attested by ").Append(E(document.AttestedBy)).Append(" · Statement ").Append(E(document.AttestationVersion)).Append("

"); if (document.WithheldFields.Count > 0) html.Append("

Some fields are withheld under your current access permissions.

"); if (!string.IsNullOrWhiteSpace(config.WatermarkLabel)) html.Append("

").Append(E(config.WatermarkLabel)).Append("

"); - RenderSections(html, JObject.Parse(document.ContentJson)); - html.Append("
").Append(E(config.FooterText)).Append("

Revision ").Append(E(document.RevisionId)).Append(" · Original checksum ").Append(E(document.OriginalChecksum)).Append("

Copy checksum ").Append(E(document.ContentChecksum)).Append(" · Layout ").Append(E(layout?.LayoutVersion)).Append(" · Printed by ").Append(E(userId)).Append(" at ").Append(DateTime.UtcNow.ToString("u")).Append("

"); + if (resolved.Layout.Definition != null && resolved.Schema != null) + { + // Definition layout (plan 4.10.1): the definition's own sections render first in layout order; the rest of the + // aggregate (participants, units, evidence, attachments) follows the generated order. + var values = content["Values"] as JObject; + content.Remove("Values"); + RenderValuesWithLayout(html, values, resolved.Schema, resolved.Layout.Definition); + var attachments = content["Attachments"] as JArray; + if (attachments != null && resolved.Layout.Definition.AttachmentListStyle != RecordsDefinitionLayoutConfig.AttachmentsTable) + { + content.Remove("Attachments"); + if (resolved.Layout.Definition.AttachmentListStyle == RecordsDefinitionLayoutConfig.AttachmentsList && attachments.Count > 0) + { + html.Append("

Attachments

    "); + foreach (var attachment in attachments.OfType()) + html.Append("
  • ").Append(E((string)attachment["FileName"] ?? "attachment")).Append(attachment["ByteSize"] != null ? " (" + attachment["ByteSize"] + " bytes)" : string.Empty).Append("
  • "); + html.Append("
"); + } + } + } + RenderSections(html, content); + html.Append("
").Append(E(config.FooterText)).Append("

Layout ").Append(E(resolved.Layout.LayoutVersion)).Append("

").Append("

Revision ").Append(E(document.RevisionId)).Append(" · Original checksum ").Append(E(document.OriginalChecksum)).Append("

Copy checksum ").Append(E(document.ContentChecksum)).Append(" · Printed by ").Append(E(userId)).Append(" at ").Append(DateTime.UtcNow.ToString("u")).Append("

"); await RequireCurrentDocumentAsync(departmentId, userId, document); return html.ToString(); } + /// Definition layout resolution for a department-definition Record; locked definitions resolve to the department default alone. + private async Task<(RecordsResolvedPrintLayout Layout, RecordDefinitionSchema Schema)> ResolveLayoutAsync(int departmentId, JObject content) + { + var definitionKey = (string)content["DefinitionKey"]; + var definitionVersion = (int?)content["DefinitionVersion"] ?? 0; + var isDepartmentDefinition = content["RecordType"] == null || content["RecordType"].Type == JTokenType.Null; + if (!isDepartmentDefinition || string.IsNullOrWhiteSpace(definitionKey) || RmsDefinitionKeys.LockedTypes.ContainsKey(definitionKey)) + { + var department = await _layouts.GetDepartmentDefaultAsync(departmentId); + return (new RecordsResolvedPrintLayout { Branding = department?.Config ?? RecordsPrintLayoutConfig.Default(), BrandingLayoutVersion = department?.LayoutVersion ?? RmsRecordPrintLayout.GeneratedLayoutVersion }, null); + } + var resolved = await _layouts.ResolveForDefinitionAsync(departmentId, definitionKey, definitionVersion); + RecordDefinitionSchema schema = null; + if (resolved.Definition != null) + schema = (await _definitions.GetVersionAsync(departmentId, definitionKey, definitionVersion))?.Schema; + return (resolved, schema); + } + + /// + /// The snapshot's Values block ({section label: {field label: display}} or rows) rendered per the definition layout: + /// section order and visibility, headings, page breaks, hidden fields, signature placement. Labels map back to the + /// pinned version's schema; a value whose field the schema no longer names prints under its stored label. + /// + public static void RenderValuesWithLayout(StringBuilder html, JObject values, RecordDefinitionSchema schema, RecordsDefinitionLayoutConfig layout) + { + if (values == null) return; + var signatures = new List<(string Label, string Value)>(); + foreach (var sectionKey in layout.OrderedSectionKeys(schema.Sections.Select(s => s.Key))) + { + var section = schema.FindSection(sectionKey); + if (section == null) continue; + var block = values.Properties().FirstOrDefault(p => string.Equals(p.Name, section.Label ?? section.Key, StringComparison.Ordinal) || string.Equals(p.Name, section.Key, StringComparison.OrdinalIgnoreCase))?.Value; + if (block == null || block.Type == JTokenType.Null) continue; + var heading = layout.HeadingFor(section.Key, section.Label ?? section.Key); + var rows = block is JArray array ? array.OfType().ToList() : new List { block as JObject ?? new JObject() }; + var printable = new List>(); + foreach (var row in rows) + { + var cells = new List<(string Label, string Value)>(); + foreach (var property in row.Properties()) + { + var label = property.Name.EndsWith(RecordSnapshotSerializer.RestrictedValueSuffix, StringComparison.Ordinal) ? property.Name.Substring(0, property.Name.Length - RecordSnapshotSerializer.RestrictedValueSuffix.Length) : property.Name; + var field = section.Fields.FirstOrDefault(f => string.Equals(f.Label ?? f.Key, label, StringComparison.Ordinal)) ?? section.Fields.FirstOrDefault(f => string.Equals(f.Key, label, StringComparison.OrdinalIgnoreCase)); + if (field != null && !layout.IsFieldVisible(field.Key)) continue; + var text = property.Value?.Type == JTokenType.Null ? null : property.Value?.ToString(); + if (string.IsNullOrWhiteSpace(text)) continue; + if (field?.Type == RmsFieldType.Signature && layout.SignatureBlockPlacement != RecordsDefinitionLayoutConfig.SignatureInline) + { + if (layout.SignatureBlockPlacement == RecordsDefinitionLayoutConfig.SignatureAtEnd) signatures.Add((heading + " / " + label, text)); + continue; + } + cells.Add((label, text)); + } + if (cells.Count > 0) printable.Add(cells); + } + if (printable.Count == 0) continue; + html.Append(layout.PageBreakBefore(section.Key) ? "

" : "

").Append(E(heading)).Append("

"); + if (block is JArray) + { + var columns = printable.SelectMany(r => r.Select(c => c.Label)).Distinct(StringComparer.Ordinal).ToList(); + html.Append(""); + foreach (var column in columns) html.Append(""); + html.Append(""); + for (var i = 0; i < printable.Count; i++) + { + html.Append(""); + foreach (var column in columns) html.Append(""); + html.Append(""); + } + html.Append("
#").Append(E(column)).Append("
").Append(i + 1).Append("").Append(E(printable[i].FirstOrDefault(c => c.Label == column).Value ?? string.Empty)).Append("
"); + } + else + { + html.Append(""); + foreach (var cell in printable[0]) html.Append(""); + html.Append("
").Append(E(cell.Label)).Append("").Append(E(cell.Value)).Append("
"); + } + } + if (signatures.Count > 0) + { + html.Append("

Signatures

"); + foreach (var signature in signatures) html.Append(""); + html.Append("
").Append(E(signature.Label)).Append("").Append(E(signature.Value)).Append("
"); + } + } + public async Task RenderPdfAsync(int departmentId, string userId, RecordDocument document) { if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ExportRecords)) throw new UnauthorizedAccessException(); - var pageSize = (await _layouts.GetDepartmentDefaultAsync(departmentId))?.Config?.PageSize; + var pageSize = (await ResolveLayoutAsync(departmentId, JObject.Parse(document.ContentJson))).Layout.Branding?.PageSize; var bytes = _pdf.ConvertHtmlToPdf(await RenderHtmlAsync(departmentId, userId, document), RecordsPrintLayoutConfig.NormalizePageSize(pageSize)); await RequireCurrentDocumentAsync(departmentId, userId, document, true); return bytes; diff --git a/Core/Resgrid.Services/Records/RecordsExportService.cs b/Core/Resgrid.Services/Records/RecordsExportService.cs index 4f5ba107..8fd46998 100644 --- a/Core/Resgrid.Services/Records/RecordsExportService.cs +++ b/Core/Resgrid.Services/Records/RecordsExportService.cs @@ -85,13 +85,27 @@ public Task GetTemplateByKeyAsync(int departmentId, string te public async Task ValidateAsync(int departmentId, string userId, RmsExportTemplate template) { - var result = new RecordsExportTemplateValidation(); if (template == null) { - result.Errors.Add("A template is required."); - return result; + var empty = new RecordsExportTemplateValidation(); + empty.Errors.Add("A template is required."); + return empty; } + return await ValidateCoreAsync(departmentId, userId, template, StoredColumns(string.IsNullOrWhiteSpace(template.RmsExportTemplateId) + ? null + : await _templates.GetByIdForDepartmentAsync(departmentId, template.RmsExportTemplateId))); + } + + /// + /// is the column set the stored template carries. SaveAsync passes the + /// snapshot it took before overwriting the row (target IS existing on an update, so re-reading afterwards + /// would compare the incoming set against itself). + /// + private async Task ValidateCoreAsync(int departmentId, string userId, RmsExportTemplate template, List storedColumns) + { + var result = new RecordsExportTemplateValidation(); + if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Trim().Length > 200) result.Errors.Add("Give the export a name of up to 200 characters."); var key = (template.TemplateKey ?? string.Empty).Trim().ToLowerInvariant(); @@ -165,7 +179,13 @@ public async Task ValidateAsync(int departmentI result.Errors.Add("Restricted columns need 'Include restricted sections' switched on."); if ((needsNarrative || needsRestricted) && !template.EgressAcknowledgedOn.HasValue) result.Errors.Add("Acknowledge that this export sends narrative or restricted content outside Resgrid before saving it."); - if (needsRestricted && !string.IsNullOrWhiteSpace(userId) && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) + // The grant gates CHANGING the restricted half, not merely saving a template that already carries it: + // SaveAsync carries the stored restricted columns forward for an author who cannot see them, and that + // carried-forward set must not then fail its own validation. + var storedRestricted = storedColumns.Where(IsRestrictedColumn).ToList(); + var restrictedChanged = !columns.Where(IsRestrictedColumn).OrderBy(c => c, StringComparer.Ordinal) + .SequenceEqual(storedRestricted.OrderBy(c => c, StringComparer.Ordinal), StringComparer.Ordinal); + if (needsRestricted && restrictedChanged && !string.IsNullOrWhiteSpace(userId) && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) result.Errors.Add("Only a member with the restricted-records grant can author an export that carries restricted columns."); if ((needsNarrative || needsRestricted) && await _protection.IsEnforcedAsync(departmentId)) @@ -183,6 +203,11 @@ public async Task SaveAsync(int departmentId, string userId, var now = DateTime.UtcNow; var existing = string.IsNullOrWhiteSpace(template.RmsExportTemplateId) ? null : await _templates.GetByIdForDepartmentAsync(departmentId, template.RmsExportTemplateId); var target = existing ?? new RmsExportTemplate { RmsExportTemplateId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), CreatedOn = now, CreatedByUserId = userId, RowVersion = 0 }; + // target IS existing on an update, so everything the stored row is compared against below has to be + // taken before the posted values overwrite it. + var storedColumns = StoredColumns(existing); + var storedIncludeRestricted = existing != null && existing.IncludeRestricted; + var canAuthorRestricted = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); target.TemplateKey = (template.TemplateKey ?? string.Empty).Trim().ToLowerInvariant(); target.Name = template.Name?.Trim(); @@ -190,9 +215,16 @@ public async Task SaveAsync(int departmentId, string userId, target.Format = template.Format; target.Scope = template.Scope; target.DefinitionKeysCsv = string.Join(",", SplitCsv(template.DefinitionKeysCsv)); - target.ColumnsJson = JsonConvert.SerializeObject(ParseColumns(template.ColumnsJson)); + // An author without the restricted grant never sees the restricted switches (they render disabled, so + // the browser posts nothing for them). Taking the post at face value would silently strip restricted + // columns off a template somebody else authored, so the stored restricted half is carried forward. + var postedColumns = ParseColumns(template.ColumnsJson); + if (!canAuthorRestricted) + postedColumns = postedColumns.Where(c => !IsRestrictedColumn(c)) + .Concat(storedColumns.Where(IsRestrictedColumn)).Distinct(StringComparer.Ordinal).ToList(); + target.ColumnsJson = JsonConvert.SerializeObject(postedColumns); target.IncludeNarrative = template.IncludeNarrative; - target.IncludeRestricted = template.IncludeRestricted; + target.IncludeRestricted = canAuthorRestricted ? template.IncludeRestricted : storedIncludeRestricted; target.FileNameTemplate = string.IsNullOrWhiteSpace(template.FileNameTemplate) ? null : template.FileNameTemplate.Trim(); target.IncludeHeader = template.IncludeHeader; target.Delimiter = string.IsNullOrEmpty(template.Delimiter) ? "," : template.Delimiter; @@ -207,19 +239,23 @@ public async Task SaveAsync(int departmentId, string userId, // The acknowledgement is a recorded decision by a named member; it is never carried over silently // when the content the export carries widens. - var carriesSensitive = ParseColumns(target.ColumnsJson).Select(RecordsExportFieldCatalog.Get).Any(f => f != null && f.Tier != RmsExportFieldTier.Safe); + var carriedTiers = Tiers(ParseColumns(target.ColumnsJson)); + var carriesSensitive = carriedTiers.Any(t => t != RmsExportFieldTier.Safe); + // Widened means the column set now carries a sensitive tier the acknowledged set did not: a Restricted + // column added to a template a member acknowledged for Narrative content only is a new decision. + var widened = carriedTiers.Any(t => t != RmsExportFieldTier.Safe && !Tiers(storedColumns).Contains(t)); if (acknowledgeEgress && carriesSensitive) { target.EgressAcknowledgedOn = now; target.EgressAcknowledgedByUserId = userId; } - else if (!carriesSensitive || !target.IncludeNarrative && !target.IncludeRestricted) + else if (!carriesSensitive || widened || !target.IncludeNarrative && !target.IncludeRestricted) { target.EgressAcknowledgedOn = null; target.EgressAcknowledgedByUserId = null; } - var validation = await ValidateAsync(departmentId, userId, target); + var validation = await ValidateCoreAsync(departmentId, userId, target, storedColumns); if (!validation.IsValid) throw new ArgumentException(string.Join(" ", validation.Errors)); @@ -471,7 +507,9 @@ private async Task> ResolveSourcesAsync(int departmentId, RmsExport if (wantIncident) { - var query = new RmsIncidentReportQuery { States = FinalizedStates.ToList(), Skip = 0, Take = 250 }; + // The window is applied in the query, not after paging: MaxWindowRecords caps how many rows the + // sweep will read, and out-of-window rows must not spend that budget. + var query = new RmsIncidentReportQuery { States = FinalizedStates.ToList(), FinalizedOnStart = start, FinalizedOnEnd = end, Skip = 0, Take = 250 }; for (var skip = 0; skip < MaxWindowRecords; skip += 250) { query.Skip = skip; @@ -549,10 +587,17 @@ public async Task RunDueSchedulesAsync(Cancell foreach (var template in due) { cancellationToken.ThrowIfCancellationRequested(); + var scheduledFor = template.NextRunOn ?? now; + // Claim the template before any work: the claim moves NextRunOn off the due value, so a second + // sweep reading the same row skips it instead of rendering and recording a duplicate run. The + // deferred value is the same hour a failure would use; the success path overwrites it below. + if (!await _templates.TryClaimDueAsync(template.DepartmentId, template.RmsExportTemplateId, scheduledFor, now.AddHours(1), now, cancellationToken)) + continue; + template.RowVersion += 1; result.TemplatesEvaluated++; try { - var window = ScheduleWindow(template, template.NextRunOn ?? now); + var window = ScheduleWindow(template, scheduledFor); var run = await RenderCoreAsync(template.DepartmentId, template, new RecordsExportRequest { Trigger = RmsExportTrigger.Scheduled, WindowStart = window.start, WindowEnd = window.end, Purpose = "Scheduled export " + template.Name }, true, cancellationToken); @@ -705,6 +750,15 @@ DateTime ToLocal(DateTime utc) #region Small helpers + /// The columns a stored template carries; empty for a template that does not exist yet. + private static List StoredColumns(RmsExportTemplate stored) => stored == null ? new List() : ParseColumns(stored.ColumnsJson); + + private static bool IsRestrictedColumn(string column) => RecordsExportFieldCatalog.Get(column)?.Tier == RmsExportFieldTier.Restricted; + + /// The distinct tiers a column set carries; an unknown column contributes nothing. + private static HashSet Tiers(IEnumerable columns) + => new HashSet((columns ?? Enumerable.Empty()).Select(RecordsExportFieldCatalog.Get).Where(f => f != null).Select(f => f.Tier)); + public static List ParseColumns(string json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs b/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs index b7a9a137..efc0a84f 100644 --- a/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs +++ b/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs @@ -134,7 +134,14 @@ private async Task EnqueueAsync(RmsRecordLegalHold hold, WorkflowTriggerEv }, cancellationToken); return entry.DomainEventOutboxId; } - private Task AuditAsync(RmsRecordLegalHold hold, string userId, string purpose, string reason, CancellationToken ct) => _audits.InsertAsync(new RmsAccessAudit { DepartmentId = hold.DepartmentId, RecordId = hold.RecordId, - ActorUserId = userId, Action = (int)RmsAccessAuditAction.Admin, Successful = true, OccurredOn = DateTime.UtcNow, Purpose = purpose, DetailJson = JsonConvert.SerializeObject(new { hold.RmsRecordLegalHoldId, hold.ReferenceNumber, reason }) }, ct, true); + /// + /// The audit row records WHICH hold and WHY in the terms the workflow event already publishes. It never + /// carries the cataloged text (ReferenceNumber, Notes, ReleaseNotes): RmsAccessAudits is not an ADP-bound + /// table, so copying that text here would leave the content this service just sealed sitting in the clear + /// in a second table. The detail checksum still ties the audit row to the exact text that was recorded. + /// + private Task AuditAsync(RmsRecordLegalHold hold, string userId, string purpose, string protectedDetail, CancellationToken ct) => _audits.InsertAsync(new RmsAccessAudit { DepartmentId = hold.DepartmentId, RecordId = hold.RecordId, + ActorUserId = userId, Action = (int)RmsAccessAuditAction.Admin, Successful = true, OccurredOn = DateTime.UtcNow, Purpose = purpose, + DetailJson = JsonConvert.SerializeObject(new { hold.RmsRecordLegalHoldId, hold.Reason, detail_checksum = string.IsNullOrEmpty(protectedDetail) ? null : RecordSnapshotSerializer.Checksum(protectedDetail) }) }, ct, true); } } diff --git a/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs b/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs index 4a119bb6..35d52011 100644 --- a/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs +++ b/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs @@ -32,9 +32,10 @@ public class RecordsNfirsLegacyService : IRecordsNfirsLegacyService private readonly IIncidentReportsService _incidents; private readonly IRecordsAuthorizationService _authorization; private readonly INerisProfileService _neris; + private readonly IProtectedReadService _protectedReads; public RecordsNfirsLegacyService(ICallsService calls, IUnitsService units, IIncidentReportingService reporting, IIncidentReportsService incidents, - IRecordsAuthorizationService authorization, INerisProfileService neris) + IRecordsAuthorizationService authorization, INerisProfileService neris, IProtectedReadService protectedReads) { _calls = calls; _units = units; @@ -42,6 +43,7 @@ public RecordsNfirsLegacyService(ICallsService calls, IUnitsService units, IInci _incidents = incidents; _authorization = authorization; _neris = neris; + _protectedReads = protectedReads; } public async Task RenderAsync(int departmentId, string viewerUserId, int callId) @@ -56,6 +58,12 @@ public async Task RenderAsync(int departmentId, string vie if (string.IsNullOrWhiteSpace(viewerUserId) || !await _authorization.CanReadSourceCallAsync(viewerUserId, departmentId, call)) throw new UnauthorizedAccessException("Source Call access is not authorized."); + // Calls.Name, Address, NatureOfCall, Type and IncidentNumber are cataloged columns (ADP section 5.1). + // Every value below is copied straight off the Call, so the read has to be resolved here: without a + // grant the contract hands back the REDACTED sentinel, which is what the rendering should show. The + // egress filter behind this is a net for a missed resolve, not the resolve itself. + await _protectedReads.ResolveForReadAsync(departmentId, call, null, viewerUserId); + var rendering = new NfirsLegacyRendering { DepartmentId = departmentId, diff --git a/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs b/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs index 48dd6f0e..9423ed7f 100644 --- a/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs +++ b/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; @@ -75,6 +77,97 @@ public async Task SaveDepartmentDefaultAsync(int departmen return row; } + public async Task GetDefinitionLayoutAsync(int departmentId, string definitionKey) + { + var key = (definitionKey ?? string.Empty).Trim().ToLowerInvariant(); + var row = string.IsNullOrEmpty(key) ? null : await _layouts.GetAsync(departmentId, (int)RmsRecordPrintLayoutScope.Definition, key); + if (row == null) + return new RmsRecordPrintLayout { DepartmentId = departmentId, Scope = (int)RmsRecordPrintLayoutScope.Definition, DefinitionKey = key, Version = 0, DefinitionConfig = RecordsDefinitionLayoutConfig.Default() }; + row.DefinitionConfig = ParseDefinition(row.ConfigJson); + return row; + } + + public async Task SaveDefinitionLayoutAsync(int departmentId, string userId, string definitionKey, RecordsDefinitionLayoutConfig config, CancellationToken cancellationToken = default) + { + var key = (definitionKey ?? string.Empty).Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(key)) throw new ArgumentException("A definition key is required.", nameof(definitionKey)); + config = NormalizeDefinition(config ?? RecordsDefinitionLayoutConfig.Default()); + var now = DateTime.UtcNow; + var row = await _layouts.GetAsync(departmentId, (int)RmsRecordPrintLayoutScope.Definition, key); + if (row == null) + { + row = new RmsRecordPrintLayout + { + RmsRecordPrintLayoutId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), + Scope = (int)RmsRecordPrintLayoutScope.Definition, DefinitionKey = key, Version = 1, CreatedOn = now, RowVersion = 1 + }; + } + else + { + row.Version += 1; + row.RowVersion += 1; + } + row.ConfigJson = JsonConvert.SerializeObject(config); + row.ModifiedByUserId = userId; + row.ModifiedOn = now; + row = await _layouts.SaveOrUpdateAsync(row, cancellationToken, true); + row.DefinitionConfig = config; + return row; + } + + public async Task ResolveForDefinitionAsync(int departmentId, string definitionKey, int definitionVersion) + { + var department = await GetDepartmentDefaultAsync(departmentId); + var resolved = new RecordsResolvedPrintLayout { Branding = department.Config ?? RecordsPrintLayoutConfig.Default(), BrandingLayoutVersion = department.LayoutVersion }; + if (string.IsNullOrWhiteSpace(definitionKey) || RmsDefinitionKeys.LockedTypes.ContainsKey(definitionKey)) + return resolved; + var definition = await GetDefinitionLayoutAsync(departmentId, definitionKey); + if (definition.Version <= 0 || definition.DefinitionConfig == null || !definition.DefinitionConfig.AppliesTo(definitionVersion)) + return resolved; + resolved.Definition = definition.DefinitionConfig; + resolved.DefinitionLayoutVersion = definition.LayoutVersion; + if (definition.DefinitionConfig.BrandingOverrides != null) + { + // Overrides replace the whole branding block, page size included; the provenance footer names both versions. + resolved.Branding = Normalize(definition.DefinitionConfig.BrandingOverrides); + resolved.BrandingLayoutVersion = definition.LayoutVersion + "/branding"; + } + return resolved; + } + + public static RecordsDefinitionLayoutConfig ParseDefinition(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return RecordsDefinitionLayoutConfig.Default(); + try + { + return NormalizeDefinition(JsonConvert.DeserializeObject(json) ?? RecordsDefinitionLayoutConfig.Default()); + } + catch (Exception ex) + { + Logging.LogException(ex, "Definition print layout could not be parsed; using the generated default."); + return RecordsDefinitionLayoutConfig.Default(); + } + } + + public static RecordsDefinitionLayoutConfig NormalizeDefinition(RecordsDefinitionLayoutConfig config) + { + static List Keys(IEnumerable keys) => (keys ?? Enumerable.Empty()).Where(k => !string.IsNullOrWhiteSpace(k)).Select(k => k.Trim().ToLowerInvariant()).Distinct(StringComparer.Ordinal).ToList(); + config.SectionOrder = Keys(config.SectionOrder); + config.HiddenSectionKeys = Keys(config.HiddenSectionKeys); + config.HiddenFieldKeys = Keys(config.HiddenFieldKeys); + config.PageBreakBeforeSectionKeys = Keys(config.PageBreakBeforeSectionKeys); + config.SectionHeadings = (config.SectionHeadings ?? new Dictionary()).Where(p => !string.IsNullOrWhiteSpace(p.Key) && !string.IsNullOrWhiteSpace(p.Value)) + .ToDictionary(p => p.Key.Trim().ToLowerInvariant(), p => Trim(p.Value, 120), StringComparer.OrdinalIgnoreCase); + config.SignatureBlockPlacement = RecordsDefinitionLayoutConfig.SignaturePlacements.Contains((config.SignatureBlockPlacement ?? string.Empty).Trim().ToLowerInvariant()) + ? config.SignatureBlockPlacement.Trim().ToLowerInvariant() : RecordsDefinitionLayoutConfig.SignatureAtEnd; + config.AttachmentListStyle = RecordsDefinitionLayoutConfig.AttachmentStyles.Contains((config.AttachmentListStyle ?? string.Empty).Trim().ToLowerInvariant()) + ? config.AttachmentListStyle.Trim().ToLowerInvariant() : RecordsDefinitionLayoutConfig.AttachmentsTable; + if (config.AppliesToVersion.HasValue && config.AppliesToVersion.Value <= 0) config.AppliesToVersion = null; + if (config.BrandingOverrides != null) config.BrandingOverrides = Normalize(config.BrandingOverrides); + return config; + } + public static RecordsPrintLayoutConfig Parse(string json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/Core/Resgrid.Services/Records/RecordsProtectionService.cs b/Core/Resgrid.Services/Records/RecordsProtectionService.cs index 56d56fee..f8212040 100644 --- a/Core/Resgrid.Services/Records/RecordsProtectionService.cs +++ b/Core/Resgrid.Services/Records/RecordsProtectionService.cs @@ -43,6 +43,20 @@ public async Task GetCatalogVersionAsync(int departmentId) catch (Exception ex) { Logging.LogException(ex, $"Pinned catalog version lookup failed for department {departmentId}."); return 0; } } + /// + /// The catalog version stamped on a row that was just sealed. Unlike , + /// which reports 0 for the read-side callers that only decorate a projection, this fails the write: a row + /// carrying real envelopes but a recorded version of 0 is invisible to the enrollment upgrade sweep that + /// is supposed to migrate it, and the write has not been persisted yet when this runs. + /// + private async Task RequiredCatalogVersionAsync(int departmentId, string operation) + { + var version = await GetCatalogVersionAsync(departmentId); + if (version <= 0) + throw new RecordProtectedContentException("catalog_version_unavailable", operation); + return version; + } + public async Task IsEnforcedAsync(int departmentId) { try { return await _dataProtection.IsProtectionEnforcedAsync(departmentId); } @@ -67,7 +81,7 @@ private async Task ApplyAsync(int departmentId, T row, T existing, string row if (!result.Success) throw new RecordProtectedContentException(result.Reason, operation); if (marked) - mark(row, await GetCatalogVersionAsync(departmentId)); + mark(row, await RequiredCatalogVersionAsync(departmentId, operation)); } private async Task ApplyCompanionsAsync(int departmentId, T row, string rowKey, @@ -83,7 +97,7 @@ private async Task ApplyCompanionsAsync(int departmentId, T row, string rowKe if (!result.Success) throw new RecordProtectedContentException(result.Reason, operation); if (marked) - mark(row, await GetCatalogVersionAsync(departmentId)); + mark(row, await RequiredCatalogVersionAsync(departmentId, operation)); } public Task ProtectDetailsAsync(int departmentId, RmsOperationalRecordDetail row, RmsOperationalRecordDetail existing, string userId = null, CancellationToken cancellationToken = default) @@ -97,7 +111,7 @@ public async Task ProtectAttachmentAsync(int departmentId, RmsRecordAttachment r if (!result.Success) throw new RecordProtectedContentException(result.Reason, "attachment"); if (row.IsProtected && row.ProtectedCatalogVersion == 0) - row.ProtectedCatalogVersion = await GetCatalogVersionAsync(departmentId); + row.ProtectedCatalogVersion = await RequiredCatalogVersionAsync(departmentId, "attachment"); } public Task ProtectRevisionAsync(int departmentId, RmsRevision row, string userId = null, CancellationToken cancellationToken = default) @@ -105,8 +119,8 @@ public Task ProtectRevisionAsync(int departmentId, RmsRevision row, string userI public async Task ProtectLocationAsync(int departmentId, RmsLocation row, RmsLocation existing, string userId = null, CancellationToken cancellationToken = default) { - await ApplyAsync(departmentId, row, existing, row?.RmsLocationId, RmsProtectedFields.Locations, (r, v) => { }, userId, "location", cancellationToken); - await ApplyCompanionsAsync(departmentId, row, row?.RmsLocationId, RmsProtectedFields.LocationCompanions, (r, v) => { }, userId, "location", cancellationToken); + await ApplyAsync(departmentId, row, existing, row?.RmsLocationId, RmsProtectedFields.Locations, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "location", cancellationToken); + await ApplyCompanionsAsync(departmentId, row, row?.RmsLocationId, RmsProtectedFields.LocationCompanions, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "location", cancellationToken); } public Task ProtectNarrativeAsync(int departmentId, RmsNarrative row, RmsNarrative existing, string userId = null, CancellationToken cancellationToken = default) @@ -166,7 +180,7 @@ public async Task ProtectExportRunAsync(int departmentId, RmsExportRun row, stri if (marked) { row.IsProtected = true; - row.ProtectedCatalogVersion = await GetCatalogVersionAsync(departmentId); + row.ProtectedCatalogVersion = await RequiredCatalogVersionAsync(departmentId, "export run"); } } @@ -179,6 +193,18 @@ public async Task ProtectExportRunAsync(int departmentId, RmsExportRun row, stri private static IReadOnlyList<(T Entity, string RowKey)> Rows(IEnumerable rows, Func key) where T : class => (rows ?? Enumerable.Empty()).Where(r => r != null).Select(r => (r, key(r))).ToList(); + public async Task ProtectValuesAsync(int departmentId, IReadOnlyList rows, string userId = null, CancellationToken cancellationToken = default) + { + foreach (var row in (rows ?? Array.Empty()).Where(r => r != null && r.ProtectionRequired)) + await ApplyAsync(departmentId, row, null, row.RmsRecordValueId, RmsProtectedFields.Values, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "typed value", cancellationToken); + } + + public Task RevealValuesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsRecordValueId), RmsProtectedFields.Values, GrantToken, User, cancellationToken); + + public Task RevealValuesForWorkloadAsync(int departmentId, IReadOnlyList rows, string purpose, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForWorkloadAsync(departmentId, purpose, Rows(rows, r => r.RmsRecordValueId), RmsProtectedFields.Values, cancellationToken); + public Task RevealAsync(int departmentId, RecordAggregate aggregate, CancellationToken cancellationToken = default) => ResolveAggregateAsync(departmentId, aggregate, GrantToken, User, cancellationToken); diff --git a/Core/Resgrid.Services/Records/RecordsRevealService.cs b/Core/Resgrid.Services/Records/RecordsRevealService.cs new file mode 100644 index 00000000..957508bd --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsRevealService.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Shared reveal logic for the Web MVC and v4 endpoints (RMS plan section 5.9.3). The aggregate is already + /// hydrated through the seam with the caller's grant; this keys the cataloged columns the way the page markers + /// expect ("{table}.{column}:{rowId}"), withholds restricted columns without RecordRestricted_View, and audits. + /// + public class RecordsRevealService : IRecordsRevealService + { + private readonly IRecordsService _records; + private readonly IIncidentReportsService _incidents; + + public RecordsRevealService(IRecordsService records, IIncidentReportsService incidents) + { + _records = records; + _incidents = incidents; + } + + public async Task RevealRecordAsync(int departmentId, string userId, RecordAggregate aggregate, bool canViewRestricted, string ipAddress) + { + if (aggregate?.Record == null) throw new ArgumentNullException(nameof(aggregate)); + var protection = aggregate.Protection ?? new ProtectedReadResult(); + if (protection.IsProtected && protection.ProtectedReason != null) + return new RecordRevealResult { Success = false, Error = protection.ProtectedReason }; + + var fields = new Dictionary(); + var details = aggregate.Details; + if (details != null) + { + foreach (var accessor in RmsProtectedFields.Details) + { + // The reveal hides exactly what the page hides: restricted detail columns stay withheld without the grant. + var column = accessor.Key.Substring(accessor.Key.IndexOf('.') + 1); + if (!canViewRestricted && RecordSnapshotSerializer.RestrictedDetailFields.Any(f => string.Equals(f, column, StringComparison.OrdinalIgnoreCase))) + continue; + fields[$"{accessor.Key}:{details.RmsOperationalRecordDetailId}"] = accessor.Value.Get(details); + } + } + foreach (var attachment in aggregate.Attachments ?? new List()) + fields[$"rmsrecordattachments.filename:{attachment.RmsRecordAttachmentId}"] = attachment.FileName; + + // Department-definition values are not cataloged yet (catalog v11); withheld cells stay withheld here too. + await _records.RecordAccessAsync(departmentId, userId, aggregate.Record.RmsOperationalRecordId, null, RmsAccessAuditAction.Read, "Protected reveal", ipAddress); + return new RecordRevealResult { Success = true, Fields = fields }; + } + + public async Task RevealIncidentAsync(int departmentId, string userId, IncidentReportAggregate aggregate, bool canViewRestricted, string ipAddress) + { + if (aggregate?.Report == null) throw new ArgumentNullException(nameof(aggregate)); + var protection = aggregate.Protection ?? new ProtectedReadResult(); + if (protection.IsProtected && protection.ProtectedReason != null) + return new RecordRevealResult { Success = false, Error = protection.ProtectedReason }; + + var fields = new Dictionary(); + void Add(IEnumerable rows, Func key, IReadOnlyDictionary Get, Action Set)> accessors) + { + foreach (var row in rows ?? Enumerable.Empty()) + foreach (var accessor in accessors) + fields[$"{accessor.Key}:{key(row)}"] = accessor.Value.Get(row); + } + if (aggregate.Narrative != null) Add(new[] { aggregate.Narrative }, n => n.RmsNarrativeId, RmsProtectedFields.Narratives); + if (aggregate.Location != null) + { + Add(new[] { aggregate.Location }, l => l.RmsLocationId, RmsProtectedFields.Locations); + fields[$"rmslocations.coordinates:{aggregate.Location.RmsLocationId}"] = aggregate.Location.Latitude.HasValue ? aggregate.Location.Latitude + ", " + aggregate.Location.Longitude : "-"; + } + Add(aggregate.Facts, f => f.RmsSourceFactId, RmsProtectedFields.SourceFacts); + Add(aggregate.Exposures, e => e.RmsExposureId, RmsProtectedFields.Exposures); + Add(aggregate.Resources, r => r.RmsIncidentResourceId, RmsProtectedFields.Resources); + Add(aggregate.Modules, m => m.RmsIncidentModuleId, RmsProtectedFields.Modules); + if (canViewRestricted) + Add(aggregate.Casualties, c => c.RmsCasualtyRescueId, RmsProtectedFields.Casualties); + foreach (var attachment in aggregate.Attachments ?? new List()) + fields[$"rmsrecordattachments.filename:{attachment.RmsRecordAttachmentId}"] = attachment.FileName; + + await _incidents.RecordAccessAsync(departmentId, userId, aggregate.Report.RmsIncidentReportId, null, RmsAccessAuditAction.Read, "Protected reveal", ipAddress); + return new RecordRevealResult { Success = true, Fields = fields }; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsService.cs b/Core/Resgrid.Services/Records/RecordsService.cs index c5fc885f..23873912 100644 --- a/Core/Resgrid.Services/Records/RecordsService.cs +++ b/Core/Resgrid.Services/Records/RecordsService.cs @@ -54,6 +54,9 @@ public class RecordsService : IRecordsService private readonly IRecordsAuthorizationService _authorization; private readonly IRecordsUdfService _udf; private readonly IRecordsProtectionService _protection; + private readonly IRecordDefinitionsService _definitions; + private readonly IRecordTypedValuesService _typedValues; + private readonly IPersonnelRolesService _roles; public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueService details, IRmsRecordParticipantsRepository participants, IRmsRecordUnitResponsesRepository units, IRmsRecordAttachmentsRepository attachments, @@ -62,9 +65,12 @@ public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueS IRecordsCutoverService cutover, IDepartmentSettingsService settings, IDepartmentGroupsService groups, IUserProfileService profiles, IUnitsService unitsService, ICallsService calls, IDepartmentDataProtectionService dataProtection, IUnitOfWork unitOfWork, IOutboundQueueProvider outboundQueue, IRecordAttachmentScanner attachmentScanner, IRecordsAuthorizationService authorization, IRecordsUdfService udf, - IRecordsProtectionService protection) + IRecordsProtectionService protection, IRecordDefinitionsService definitions, IRecordTypedValuesService typedValues, IPersonnelRolesService roles) { _protection = protection; + _definitions = definitions; + _typedValues = typedValues; + _roles = roles; _records = records; _details = details; _participants = participants; @@ -125,8 +131,14 @@ public async Task CreateDraftAsync(int departmentId, string use { if (input == null) throw new ArgumentNullException(nameof(input)); if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentException("An acting user is required.", nameof(userId)); + RmsRecordDefinitionVersion definitionVersion = null; if (!RmsDefinitionKeys.LockedTypes.TryGetValue(input.DefinitionKey ?? string.Empty, out var recordType)) - throw new ArgumentException($"'{input.DefinitionKey}' is not a published definition.", nameof(input)); + { + // Department definitions (RMS-1B): the current published version is pinned on the Record for life. + definitionVersion = await _definitions.GetCurrentPublishedAsync(departmentId, input.DefinitionKey ?? string.Empty); + if (definitionVersion == null) + throw new ArgumentException($"'{input.DefinitionKey}' is not a published definition.", nameof(input)); + } await EnsureRecordsUsableAsync(departmentId); if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord)) @@ -154,9 +166,9 @@ public async Task CreateDraftAsync(int departmentId, string use DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), DefinitionKey = input.DefinitionKey, - DefinitionVersion = RmsDefinitionKeys.LockedDefinitionVersion, - RecordType = (int)recordType, - LifecyclePreset = (int)RmsDefinitionKeys.LockedDefaultPreset, + DefinitionVersion = definitionVersion?.Version ?? RmsDefinitionKeys.LockedDefinitionVersion, + RecordType = definitionVersion == null ? (int)recordType : (int?)null, + LifecyclePreset = definitionVersion?.LifecyclePreset ?? (int)RmsDefinitionKeys.LockedDefaultPreset, State = (int)RmsRecordState.Draft, DraftReference = NewDraftReference(), StationGroupId = input.StationGroupId ?? authorGroup?.DepartmentGroupId, @@ -189,18 +201,32 @@ public async Task CreateDraftAsync(int departmentId, string use }; await ApplyAuthorizedDetailsAsync(departmentId, userId, record.DefinitionKey, details, input.Details); await ApplyCallSnapshotAsync(departmentId, userId, details, input.CallId); - ValidateDefinitionRequirements(recordType, details); + if (definitionVersion == null) ValidateDefinitionRequirements(recordType, details); var participants = await BuildParticipantsAsync(departmentId, recordId, input.Participants, now); var units = await BuildUnitsAsync(departmentId, recordId, input.Units, now); - record.DisplaySummary = BuildDisplaySummary(recordType, record, details, units); + RecordValueSet values = null; + if (definitionVersion != null) + { + var validation = await _typedValues.ValidateAsync(departmentId, definitionVersion, input.Values, false); + if (!validation.IsValid) throw new ArgumentException(string.Join(" ", validation.Issues.Where(i => i.Severity == "error").Select(i => i.Message))); + } + record.DisplaySummary = definitionVersion == null ? BuildDisplaySummary(recordType, record, details, units) : definitionVersion.DefinitionKey; var outboxIds = new List(); try { await InTransactionAsync(async () => { + // OnCreate numbering (plan 4.1): the number is reserved now; a cancelled draft records it as voided, never reused. + if (definitionVersion != null && definitionVersion.Numbering.Assignment == RmsNumberAssignment.OnCreate) + record.RecordNumber = await AllocateRecordNumberAsync(record, cancellationToken); await _records.InsertAsync(record, cancellationToken, true); + if (definitionVersion != null) + { + values = await _typedValues.SaveDraftValuesAsync(departmentId, userId, recordId, definitionVersion, input.Values, cancellationToken); + record.DisplaySummary = _typedValues.ToDisplaySummary(definitionVersion.Schema, values) ?? definitionVersion.DefinitionKey; + } record.UdfDefinitionId = await _udf.SaveInTransactionAsync(departmentId, userId, recordId, record.DefinitionKey, record.DefinitionVersion, null, input.CustomFields, cancellationToken); await _records.UpdateAsync(record, cancellationToken, true); await _details.InsertAsync(details, cancellationToken); @@ -209,7 +235,7 @@ await InTransactionAsync(async () => foreach (var unit in units) await _units.InsertAsync(unit, cancellationToken, true); - var aggregate = new RecordAggregate { Record = record, Details = details, Participants = participants, Units = units }; + var aggregate = new RecordAggregate { Record = record, Details = details, Participants = participants, Units = units, Values = values, DefinitionVersionRow = definitionVersion }; await RecomputeGroupScopeAsync(aggregate, cancellationToken); await UpsertProjectionAsync(aggregate, cancellationToken); @@ -251,6 +277,13 @@ public async Task SaveDraftAsync(int departmentId, string userI throw new RecordTransitionException(recordId, state, state, "the Record is not editable in this state"); var recordType = (RmsOperationalRecordType)record.RecordType.GetValueOrDefault(); + var definitionVersion = await DefinitionVersionForAsync(record); + if (definitionVersion != null) + { + var validation = await _typedValues.ValidateAsync(departmentId, definitionVersion, input.Values, false); + if (!validation.IsValid) throw new ArgumentException(string.Join(" ", validation.Issues.Where(i => i.Severity == "error").Select(i => i.Message))); + } + RecordValueSet values = null; var now = DateTime.UtcNow; await InTransactionAsync(async () => @@ -271,7 +304,8 @@ await InTransactionAsync(async () => record.UdfDefinitionId = await _udf.SaveInTransactionAsync(departmentId, userId, recordId, record.DefinitionKey, record.DefinitionVersion, record.UdfDefinitionId, input.CustomFields, cancellationToken); if (input.CallId != record.CallId) await ApplyCallSnapshotAsync(departmentId, userId, details, input.CallId); - ValidateDefinitionRequirements(recordType, details); + if (definitionVersion == null) ValidateDefinitionRequirements(recordType, details); + if (definitionVersion != null) values = await _typedValues.SaveDraftValuesAsync(departmentId, userId, recordId, definitionVersion, input.Values, cancellationToken); details.ModifiedOn = now; details.RowVersion += 1; await _details.SaveOrUpdateAsync(details, cancellationToken); @@ -290,14 +324,14 @@ await InTransactionAsync(async () => record.ExternalId = input.ExternalId; record.StartedOn = input.StartedOn; record.EndedOn = input.EndedOn; - record.DisplaySummary = BuildDisplaySummary(recordType, record, details, units); + record.DisplaySummary = definitionVersion == null ? BuildDisplaySummary(recordType, record, details, units) : _typedValues.ToDisplaySummary(definitionVersion.Schema, values) ?? definitionVersion.DefinitionKey; record.ModifiedOn = now; record.ModifiedByUserId = userId; if (state == RmsRecordState.Returned) record.State = (int)RmsRecordState.Draft; await _records.UpdateAsync(record, cancellationToken, true); - var aggregate = new RecordAggregate { Record = record, Details = details, Participants = participants, Units = units }; + var aggregate = new RecordAggregate { Record = record, Details = details, Participants = participants, Units = units, Values = values, DefinitionVersionRow = definitionVersion }; await RecomputeGroupScopeAsync(aggregate, cancellationToken); await UpsertProjectionAsync(aggregate, cancellationToken); // Draft autosaves emit no Workflow event (RMS plan section 5.6). @@ -323,7 +357,7 @@ await InTransactionAsync(async () => await GuardVersionAsync(record, expectedRowVersion, cancellationToken); record.State = (int)RmsRecordState.ReadyForReview; record.SubmittedForReviewOn = now; - record.ReviewDueOn = now.AddHours(await _settings.GetRecordsReviewDueHoursAsync(departmentId)); + record.ReviewDueOn = now.AddHours((await DefinitionVersionForAsync(record))?.ReviewDueHours ?? await _settings.GetRecordsReviewDueHoursAsync(departmentId)); record.ModifiedOn = now; record.ModifiedByUserId = userId; await _records.UpdateAsync(record, cancellationToken, true); @@ -342,6 +376,7 @@ public async Task ReturnForCorrectionAsync(int departmentId, st var record = await LoadRecordAsync(departmentId, recordId); var from = (RmsRecordState)record.State; RequireTransition(record, from, RmsRecordState.Returned); + await RequireDefinitionRoleAsync(record, userId, from == RmsRecordState.Approved); var now = DateTime.UtcNow; var outboxIds = new List(); @@ -385,6 +420,7 @@ public async Task ApproveAsync(int departmentId, string userId, RequireTransition(record, from, RmsRecordState.Approved); if (string.Equals(record.AuthorUserId, userId, StringComparison.Ordinal)) throw new RecordTransitionException(recordId, from, RmsRecordState.Approved, "the approver may not be the author"); + await RequireDefinitionRoleAsync(record, userId, true); var now = DateTime.UtcNow; var outboxIds = new List(); @@ -417,6 +453,8 @@ public async Task FinalizeAsync(int departmentId, string userId if (isAmendment && string.IsNullOrWhiteSpace(reasonCode)) throw new ArgumentException("A reason code is required to finalize an amendment.", nameof(reasonCode)); + if (from == RmsRecordState.ReadyForReview) + await RequireDefinitionRoleAsync(record, userId, false); var now = DateTime.UtcNow; var recordType = (RmsOperationalRecordType)record.RecordType.GetValueOrDefault(); @@ -429,8 +467,17 @@ await InTransactionAsync(async () => var draft = await HydrateDraftAsync(record); draft.Protection.RequireRevealed(isAmendment ? "finalize amendment" : "finalize"); - ValidateDefinitionRequirements(recordType, draft.Details); - ValidateForFinalization(recordType, draft); + if (draft.DefinitionVersionRow == null) + { + ValidateDefinitionRequirements(recordType, draft.Details); + ValidateForFinalization(recordType, draft); + } + else + { + // Department definitions: requiredness and Show/Require rules apply now, never on autosave (plan 4.1). + var validation = await _typedValues.ValidateAsync(departmentId, draft.DefinitionVersionRow, draft.Values?.ToInputs() ?? new List(), true); + if (!validation.IsValid) throw new ArgumentException(string.Join(" ", validation.Issues.Where(i => i.Severity == "error").Select(i => i.Message))); + } _udf.ValidateForFinalization(draft.CustomFields); if (string.IsNullOrWhiteSpace(record.RecordNumber)) @@ -616,6 +663,35 @@ await InTransactionAsync(async () => return await GetAsync(departmentId, recordId, false); } + public async Task AssignReviewerAsync(int departmentId, string userId, string recordId, string reviewerUserId, string reason, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(reviewerUserId)) throw new ArgumentException("A reviewer is required.", nameof(reviewerUserId)); + var record = await LoadRecordAsync(departmentId, recordId); + var state = (RmsRecordState)record.State; + if (state != RmsRecordState.ReadyForReview) + throw new RecordTransitionException(recordId, state, state, "only a Record awaiting review can be assigned a reviewer"); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ReviewRecords)) + throw new UnauthorizedAccessException("Assigning a reviewer requires the ReviewRecords permission."); + if (!await _authorization.CanUserViewRecordAsync(userId, recordId, departmentId)) + throw new UnauthorizedAccessException("Record access is not authorized."); + if (!await _authorization.HasPermissionAsync(reviewerUserId, departmentId, PermissionTypes.ReviewRecords)) + throw new ArgumentException("The chosen reviewer does not hold the ReviewRecords permission.", nameof(reviewerUserId)); + + await InTransactionAsync(async () => + { + await GuardVersionAsync(record, record.RowVersion, cancellationToken); + var previousReviewer = record.ReviewerUserId; + record.ReviewerUserId = reviewerUserId; + record.ModifiedOn = DateTime.UtcNow; + record.ModifiedByUserId = userId; + await _records.UpdateAsync(record, cancellationToken, true); + await RefreshProjectionAsync(record, cancellationToken); + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Admin, "Assign reviewer", RmsOriginClient.Web, cancellationToken, new { previousReviewer, reviewerUserId, reason }); + }); + + return await GetAsync(departmentId, recordId, false); + } + #endregion #region Reads @@ -889,6 +965,24 @@ private async Task GuardVersionAsync(RmsOperationalRecord record, long expectedR record.RowVersion = expectedRowVersion + 1; } + /// The pinned definition version of a department-definition Record; null for locked system definitions. + private async Task DefinitionVersionForAsync(RmsOperationalRecord record) + { + if (record == null || record.RecordType != null || RmsDefinitionKeys.IsSystemKey(record.DefinitionKey)) return null; + return await _definitions.GetVersionAsync(record.DepartmentId, record.DefinitionKey, record.DefinitionVersion); + } + + /// Roles assigned per definition narrow the underlying permission, never widen it (plan 4.1 presets table). + private async Task RequireDefinitionRoleAsync(RmsOperationalRecord record, string userId, bool approver) + { + var version = await DefinitionVersionForAsync(record); + var roleIds = RecordDefinitionsService.ParseIds(approver ? version?.ApproverRoleIds : version?.ReviewerRoleIds); + if (roleIds.Count == 0) return; + var roles = await _roles.GetRolesForUserAsync(userId, record.DepartmentId) ?? new List(); + if (!roles.Any(r => roleIds.Contains(r.PersonnelRoleId))) + throw new UnauthorizedAccessException(approver ? "This definition limits approval to selected roles." : "This definition limits review to selected roles."); + } + private async Task InTransactionAsync(Func work) { _unitOfWork.CreateOrGetConnection(); @@ -932,6 +1026,11 @@ private async Task HydrateDraftAsync(RmsOperationalRecord recor Units = (await _units.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId, null))?.ToList() ?? new List(), Attachments = (await _attachments.GetMetadataForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId))?.ToList() ?? new List() }; + if (record.RecordType == null) + { + aggregate.DefinitionVersionRow = await DefinitionVersionForAsync(record); + aggregate.Values = await _typedValues.HydrateAsync(record.DepartmentId, record.RmsOperationalRecordId, null, aggregate.DefinitionVersionRow, true); + } aggregate.Protection = await _protection.RevealAsync(record.DepartmentId, aggregate); return aggregate; } @@ -955,6 +1054,8 @@ private async Task WriteRevisionAsync(RmsOperationalRecord record, snapshot.Evidence = snapshot.Evidence.OrderBy(e => e.RmsEvidenceArtifactId, StringComparer.Ordinal).ToList(); if (transition != RmsRevisionTransition.Voided) await _evidence.RequireInventoryCoverageAsync(record.DepartmentId, record.RmsOperationalRecordId, snapshot.Evidence); snapshot.RecordNumber = record.RecordNumber; + if (draft.DefinitionVersionRow != null && draft.Values != null) + snapshot.Values = _typedValues.ToSnapshot(draft.DefinitionVersionRow.Schema, draft.Values); var json = RecordSnapshotSerializer.Serialize(snapshot); var revision = new RmsRevision @@ -998,6 +1099,8 @@ private async Task WriteRevisionAsync(RmsOperationalRecord record, copy.RowVersion = 1; await _details.InsertAsync(copy, cancellationToken); } + if (record.RecordType == null) + await _typedValues.CopyDraftToRevisionAsync(record.DepartmentId, record.RmsOperationalRecordId, revision.RmsRevisionId, cancellationToken); foreach (var participant in draft.Participants) { @@ -1020,6 +1123,8 @@ private async Task WriteRevisionAsync(RmsOperationalRecord record, private async Task RestoreDraftFromSnapshotAsync(RmsOperationalRecord record, RecordSnapshot snapshot, DateTime now, CancellationToken cancellationToken) { + if (record.RecordType == null) + await _typedValues.RestoreDraftFromRevisionAsync(record.DepartmentId, record.ModifiedByUserId, record.RmsOperationalRecordId, record.CurrentRevisionId, await DefinitionVersionForAsync(record), cancellationToken); await _udf.RestoreInTransactionAsync(record.DepartmentId, record.RmsOperationalRecordId, record.DefinitionKey, record.DefinitionVersion, snapshot.CustomFields, record.ModifiedByUserId, cancellationToken); record.UdfDefinitionId = snapshot.CustomFields?.DefinitionId; var details = await _details.GetDraftAsync(record.DepartmentId, record.RmsOperationalRecordId); @@ -1066,13 +1171,28 @@ private async Task AllocateRecordNumberAsync(RmsOperationalRecord record var config = await _settings.GetRecordsNumberingConfigAsync(record.DepartmentId); var prefixBase = RmsDefinitionKeys.DefaultNumberPrefix(record.DefinitionKey); var year = (record.StartedOn ?? DateTime.UtcNow).Year; + var perGroup = config.PerGroupSequence; + var includeYear = config.IncludeYear; + var configuredWidth = config.SequenceWidth; + if (record.RecordType == null) + { + // Department definitions carry their own numbering policy (plan 4.1 "Numbering"); the department setting is the fallback. + var numbering = (await DefinitionVersionForAsync(record))?.Numbering; + if (numbering != null) + { + if (!string.IsNullOrWhiteSpace(numbering.Prefix)) prefixBase = numbering.Prefix; + perGroup = numbering.PerGroupSequence; + includeYear = numbering.ResetYearly; + configuredWidth = numbering.SequenceWidth; + } + } var prefix = prefixBase + "-"; - if (config.PerGroupSequence && record.StationGroupId.HasValue) + if (perGroup && record.StationGroupId.HasValue) prefix += "G" + record.StationGroupId.Value + "-"; - if (config.IncludeYear) + if (includeYear) prefix += year + "-"; - var width = Math.Max(3, Math.Min(8, config.SequenceWidth <= 0 ? 4 : config.SequenceWidth)); + var width = Math.Max(3, Math.Min(8, configuredWidth <= 0 ? 4 : configuredWidth)); var sequence = await _records.GetMaxRecordNumberSequenceAsync(record.DepartmentId, prefix) + 1; return prefix + sequence.ToString("D" + width); } @@ -1150,7 +1270,8 @@ private async Task UpsertProjectionAsync(RecordAggregate aggregate, Cancellation projection.GroupScopeIds = string.Join(",", (aggregate.GroupScope ?? new List()).Select(s => s.DepartmentGroupId).Distinct()); projection.DisplaySummary = record.DisplaySummary; // Safe fields only: never narrative, address detail, contact or restricted sections (plan section 5.10). - projection.SearchText = string.Join(" ", new[] { record.RecordNumber, record.DraftReference, record.DisplaySummary, details.Course, details.CourseCode, details.CallNumber, details.CallName, details.Type, record.ExternalId }.Where(s => !string.IsNullOrWhiteSpace(s))); + projection.SearchText = string.Join(" ", new[] { record.RecordNumber, record.DraftReference, record.DisplaySummary, details.Course, details.CourseCode, details.CallNumber, details.CallName, details.Type, record.ExternalId, + aggregate.DefinitionVersionRow == null || aggregate.Values == null ? null : _typedValues.ToSearchText(aggregate.DefinitionVersionRow.Schema, aggregate.Values) }.Where(s => !string.IsNullOrWhiteSpace(s))); projection.IsLegacy = false; projection.ProjectionVersion = RmsRecordSearchProjection.CurrentProjectionVersion; projection.ProtectedCatalogVersion = await SafeCatalogVersionAsync(record.DepartmentId); @@ -1205,6 +1326,18 @@ private async Task EnqueueLifecycleEventAsync(RmsOperati }; if (extra != null) payload["extra"] = extra; + if (record.RecordType == null) + { + // Department definitions (RMS-1B): stable definition identity plus the explicitly Workflow-exposed field values. + var definitionVersion = await DefinitionVersionForAsync(record); + if (definitionVersion != null) + { + var definition = await _definitions.GetAsync(record.DepartmentId, record.DefinitionKey); + payload["definition"] = RecordDefinitionsService.DefinitionBlock(definition?.Definition ?? new RmsRecordDefinition { DefinitionKey = record.DefinitionKey, DepartmentId = record.DepartmentId, Owner = (int)RmsDefinitionOwner.Department }, definitionVersion, null, null); + var values = await _typedValues.HydrateAsync(record.DepartmentId, record.RmsOperationalRecordId, revision?.RmsRevisionId, definitionVersion, true); + payload["fields"] = _typedValues.ToWorkflowBlock(definitionVersion.Schema, values); + } + } if (blocks != null) foreach (var block in blocks) payload[block.Key] = block.Value; diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index d733aad4..2aad8186 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -270,6 +270,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // RMS-3d: public-records workflow (M0171) and the Records queue dashboards. builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -283,6 +284,17 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); // Department report exports via the Workflow system (RMS plan section 5.6, registry M0177, worker 45) builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-1B configurable definitions and RMS-1C packs (registry M0158-M0163) + 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(); + // Field Records for the operational apps (RMS-1D) + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // Default attachment scanner: no engine, rows stay Skipped. A real scanner provider replaces this registration. builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index b2b8f4f5..d60b9e64 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -532,6 +532,8 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve case WorkflowTriggerEventType.RecordEvidenceCaptured: case WorkflowTriggerEventType.RecordPurged: case WorkflowTriggerEventType.RecordExportScheduled: + case WorkflowTriggerEventType.RecordDefinitionPublished: + case WorkflowTriggerEventType.RecordDefinitionRetired: AddRecordsSamples(obj, eventType); break; } @@ -702,6 +704,42 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType protection["protected_catalog_version"] = 0; obj["protection"] = protection; + // definition.* and fields.* (RMS-1B): shown for every Records trigger so a department-definition template previews. + var isDefinitionTrigger = eventType == WorkflowTriggerEventType.RecordDefinitionPublished || eventType == WorkflowTriggerEventType.RecordDefinitionRetired; + var definition = new ScriptObject(); + definition["id"] = "2b3c4d5e-6f70-4a81-9b92-a3b4c5d6e7f8"; + definition["key"] = "security-patrol"; + definition["name"] = "Security Patrol Log"; + definition["category"] = "Security"; + definition["owner"] = "Department"; + definition["version"] = 2; + definition["previous_version"] = eventType == WorkflowTriggerEventType.RecordDefinitionPublished ? 1 : (int?)null; + definition["state"] = eventType == WorkflowTriggerEventType.RecordDefinitionRetired ? "Retired" : "Published"; + definition["lifecycle_preset"] = "QuickEntry"; + definition["template_key"] = "template.security-patrol"; + definition["jurisdiction_profile_key"] = "us"; + definition["minimum_client_capability"] = "records.v1b"; + definition["schema_checksum"] = "9f2c1e0d8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e"; + definition["published_on"] = DateTime.Now.AddDays(-7); + definition["retired"] = eventType == WorkflowTriggerEventType.RecordDefinitionRetired; + definition["reason"] = eventType == WorkflowTriggerEventType.RecordDefinitionRetired ? "Replaced by security-patrol-v2" : ""; + definition["exposed_field_keys"] = new ScriptArray { "client_site", "officer", "exception_reported" }; + definition["section_keys"] = new ScriptArray { "assignment", "checkpoints", "observations", "exceptions", "handoff" }; + obj["definition"] = definition; + if (!isDefinitionTrigger) + { + var fields = new ScriptObject(); + fields["client_site"] = "Harbor Logistics — Pier 4"; + fields["officer"] = "J. Alvarez"; + fields["exception_reported"] = true; + var checkpoint = new ScriptObject(); + checkpoint["checkpoint"] = "Gate B"; + checkpoint["status"] = "Exception"; + fields["checkpoints"] = new ScriptArray { checkpoint }; + fields["checkpoints_count"] = 1; + obj["fields"] = fields; + } + if (eventType == WorkflowTriggerEventType.RecordApproved) { var approval = new ScriptObject(); diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index d2bcb7a2..225c78df 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -413,6 +413,8 @@ public async Task BuildContextAsync( case WorkflowTriggerEventType.RecordSubmissionFailed: case WorkflowTriggerEventType.RecordOverdue: case WorkflowTriggerEventType.RecordApproved: + case WorkflowTriggerEventType.RecordDefinitionPublished: + case WorkflowTriggerEventType.RecordDefinitionRetired: case WorkflowTriggerEventType.RecordAttachmentAdded: case WorkflowTriggerEventType.RecordDisclosureRequested: case WorkflowTriggerEventType.RecordDisclosureProduced: @@ -1219,7 +1221,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" }) + foreach (var name in new[] { "attachment", "disclosure", "legal_hold", "evidence", "purge", "export", "definition", "fields" }) { if (payload[name] is JObject block) obj[name] = ToScriptObject(block); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0158_AddRmsRecordDefinitions.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0158_AddRmsRecordDefinitions.cs new file mode 100644 index 00000000..b0ee065e --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0158_AddRmsRecordDefinitions.cs @@ -0,0 +1,152 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Records (RMS-1B) department definitions (plan sections 4.1 and 5.2, registry M0158): stable definition identity, immutable-once-published versions (authored SchemaJson, checksum, capability floor, numbering/lifecycle/retention policy) and the section/field rows materialized at publish for stable query identity. + /// Existence-guarded for safe retry. + /// + [Migration(158)] + public class M0158_AddRmsRecordDefinitions : Migration + { + public override void Up() + { + if (!Schema.Table("RmsRecordDefinitions").Exists()) + { + Create.Table("RmsRecordDefinitions") + .WithColumn("RmsRecordDefinitionId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("DefinitionKey").AsString(200).NotNullable() + .WithColumn("Owner").AsInt32().NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Category").AsString(200).Nullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("TemplateKey").AsString(200).Nullable() + .WithColumn("TemplatePackVersion").AsInt32().Nullable() + .WithColumn("JurisdictionProfileKey").AsString(64).Nullable() + .WithColumn("PermittedSubjectTypes").AsString(400).Nullable() + .WithColumn("CurrentPublishedVersion").AsInt32().Nullable() + .WithColumn("LatestVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsRetired").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("RetiredOn").AsDateTime2().Nullable() + .WithColumn("RetiredByUserId").AsString(128).Nullable() + .WithColumn("RetiredReason").AsString(int.MaxValue).Nullable() + .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(); + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsRecordDefinitions_Department_Key ON RmsRecordDefinitions (DepartmentId, DefinitionKey);"); + } + + if (!Schema.Table("RmsRecordDefinitionVersions").Exists()) + { + Create.Table("RmsRecordDefinitionVersions") + .WithColumn("RmsRecordDefinitionVersionId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RmsRecordDefinitionId").AsString(36).NotNullable() + .WithColumn("DefinitionKey").AsString(200).NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("State").AsInt32().NotNullable() + .WithColumn("LifecyclePreset").AsInt32().NotNullable() + .WithColumn("ReviewerRoleIds").AsString(400).Nullable() + .WithColumn("ApproverRoleIds").AsString(400).Nullable() + .WithColumn("ReviewDueHours").AsInt32().Nullable() + .WithColumn("ApproveDueHours").AsInt32().Nullable() + .WithColumn("RequireAuthorAttestation").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("NumberingJson").AsString(int.MaxValue).Nullable() + .WithColumn("RetentionYears").AsInt32().Nullable() + .WithColumn("Classification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SchemaJson").AsString(int.MaxValue).Nullable() + .WithColumn("SchemaChecksum").AsString(128).Nullable() + .WithColumn("MinimumClientCapability").AsString(64).Nullable() + .WithColumn("ClientSurfaceJson").AsString(int.MaxValue).Nullable() + .WithColumn("MigrationMapJson").AsString(int.MaxValue).Nullable() + .WithColumn("ChangeNotes").AsString(int.MaxValue).Nullable() + .WithColumn("PublishedOn").AsDateTime2().Nullable() + .WithColumn("PublishedByUserId").AsString(128).Nullable() + .WithColumn("RetiredOn").AsDateTime2().Nullable() + .WithColumn("RetiredByUserId").AsString(128).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedByUserId").AsString(128).Nullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsRecordDefinitionVersions_Department_Key_Version ON RmsRecordDefinitionVersions (DepartmentId, DefinitionKey, Version);"); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordDefinitionVersions_Department_State ON RmsRecordDefinitionVersions (DepartmentId, State);"); + } + + if (!Schema.Table("RmsRecordSectionDefinitions").Exists()) + { + Create.Table("RmsRecordSectionDefinitions") + .WithColumn("RmsRecordSectionDefinitionId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RmsRecordDefinitionVersionId").AsString(36).NotNullable() + .WithColumn("DefinitionKey").AsString(200).NotNullable() + .WithColumn("DefinitionVersion").AsInt32().NotNullable() + .WithColumn("SectionKey").AsString(64).NotNullable() + .WithColumn("Label").AsString(400).Nullable() + .WithColumn("Help").AsString(int.MaxValue).Nullable() + .WithColumn("Ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("IsRepeating").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("MinRows").AsInt32().Nullable() + .WithColumn("MaxRows").AsInt32().Nullable() + .WithColumn("RulesJson").AsString(int.MaxValue).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsRecordSectionDefinitions_Version_Key ON RmsRecordSectionDefinitions (DepartmentId, RmsRecordDefinitionVersionId, SectionKey);"); + } + + if (!Schema.Table("RmsRecordFieldDefinitions").Exists()) + { + Create.Table("RmsRecordFieldDefinitions") + .WithColumn("RmsRecordFieldDefinitionId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RmsRecordDefinitionVersionId").AsString(36).NotNullable() + .WithColumn("DefinitionKey").AsString(200).NotNullable() + .WithColumn("DefinitionVersion").AsInt32().NotNullable() + .WithColumn("SectionKey").AsString(64).NotNullable() + .WithColumn("FieldKey").AsString(64).NotNullable() + .WithColumn("Label").AsString(400).Nullable() + .WithColumn("DataType").AsInt32().NotNullable() + .WithColumn("Ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Required").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("RequiredToFinalize").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Classification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ReferenceType").AsString(64).Nullable() + .WithColumn("Searchable").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Filterable").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Sortable").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Groupable").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Aggregatable").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("WorkflowExposed").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Exportable").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("ConstraintsJson").AsString(int.MaxValue).Nullable() + .WithColumn("RulesJson").AsString(int.MaxValue).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsRecordFieldDefinitions_Version_Key ON RmsRecordFieldDefinitions (DepartmentId, RmsRecordDefinitionVersionId, FieldKey);"); + } + + } + + public override void Down() + { + if (Schema.Table("RmsRecordFieldDefinitions").Exists()) + Delete.Table("RmsRecordFieldDefinitions"); + if (Schema.Table("RmsRecordSectionDefinitions").Exists()) + Delete.Table("RmsRecordSectionDefinitions"); + if (Schema.Table("RmsRecordDefinitionVersions").Exists()) + Delete.Table("RmsRecordDefinitionVersions"); + if (Schema.Table("RmsRecordDefinitions").Exists()) + Delete.Table("RmsRecordDefinitions"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0159_AddRmsRecordValues.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0159_AddRmsRecordValues.cs new file mode 100644 index 00000000..6e03b3d1 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0159_AddRmsRecordValues.cs @@ -0,0 +1,86 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Records (RMS-1B) typed values (plan section 5.3, registry M0159): one discriminated table, one row per scalar / repeating-group cell / multi-select option, exactly one column group populated (check constraint plus the service guard), repeating rows in RmsRecordValueGroups, explicit equality/range indexes filtered to unprotected rows, inert ADP columns. + /// Existence-guarded for safe retry. + /// + [Migration(159)] + public class M0159_AddRmsRecordValues : Migration + { + public override void Up() + { + if (!Schema.Table("RmsRecordValueGroups").Exists()) + { + Create.Table("RmsRecordValueGroups") + .WithColumn("RmsRecordValueGroupId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RecordKind").AsInt32().NotNullable() + .WithColumn("RevisionId").AsString(36).Nullable() + .WithColumn("RmsRecordDefinitionVersionId").AsString(36).Nullable() + .WithColumn("SectionKey").AsString(64).NotNullable() + .WithColumn("Ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ClientRowKey").AsString(64).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordValueGroups_Department_Record_Revision ON RmsRecordValueGroups (DepartmentId, RecordId, RevisionId);"); + } + + if (!Schema.Table("RmsRecordValues").Exists()) + { + Create.Table("RmsRecordValues") + .WithColumn("RmsRecordValueId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RecordKind").AsInt32().NotNullable() + .WithColumn("RevisionId").AsString(36).Nullable() + .WithColumn("RmsRecordDefinitionVersionId").AsString(36).Nullable() + .WithColumn("FieldKey").AsString(64).NotNullable() + .WithColumn("RmsRecordValueGroupId").AsString(36).Nullable() + .WithColumn("Ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ValueType").AsInt32().NotNullable() + .WithColumn("TextValue").AsString(400).Nullable() + .WithColumn("LongTextValue").AsString(int.MaxValue).Nullable() + .WithColumn("NumberValue").AsDecimal(28, 10).Nullable() + .WithColumn("BoolValue").AsBoolean().Nullable() + .WithColumn("DateTimeValue").AsDateTime2().Nullable() + .WithColumn("DateTimeOffsetMinutes").AsInt32().Nullable() + .WithColumn("DurationSeconds").AsInt64().Nullable() + .WithColumn("UnitCode").AsString(64).Nullable() + .WithColumn("CanonicalNumberValue").AsDecimal(28, 10).Nullable() + .WithColumn("CanonicalUnitCode").AsString(64).Nullable() + .WithColumn("CurrencyCode").AsString(64).Nullable() + .WithColumn("ReferenceType").AsString(64).Nullable() + .WithColumn("ReferenceId").AsString(200).Nullable() + .WithColumn("ReferenceSnapshotJson").AsString(int.MaxValue).Nullable() + .WithColumn("OptionKey").AsString(64).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedEnvelope").AsString(int.MaxValue).Nullable() + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordValues_Department_Record_Revision ON RmsRecordValues (DepartmentId, RecordId, RevisionId);"); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordValues_Department_Version_Field_Text ON RmsRecordValues (DepartmentId, RmsRecordDefinitionVersionId, FieldKey, TextValue) WHERE IsProtected = 0;"); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordValues_Department_Version_Field_Number ON RmsRecordValues (DepartmentId, RmsRecordDefinitionVersionId, FieldKey, NumberValue) WHERE IsProtected = 0;"); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordValues_Department_Version_Field_DateTime ON RmsRecordValues (DepartmentId, RmsRecordDefinitionVersionId, FieldKey, DateTimeValue) WHERE IsProtected = 0;"); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordValues_Department_Revision ON RmsRecordValues (DepartmentId, RevisionId);"); + Execute.Sql("ALTER TABLE RmsRecordValues ADD CONSTRAINT CK_RmsRecordValues_OneColumnGroup CHECK ((CASE WHEN TextValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN LongTextValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN NumberValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN BoolValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN DateTimeValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN DurationSeconds IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN ReferenceId IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN OptionKey IS NOT NULL THEN 1 ELSE 0 END) = 1 OR ProtectedEnvelope IS NOT NULL);"); + } + + } + + public override void Down() + { + if (Schema.Table("RmsRecordValues").Exists()) + Delete.Table("RmsRecordValues"); + if (Schema.Table("RmsRecordValueGroups").Exists()) + Delete.Table("RmsRecordValueGroups"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0161_AddRmsSavedReports.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0161_AddRmsSavedReports.cs new file mode 100644 index 00000000..20a8d4fe --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0161_AddRmsSavedReports.cs @@ -0,0 +1,46 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Records (RMS-1B) saved reports (plan section 4.1 reporting, registry M0161): department-owned allowlisted columns, bounded filters, one group-by and count/sum/avg/min/max, with explicit cross-version field mappings in SpecJson. + /// Existence-guarded for safe retry. + /// + [Migration(161)] + public class M0161_AddRmsSavedReports : Migration + { + public override void Up() + { + if (!Schema.Table("RmsSavedReportDefinitions").Exists()) + { + Create.Table("RmsSavedReportDefinitions") + .WithColumn("RmsSavedReportDefinitionId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("DefinitionKey").AsString(200).NotNullable() + .WithColumn("DefinitionVersion").AsInt32().Nullable() + .WithColumn("SpecJson").AsString(int.MaxValue).Nullable() + .WithColumn("MaxRowsPerRun").AsInt32().NotNullable().WithDefaultValue(5000) + .WithColumn("IncludeRestricted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("LastRunOn").AsDateTime2().Nullable() + .WithColumn("LastRunByUserId").AsString(128).Nullable() + .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(); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsSavedReportDefinitions_Department ON RmsSavedReportDefinitions (DepartmentId, DefinitionKey);"); + } + + } + + public override void Down() + { + if (Schema.Table("RmsSavedReportDefinitions").Exists()) + Delete.Table("RmsSavedReportDefinitions"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0162_AddRmsTemplatePacksAndJurisdictionProfiles.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0162_AddRmsTemplatePacksAndJurisdictionProfiles.cs new file mode 100644 index 00000000..4883eeaf --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0162_AddRmsTemplatePacksAndJurisdictionProfiles.cs @@ -0,0 +1,82 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Records (RMS-1C) product template packs and locked jurisdiction profiles (plan section 4.1, registry M0162): product-scope rows (DepartmentId 0) mirrored from the code catalog so departments see provenance, review dates, locales, units, currency and deprecation; a pack update never mutates a department clone. + /// Existence-guarded for safe retry. + /// + [Migration(162)] + public class M0162_AddRmsTemplatePacksAndJurisdictionProfiles : Migration + { + public override void Up() + { + if (!Schema.Table("RmsTemplatePackVersions").Exists()) + { + Create.Table("RmsTemplatePackVersions") + .WithColumn("RmsTemplatePackVersionId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("PackKey").AsString(200).NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Category").AsString(200).Nullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("IsPreview").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DefinitionKeys").AsString(int.MaxValue).Nullable() + .WithColumn("SupportedProfiles").AsString(400).Nullable() + .WithColumn("SupportedLocales").AsString(400).Nullable() + .WithColumn("ReleaseNotes").AsString(int.MaxValue).Nullable() + .WithColumn("SourceProvenanceJson").AsString(int.MaxValue).Nullable() + .WithColumn("ReviewedOn").AsDateTime2().Nullable() + .WithColumn("ArtifactStatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ContentChecksum").AsString(128).Nullable() + .WithColumn("IsDeprecated").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DeprecatedByPackKey").AsString(200).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsTemplatePackVersions_Key_Version ON RmsTemplatePackVersions (DepartmentId, PackKey, Version);"); + } + + if (!Schema.Table("RmsJurisdictionProfileVersions").Exists()) + { + Create.Table("RmsJurisdictionProfileVersions") + .WithColumn("RmsJurisdictionProfileVersionId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("ProfileKey").AsString(64).NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Country").AsString(64).Nullable() + .WithColumn("Subdivision").AsString(64).Nullable() + .WithColumn("AgencyScope").AsString(200).Nullable() + .WithColumn("DefaultLocale").AsString(64).Nullable() + .WithColumn("SupportedLocales").AsString(400).Nullable() + .WithColumn("MeasurementSystem").AsString(64).Nullable() + .WithColumn("CurrencyCode").AsString(64).Nullable() + .WithColumn("DefaultTimeZone").AsString(128).Nullable() + .WithColumn("TerminologyJson").AsString(int.MaxValue).Nullable() + .WithColumn("StandardsJson").AsString(int.MaxValue).Nullable() + .WithColumn("ClassificationDefault").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RetentionYearsDefault").AsInt32().Nullable() + .WithColumn("RequiredSections").AsString(int.MaxValue).Nullable() + .WithColumn("ArtifactStatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ReviewedOn").AsDateTime2().Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsJurisdictionProfileVersions_Key_Version ON RmsJurisdictionProfileVersions (DepartmentId, ProfileKey, Version);"); + } + + } + + public override void Down() + { + if (Schema.Table("RmsJurisdictionProfileVersions").Exists()) + Delete.Table("RmsJurisdictionProfileVersions"); + if (Schema.Table("RmsTemplatePackVersions").Exists()) + Delete.Table("RmsTemplatePackVersions"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0163_AddRmsExternalOrderReferences.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0163_AddRmsExternalOrderReferences.cs new file mode 100644 index 00000000..6630015e --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0163_AddRmsExternalOrderReferences.cs @@ -0,0 +1,132 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Records (RMS-1C) external orders and fills (plan section 4.1 external-order fill contract, registry M0163): one manually entered, checksummed order snapshot per deployment Record with both home and host profiles, and one row per request/fill the department answers with its lifecycle times captured with local offset. No connector, no write-back. + /// Existence-guarded for safe retry. + /// + [Migration(163)] + public class M0163_AddRmsExternalOrderReferences : Migration + { + public override void Up() + { + if (!Schema.Table("RmsExternalOrders").Exists()) + { + Create.Table("RmsExternalOrders") + .WithColumn("RmsExternalOrderId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("ProfileKey").AsString(64).NotNullable() + .WithColumn("ProfileVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("HomeProfileKey").AsString(64).Nullable() + .WithColumn("HostProfileKey").AsString(64).Nullable() + .WithColumn("SourceScheme").AsString(64).Nullable() + .WithColumn("SourceSystem").AsString(200).Nullable() + .WithColumn("OrderNumber").AsString(200).NotNullable() + .WithColumn("IncidentName").AsString(200).NotNullable() + .WithColumn("IncidentNumber").AsString(200).Nullable() + .WithColumn("IncidentCountry").AsString(64).Nullable() + .WithColumn("IncidentSubdivision").AsString(64).Nullable() + .WithColumn("OrderingOffice").AsString(200).Nullable() + .WithColumn("DispatchOffice").AsString(200).Nullable() + .WithColumn("RequestingAgency").AsString(200).Nullable() + .WithColumn("ReceivingAgency").AsString(200).Nullable() + .WithColumn("SendingAgency").AsString(200).Nullable() + .WithColumn("DepartmentRole").AsString(64).Nullable() + .WithColumn("CostCode").AsString(200).Nullable() + .WithColumn("AgreementReference").AsString(200).Nullable() + .WithColumn("CurrencyCode").AsString(64).Nullable() + .WithColumn("MeasurementSystem").AsString(64).Nullable() + .WithColumn("TimeZoneId").AsString(128).Nullable() + .WithColumn("CapturedOffsetMinutes").AsInt32().Nullable() + .WithColumn("SourceCapturedOn").AsDateTime2().Nullable() + .WithColumn("SourceVersion").AsString(64).Nullable() + .WithColumn("ArtifactFileName").AsString(400).Nullable() + .WithColumn("ArtifactContentType").AsString(200).Nullable() + .WithColumn("ArtifactChecksum").AsString(128).Nullable() + .WithColumn("ArtifactData").AsBinary(int.MaxValue).Nullable() + .WithColumn("ArtifactSafeUrl").AsString(int.MaxValue).Nullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("MobilizedOn").AsDateTime2().Nullable() + .WithColumn("ReleasedOn").AsDateTime2().Nullable() + .WithColumn("ClosedOutOn").AsDateTime2().Nullable() + .WithColumn("ClosedOutByUserId").AsString(128).Nullable() + .WithColumn("CloseoutNotes").AsString(int.MaxValue).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(); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsExternalOrders_Department_Record ON RmsExternalOrders (DepartmentId, RecordId);"); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsExternalOrders_Department_Order ON RmsExternalOrders (DepartmentId, OrderNumber);"); + } + + if (!Schema.Table("RmsExternalOrderFills").Exists()) + { + Create.Table("RmsExternalOrderFills") + .WithColumn("RmsExternalOrderFillId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RmsExternalOrderId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RequestNumber").AsString(200).NotNullable() + .WithColumn("ParentRequestNumber").AsString(200).Nullable() + .WithColumn("RequestCategory").AsString(64).Nullable() + .WithColumn("FillNumber").AsString(200).Nullable() + .WithColumn("ResourceKind").AsString(200).Nullable() + .WithColumn("ResourceType").AsString(200).Nullable() + .WithColumn("ResourceTypeScheme").AsString(64).Nullable() + .WithColumn("Position").AsString(200).Nullable() + .WithColumn("PositionScheme").AsString(64).Nullable() + .WithColumn("IsTrainee").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("HomeUnit").AsString(200).Nullable() + .WithColumn("HostAgency").AsString(200).Nullable() + .WithColumn("AgencyUnitId").AsString(200).Nullable() + .WithColumn("PointOfHire").AsString(400).Nullable() + .WithColumn("CostCode").AsString(200).Nullable() + .WithColumn("AgreementReference").AsString(200).Nullable() + .WithColumn("AssignedUserId").AsString(128).Nullable() + .WithColumn("AssignedUnitId").AsInt32().Nullable() + .WithColumn("QualificationsJson").AsString(int.MaxValue).Nullable() + .WithColumn("RosterJson").AsString(int.MaxValue).Nullable() + .WithColumn("TravelJson").AsString(int.MaxValue).Nullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("DeclineReason").AsString(int.MaxValue).Nullable() + .WithColumn("RequestedOn").AsDateTime2().Nullable() + .WithColumn("NeededOn").AsDateTime2().Nullable() + .WithColumn("FilledOn").AsDateTime2().Nullable() + .WithColumn("MobilizedOn").AsDateTime2().Nullable() + .WithColumn("CheckedInOn").AsDateTime2().Nullable() + .WithColumn("AssignedOn").AsDateTime2().Nullable() + .WithColumn("ReleasedOn").AsDateTime2().Nullable() + .WithColumn("DemobilizedOn").AsDateTime2().Nullable() + .WithColumn("ReturnedOn").AsDateTime2().Nullable() + .WithColumn("CapturedOffsetMinutes").AsInt32().Nullable() + .WithColumn("Notes").AsString(int.MaxValue).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(); + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsExternalOrderFills_Department_Order ON RmsExternalOrderFills (DepartmentId, RmsExternalOrderId);"); + } + + } + + public override void Down() + { + if (Schema.Table("RmsExternalOrderFills").Exists()) + Delete.Table("RmsExternalOrderFills"); + if (Schema.Table("RmsExternalOrders").Exists()) + Delete.Table("RmsExternalOrders"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0178_RmsProtectedDataCatalogV11.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0178_RmsProtectedDataCatalogV11.cs new file mode 100644 index 00000000..2fab3fa5 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0178_RmsProtectedDataCatalogV11.cs @@ -0,0 +1,36 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// RMS Advanced Data Protection catalog v11 (RMS plan section 5.9.4 (e), registry M0178): typed values of + /// department definitions. A row whose field is Protected-classified is flagged ProtectionRequired at write time; + /// the ADP seam seals its typed columns into the existing ProtectedEnvelope column and the migration sweep only + /// visits flagged rows. Guarded for safe retry. + /// + [Migration(178)] + public class M0178_RmsProtectedDataCatalogV11 : Migration + { + public override void Up() + { + if (!Schema.Table("RmsRecordValues").Exists()) + return; + + if (!Schema.Table("RmsRecordValues").Column("ProtectionRequired").Exists()) + Alter.Table("RmsRecordValues").AddColumn("ProtectionRequired").AsBoolean().NotNullable().WithDefaultValue(false); + + if (!Schema.Table("RmsRecordValues").Index("IX_RmsRecordValues_Department_ProtectionRequired").Exists()) + Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordValues_Department_ProtectionRequired ON RmsRecordValues (DepartmentId, RmsRecordValueId) WHERE ProtectionRequired = 1;"); + } + + public override void Down() + { + if (!Schema.Table("RmsRecordValues").Exists()) + return; + if (Schema.Table("RmsRecordValues").Index("IX_RmsRecordValues_Department_ProtectionRequired").Exists()) + Delete.Index("IX_RmsRecordValues_Department_ProtectionRequired").OnTable("RmsRecordValues"); + if (Schema.Table("RmsRecordValues").Column("ProtectionRequired").Exists()) + Delete.Column("ProtectionRequired").FromTable("RmsRecordValues"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0179_AddRmsRecordWorkAssignments.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0179_AddRmsRecordWorkAssignments.cs new file mode 100644 index 00000000..5ca880a9 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0179_AddRmsRecordWorkAssignments.cs @@ -0,0 +1,64 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Work assignments on Records (RMS plan section 5.2 RmsRecordWorkAssignment, RMS-1D, registry M0179): an + /// optional person / unit / group / command-role / dispatch-role assignment with purpose, due, acknowledged, + /// completed and cancelled state, safe source context and the client origin. Guarded for safe retry. + /// + [Migration(179)] + public class M0179_AddRmsRecordWorkAssignments : Migration + { + public override void Up() + { + if (Schema.Table("RmsRecordWorkAssignments").Exists()) + return; + + Create.Table("RmsRecordWorkAssignments") + .WithColumn("RmsRecordWorkAssignmentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("AssigneeKind").AsInt32().NotNullable() + .WithColumn("AssigneeUserId").AsString(128).Nullable() + .WithColumn("AssigneeUnitId").AsInt32().Nullable() + .WithColumn("AssigneeGroupId").AsInt32().Nullable() + .WithColumn("AssigneeRole").AsString(100).Nullable() + .WithColumn("Purpose").AsString(32).NotNullable() + .WithColumn("Note").AsString(1000).Nullable() + .WithColumn("SourceContextJson").AsString(1000).Nullable() + .WithColumn("DueOn").AsDateTime2().Nullable() + .WithColumn("State").AsInt32().NotNullable() + .WithColumn("AcknowledgedOn").AsDateTime2().Nullable() + .WithColumn("AcknowledgedByUserId").AsString(128).Nullable() + .WithColumn("CompletedOn").AsDateTime2().Nullable() + .WithColumn("CompletedByUserId").AsString(128).Nullable() + .WithColumn("CancelledOn").AsDateTime2().Nullable() + .WithColumn("CancelledByUserId").AsString(128).Nullable() + .WithColumn("CancelReason").AsString(500).Nullable() + .WithColumn("OriginClient").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedByUserId").AsString(128).Nullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsRecordWorkAssignments_Record").OnTable("RmsRecordWorkAssignments") + .OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending(); + Create.Index("IX_RmsRecordWorkAssignments_Person").OnTable("RmsRecordWorkAssignments") + .OnColumn("DepartmentId").Ascending().OnColumn("AssigneeUserId").Ascending().OnColumn("State").Ascending(); + Create.Index("IX_RmsRecordWorkAssignments_Unit").OnTable("RmsRecordWorkAssignments") + .OnColumn("DepartmentId").Ascending().OnColumn("AssigneeUnitId").Ascending().OnColumn("State").Ascending(); + Create.Index("IX_RmsRecordWorkAssignments_Modified").OnTable("RmsRecordWorkAssignments") + .OnColumn("DepartmentId").Ascending().OnColumn("ModifiedOn").Ascending(); + } + + public override void Down() + { + if (Schema.Table("RmsRecordWorkAssignments").Exists()) + Delete.Table("RmsRecordWorkAssignments"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0158_AddRmsRecordDefinitionsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0158_AddRmsRecordDefinitionsPg.cs new file mode 100644 index 00000000..575fbe2a --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0158_AddRmsRecordDefinitionsPg.cs @@ -0,0 +1,152 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Records (RMS-1B) department definitions (plan sections 4.1 and 5.2, registry M0158): stable definition identity, immutable-once-published versions (authored SchemaJson, checksum, capability floor, numbering/lifecycle/retention policy) and the section/field rows materialized at publish for stable query identity. + /// PostgreSQL twin of the SQL Server migration; lower-case identifiers, citext keys, existence-guarded. + /// + [Migration(158)] + public class M0158_AddRmsRecordDefinitionsPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmsrecorddefinitions").Exists()) + { + Create.Table("rmsrecorddefinitions") + .WithColumn("rmsrecorddefinitionid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("definitionkey").AsCustom("citext").NotNullable() + .WithColumn("owner").AsInt32().NotNullable() + .WithColumn("name").AsCustom("citext").NotNullable() + .WithColumn("category").AsCustom("citext").Nullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("templatekey").AsCustom("citext").Nullable() + .WithColumn("templatepackversion").AsInt32().Nullable() + .WithColumn("jurisdictionprofilekey").AsCustom("citext").Nullable() + .WithColumn("permittedsubjecttypes").AsCustom("citext").Nullable() + .WithColumn("currentpublishedversion").AsInt32().Nullable() + .WithColumn("latestversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isretired").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("retiredon").AsDateTime2().Nullable() + .WithColumn("retiredbyuserid").AsCustom("citext").Nullable() + .WithColumn("retiredreason").AsCustom("text").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsCustom("citext").Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("modifiedbyuserid").AsCustom("citext").Nullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmsrecorddefinitions_department_key ON rmsrecorddefinitions (departmentid, definitionkey);"); + } + + if (!Schema.Table("rmsrecorddefinitionversions").Exists()) + { + Create.Table("rmsrecorddefinitionversions") + .WithColumn("rmsrecorddefinitionversionid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("rmsrecorddefinitionid").AsCustom("citext").NotNullable() + .WithColumn("definitionkey").AsCustom("citext").NotNullable() + .WithColumn("version").AsInt32().NotNullable() + .WithColumn("state").AsInt32().NotNullable() + .WithColumn("lifecyclepreset").AsInt32().NotNullable() + .WithColumn("reviewerroleids").AsCustom("citext").Nullable() + .WithColumn("approverroleids").AsCustom("citext").Nullable() + .WithColumn("reviewduehours").AsInt32().Nullable() + .WithColumn("approveduehours").AsInt32().Nullable() + .WithColumn("requireauthorattestation").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("numberingjson").AsCustom("text").Nullable() + .WithColumn("retentionyears").AsInt32().Nullable() + .WithColumn("classification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("schemajson").AsCustom("text").Nullable() + .WithColumn("schemachecksum").AsCustom("citext").Nullable() + .WithColumn("minimumclientcapability").AsCustom("citext").Nullable() + .WithColumn("clientsurfacejson").AsCustom("text").Nullable() + .WithColumn("migrationmapjson").AsCustom("text").Nullable() + .WithColumn("changenotes").AsCustom("text").Nullable() + .WithColumn("publishedon").AsDateTime2().Nullable() + .WithColumn("publishedbyuserid").AsCustom("citext").Nullable() + .WithColumn("retiredon").AsDateTime2().Nullable() + .WithColumn("retiredbyuserid").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsCustom("citext").Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("modifiedbyuserid").AsCustom("citext").Nullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmsrecorddefinitionversions_department_key_version ON rmsrecorddefinitionversions (departmentid, definitionkey, version);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecorddefinitionversions_department_state ON rmsrecorddefinitionversions (departmentid, state);"); + } + + if (!Schema.Table("rmsrecordsectiondefinitions").Exists()) + { + Create.Table("rmsrecordsectiondefinitions") + .WithColumn("rmsrecordsectiondefinitionid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("rmsrecorddefinitionversionid").AsCustom("citext").NotNullable() + .WithColumn("definitionkey").AsCustom("citext").NotNullable() + .WithColumn("definitionversion").AsInt32().NotNullable() + .WithColumn("sectionkey").AsCustom("citext").NotNullable() + .WithColumn("label").AsCustom("citext").Nullable() + .WithColumn("help").AsCustom("text").Nullable() + .WithColumn("ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("isrepeating").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("minrows").AsInt32().Nullable() + .WithColumn("maxrows").AsInt32().Nullable() + .WithColumn("rulesjson").AsCustom("text").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmsrecordsectiondefinitions_version_key ON rmsrecordsectiondefinitions (departmentid, rmsrecorddefinitionversionid, sectionkey);"); + } + + if (!Schema.Table("rmsrecordfielddefinitions").Exists()) + { + Create.Table("rmsrecordfielddefinitions") + .WithColumn("rmsrecordfielddefinitionid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("rmsrecorddefinitionversionid").AsCustom("citext").NotNullable() + .WithColumn("definitionkey").AsCustom("citext").NotNullable() + .WithColumn("definitionversion").AsInt32().NotNullable() + .WithColumn("sectionkey").AsCustom("citext").NotNullable() + .WithColumn("fieldkey").AsCustom("citext").NotNullable() + .WithColumn("label").AsCustom("citext").Nullable() + .WithColumn("datatype").AsInt32().NotNullable() + .WithColumn("ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("required").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("requiredtofinalize").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("classification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("referencetype").AsCustom("citext").Nullable() + .WithColumn("searchable").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("filterable").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("sortable").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("groupable").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("aggregatable").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("workflowexposed").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("exportable").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("constraintsjson").AsCustom("text").Nullable() + .WithColumn("rulesjson").AsCustom("text").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmsrecordfielddefinitions_version_key ON rmsrecordfielddefinitions (departmentid, rmsrecorddefinitionversionid, fieldkey);"); + } + + } + + public override void Down() + { + if (Schema.Table("rmsrecordfielddefinitions").Exists()) + Delete.Table("rmsrecordfielddefinitions"); + if (Schema.Table("rmsrecordsectiondefinitions").Exists()) + Delete.Table("rmsrecordsectiondefinitions"); + if (Schema.Table("rmsrecorddefinitionversions").Exists()) + Delete.Table("rmsrecorddefinitionversions"); + if (Schema.Table("rmsrecorddefinitions").Exists()) + Delete.Table("rmsrecorddefinitions"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0159_AddRmsRecordValuesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0159_AddRmsRecordValuesPg.cs new file mode 100644 index 00000000..560e8c42 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0159_AddRmsRecordValuesPg.cs @@ -0,0 +1,86 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Records (RMS-1B) typed values (plan section 5.3, registry M0159): one discriminated table, one row per scalar / repeating-group cell / multi-select option, exactly one column group populated (check constraint plus the service guard), repeating rows in RmsRecordValueGroups, explicit equality/range indexes filtered to unprotected rows, inert ADP columns. + /// PostgreSQL twin of the SQL Server migration; lower-case identifiers, citext keys, existence-guarded. + /// + [Migration(159)] + public class M0159_AddRmsRecordValuesPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmsrecordvaluegroups").Exists()) + { + Create.Table("rmsrecordvaluegroups") + .WithColumn("rmsrecordvaluegroupid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("recordid").AsCustom("citext").NotNullable() + .WithColumn("recordkind").AsInt32().NotNullable() + .WithColumn("revisionid").AsCustom("citext").Nullable() + .WithColumn("rmsrecorddefinitionversionid").AsCustom("citext").Nullable() + .WithColumn("sectionkey").AsCustom("citext").NotNullable() + .WithColumn("ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("clientrowkey").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvaluegroups_department_record_revision ON rmsrecordvaluegroups (departmentid, recordid, revisionid);"); + } + + if (!Schema.Table("rmsrecordvalues").Exists()) + { + Create.Table("rmsrecordvalues") + .WithColumn("rmsrecordvalueid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("recordid").AsCustom("citext").NotNullable() + .WithColumn("recordkind").AsInt32().NotNullable() + .WithColumn("revisionid").AsCustom("citext").Nullable() + .WithColumn("rmsrecorddefinitionversionid").AsCustom("citext").Nullable() + .WithColumn("fieldkey").AsCustom("citext").NotNullable() + .WithColumn("rmsrecordvaluegroupid").AsCustom("citext").Nullable() + .WithColumn("ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("valuetype").AsInt32().NotNullable() + .WithColumn("textvalue").AsCustom("citext").Nullable() + .WithColumn("longtextvalue").AsCustom("text").Nullable() + .WithColumn("numbervalue").AsDecimal(28, 10).Nullable() + .WithColumn("boolvalue").AsBoolean().Nullable() + .WithColumn("datetimevalue").AsDateTime2().Nullable() + .WithColumn("datetimeoffsetminutes").AsInt32().Nullable() + .WithColumn("durationseconds").AsInt64().Nullable() + .WithColumn("unitcode").AsCustom("citext").Nullable() + .WithColumn("canonicalnumbervalue").AsDecimal(28, 10).Nullable() + .WithColumn("canonicalunitcode").AsCustom("citext").Nullable() + .WithColumn("currencycode").AsCustom("citext").Nullable() + .WithColumn("referencetype").AsCustom("citext").Nullable() + .WithColumn("referenceid").AsCustom("citext").Nullable() + .WithColumn("referencesnapshotjson").AsCustom("text").Nullable() + .WithColumn("optionkey").AsCustom("citext").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedenvelope").AsCustom("text").Nullable() + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_record_revision ON rmsrecordvalues (departmentid, recordid, revisionid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_version_field_text ON rmsrecordvalues (departmentid, rmsrecorddefinitionversionid, fieldkey, textvalue) WHERE isprotected = 0;"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_version_field_number ON rmsrecordvalues (departmentid, rmsrecorddefinitionversionid, fieldkey, numbervalue) WHERE isprotected = 0;"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_version_field_datetime ON rmsrecordvalues (departmentid, rmsrecorddefinitionversionid, fieldkey, datetimevalue) WHERE isprotected = 0;"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_revision ON rmsrecordvalues (departmentid, revisionid);"); + Execute.Sql("ALTER TABLE rmsrecordvalues ADD CONSTRAINT ck_rmsrecordvalues_onecolumngroup CHECK ((CASE WHEN textvalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN longtextvalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN numbervalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN boolvalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN datetimevalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN durationseconds IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN referenceid IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN optionkey IS NOT NULL THEN 1 ELSE 0 END) = 1 OR protectedenvelope IS NOT NULL);"); + } + + } + + public override void Down() + { + if (Schema.Table("rmsrecordvalues").Exists()) + Delete.Table("rmsrecordvalues"); + if (Schema.Table("rmsrecordvaluegroups").Exists()) + Delete.Table("rmsrecordvaluegroups"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0161_AddRmsSavedReportsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0161_AddRmsSavedReportsPg.cs new file mode 100644 index 00000000..0d5ed9cd --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0161_AddRmsSavedReportsPg.cs @@ -0,0 +1,46 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Records (RMS-1B) saved reports (plan section 4.1 reporting, registry M0161): department-owned allowlisted columns, bounded filters, one group-by and count/sum/avg/min/max, with explicit cross-version field mappings in SpecJson. + /// PostgreSQL twin of the SQL Server migration; lower-case identifiers, citext keys, existence-guarded. + /// + [Migration(161)] + public class M0161_AddRmsSavedReportsPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmssavedreportdefinitions").Exists()) + { + Create.Table("rmssavedreportdefinitions") + .WithColumn("rmssavedreportdefinitionid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("name").AsCustom("citext").NotNullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("definitionkey").AsCustom("citext").NotNullable() + .WithColumn("definitionversion").AsInt32().Nullable() + .WithColumn("specjson").AsCustom("text").Nullable() + .WithColumn("maxrowsperrun").AsInt32().NotNullable().WithDefaultValue(5000) + .WithColumn("includerestricted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("lastrunon").AsDateTime2().Nullable() + .WithColumn("lastrunbyuserid").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsCustom("citext").Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("modifiedbyuserid").AsCustom("citext").Nullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmssavedreportdefinitions_department ON rmssavedreportdefinitions (departmentid, definitionkey);"); + } + + } + + public override void Down() + { + if (Schema.Table("rmssavedreportdefinitions").Exists()) + Delete.Table("rmssavedreportdefinitions"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0162_AddRmsTemplatePacksAndJurisdictionProfilesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0162_AddRmsTemplatePacksAndJurisdictionProfilesPg.cs new file mode 100644 index 00000000..0cdf1df7 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0162_AddRmsTemplatePacksAndJurisdictionProfilesPg.cs @@ -0,0 +1,82 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Records (RMS-1C) product template packs and locked jurisdiction profiles (plan section 4.1, registry M0162): product-scope rows (DepartmentId 0) mirrored from the code catalog so departments see provenance, review dates, locales, units, currency and deprecation; a pack update never mutates a department clone. + /// PostgreSQL twin of the SQL Server migration; lower-case identifiers, citext keys, existence-guarded. + /// + [Migration(162)] + public class M0162_AddRmsTemplatePacksAndJurisdictionProfilesPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmstemplatepackversions").Exists()) + { + Create.Table("rmstemplatepackversions") + .WithColumn("rmstemplatepackversionid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("packkey").AsCustom("citext").NotNullable() + .WithColumn("version").AsInt32().NotNullable() + .WithColumn("name").AsCustom("citext").NotNullable() + .WithColumn("category").AsCustom("citext").Nullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("ispreview").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("definitionkeys").AsCustom("text").Nullable() + .WithColumn("supportedprofiles").AsCustom("citext").Nullable() + .WithColumn("supportedlocales").AsCustom("citext").Nullable() + .WithColumn("releasenotes").AsCustom("text").Nullable() + .WithColumn("sourceprovenancejson").AsCustom("text").Nullable() + .WithColumn("reviewedon").AsDateTime2().Nullable() + .WithColumn("artifactstatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("contentchecksum").AsCustom("citext").Nullable() + .WithColumn("isdeprecated").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("deprecatedbypackkey").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmstemplatepackversions_key_version ON rmstemplatepackversions (departmentid, packkey, version);"); + } + + if (!Schema.Table("rmsjurisdictionprofileversions").Exists()) + { + Create.Table("rmsjurisdictionprofileversions") + .WithColumn("rmsjurisdictionprofileversionid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("profilekey").AsCustom("citext").NotNullable() + .WithColumn("version").AsInt32().NotNullable() + .WithColumn("name").AsCustom("citext").NotNullable() + .WithColumn("country").AsCustom("citext").Nullable() + .WithColumn("subdivision").AsCustom("citext").Nullable() + .WithColumn("agencyscope").AsCustom("citext").Nullable() + .WithColumn("defaultlocale").AsCustom("citext").Nullable() + .WithColumn("supportedlocales").AsCustom("citext").Nullable() + .WithColumn("measurementsystem").AsCustom("citext").Nullable() + .WithColumn("currencycode").AsCustom("citext").Nullable() + .WithColumn("defaulttimezone").AsCustom("citext").Nullable() + .WithColumn("terminologyjson").AsCustom("text").Nullable() + .WithColumn("standardsjson").AsCustom("text").Nullable() + .WithColumn("classificationdefault").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("retentionyearsdefault").AsInt32().Nullable() + .WithColumn("requiredsections").AsCustom("text").Nullable() + .WithColumn("artifactstatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("reviewedon").AsDateTime2().Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmsjurisdictionprofileversions_key_version ON rmsjurisdictionprofileversions (departmentid, profilekey, version);"); + } + + } + + public override void Down() + { + if (Schema.Table("rmsjurisdictionprofileversions").Exists()) + Delete.Table("rmsjurisdictionprofileversions"); + if (Schema.Table("rmstemplatepackversions").Exists()) + Delete.Table("rmstemplatepackversions"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0163_AddRmsExternalOrderReferencesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0163_AddRmsExternalOrderReferencesPg.cs new file mode 100644 index 00000000..16158e18 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0163_AddRmsExternalOrderReferencesPg.cs @@ -0,0 +1,132 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Records (RMS-1C) external orders and fills (plan section 4.1 external-order fill contract, registry M0163): one manually entered, checksummed order snapshot per deployment Record with both home and host profiles, and one row per request/fill the department answers with its lifecycle times captured with local offset. No connector, no write-back. + /// PostgreSQL twin of the SQL Server migration; lower-case identifiers, citext keys, existence-guarded. + /// + [Migration(163)] + public class M0163_AddRmsExternalOrderReferencesPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmsexternalorders").Exists()) + { + Create.Table("rmsexternalorders") + .WithColumn("rmsexternalorderid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("recordid").AsCustom("citext").NotNullable() + .WithColumn("profilekey").AsCustom("citext").NotNullable() + .WithColumn("profileversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("homeprofilekey").AsCustom("citext").Nullable() + .WithColumn("hostprofilekey").AsCustom("citext").Nullable() + .WithColumn("sourcescheme").AsCustom("citext").Nullable() + .WithColumn("sourcesystem").AsCustom("citext").Nullable() + .WithColumn("ordernumber").AsCustom("citext").NotNullable() + .WithColumn("incidentname").AsCustom("citext").NotNullable() + .WithColumn("incidentnumber").AsCustom("citext").Nullable() + .WithColumn("incidentcountry").AsCustom("citext").Nullable() + .WithColumn("incidentsubdivision").AsCustom("citext").Nullable() + .WithColumn("orderingoffice").AsCustom("citext").Nullable() + .WithColumn("dispatchoffice").AsCustom("citext").Nullable() + .WithColumn("requestingagency").AsCustom("citext").Nullable() + .WithColumn("receivingagency").AsCustom("citext").Nullable() + .WithColumn("sendingagency").AsCustom("citext").Nullable() + .WithColumn("departmentrole").AsCustom("citext").Nullable() + .WithColumn("costcode").AsCustom("citext").Nullable() + .WithColumn("agreementreference").AsCustom("citext").Nullable() + .WithColumn("currencycode").AsCustom("citext").Nullable() + .WithColumn("measurementsystem").AsCustom("citext").Nullable() + .WithColumn("timezoneid").AsCustom("citext").Nullable() + .WithColumn("capturedoffsetminutes").AsInt32().Nullable() + .WithColumn("sourcecapturedon").AsDateTime2().Nullable() + .WithColumn("sourceversion").AsCustom("citext").Nullable() + .WithColumn("artifactfilename").AsCustom("citext").Nullable() + .WithColumn("artifactcontenttype").AsCustom("citext").Nullable() + .WithColumn("artifactchecksum").AsCustom("citext").Nullable() + .WithColumn("artifactdata").AsCustom("bytea").Nullable() + .WithColumn("artifactsafeurl").AsCustom("text").Nullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("mobilizedon").AsDateTime2().Nullable() + .WithColumn("releasedon").AsDateTime2().Nullable() + .WithColumn("closedouton").AsDateTime2().Nullable() + .WithColumn("closedoutbyuserid").AsCustom("citext").Nullable() + .WithColumn("closeoutnotes").AsCustom("text").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsCustom("citext").Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("modifiedbyuserid").AsCustom("citext").Nullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsexternalorders_department_record ON rmsexternalorders (departmentid, recordid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsexternalorders_department_order ON rmsexternalorders (departmentid, ordernumber);"); + } + + if (!Schema.Table("rmsexternalorderfills").Exists()) + { + Create.Table("rmsexternalorderfills") + .WithColumn("rmsexternalorderfillid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("rmsexternalorderid").AsCustom("citext").NotNullable() + .WithColumn("recordid").AsCustom("citext").NotNullable() + .WithColumn("requestnumber").AsCustom("citext").NotNullable() + .WithColumn("parentrequestnumber").AsCustom("citext").Nullable() + .WithColumn("requestcategory").AsCustom("citext").Nullable() + .WithColumn("fillnumber").AsCustom("citext").Nullable() + .WithColumn("resourcekind").AsCustom("citext").Nullable() + .WithColumn("resourcetype").AsCustom("citext").Nullable() + .WithColumn("resourcetypescheme").AsCustom("citext").Nullable() + .WithColumn("position").AsCustom("citext").Nullable() + .WithColumn("positionscheme").AsCustom("citext").Nullable() + .WithColumn("istrainee").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("homeunit").AsCustom("citext").Nullable() + .WithColumn("hostagency").AsCustom("citext").Nullable() + .WithColumn("agencyunitid").AsCustom("citext").Nullable() + .WithColumn("pointofhire").AsCustom("citext").Nullable() + .WithColumn("costcode").AsCustom("citext").Nullable() + .WithColumn("agreementreference").AsCustom("citext").Nullable() + .WithColumn("assigneduserid").AsCustom("citext").Nullable() + .WithColumn("assignedunitid").AsInt32().Nullable() + .WithColumn("qualificationsjson").AsCustom("text").Nullable() + .WithColumn("rosterjson").AsCustom("text").Nullable() + .WithColumn("traveljson").AsCustom("text").Nullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("declinereason").AsCustom("text").Nullable() + .WithColumn("requestedon").AsDateTime2().Nullable() + .WithColumn("neededon").AsDateTime2().Nullable() + .WithColumn("filledon").AsDateTime2().Nullable() + .WithColumn("mobilizedon").AsDateTime2().Nullable() + .WithColumn("checkedinon").AsDateTime2().Nullable() + .WithColumn("assignedon").AsDateTime2().Nullable() + .WithColumn("releasedon").AsDateTime2().Nullable() + .WithColumn("demobilizedon").AsDateTime2().Nullable() + .WithColumn("returnedon").AsDateTime2().Nullable() + .WithColumn("capturedoffsetminutes").AsInt32().Nullable() + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsCustom("citext").Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("modifiedbyuserid").AsCustom("citext").Nullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsexternalorderfills_department_order ON rmsexternalorderfills (departmentid, rmsexternalorderid);"); + } + + } + + public override void Down() + { + if (Schema.Table("rmsexternalorderfills").Exists()) + Delete.Table("rmsexternalorderfills"); + if (Schema.Table("rmsexternalorders").Exists()) + Delete.Table("rmsexternalorders"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0178_RmsProtectedDataCatalogV11Pg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0178_RmsProtectedDataCatalogV11Pg.cs new file mode 100644 index 00000000..c64f7a96 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0178_RmsProtectedDataCatalogV11Pg.cs @@ -0,0 +1,34 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// RMS Advanced Data Protection catalog v11 (RMS plan section 5.9.4 (e), registry M0178): typed values of + /// department definitions. A row whose field is Protected-classified is flagged protectionrequired at write time; + /// the ADP seam seals its typed columns into the existing protectedenvelope column and the migration sweep only + /// visits flagged rows. Guarded for safe retry. + /// + [Migration(178)] + public class M0178_RmsProtectedDataCatalogV11Pg : Migration + { + public override void Up() + { + if (!Schema.Table("rmsrecordvalues").Exists()) + return; + + if (!Schema.Table("rmsrecordvalues").Column("protectionrequired").Exists()) + Alter.Table("rmsrecordvalues").AddColumn("protectionrequired").AsBoolean().NotNullable().WithDefaultValue(false); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_protectionrequired ON rmsrecordvalues (departmentid, rmsrecordvalueid) WHERE protectionrequired = TRUE;"); + } + + public override void Down() + { + if (!Schema.Table("rmsrecordvalues").Exists()) + return; + Execute.Sql("DROP INDEX IF EXISTS ix_rmsrecordvalues_department_protectionrequired;"); + if (Schema.Table("rmsrecordvalues").Column("protectionrequired").Exists()) + Delete.Column("protectionrequired").FromTable("rmsrecordvalues"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0179_AddRmsRecordWorkAssignmentsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0179_AddRmsRecordWorkAssignmentsPg.cs new file mode 100644 index 00000000..1ddab0cd --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0179_AddRmsRecordWorkAssignmentsPg.cs @@ -0,0 +1,60 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Work assignments on Records (RMS plan section 5.2 RmsRecordWorkAssignment, RMS-1D, registry M0179): an + /// optional person / unit / group / command-role / dispatch-role assignment with purpose, due, acknowledged, + /// completed and cancelled state, safe source context and the client origin. Guarded for safe retry. + /// + [Migration(179)] + public class M0179_AddRmsRecordWorkAssignmentsPg : Migration + { + public override void Up() + { + if (Schema.Table("rmsrecordworkassignments").Exists()) + return; + + Create.Table("rmsrecordworkassignments") + .WithColumn("rmsrecordworkassignmentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("assigneekind").AsInt32().NotNullable() + .WithColumn("assigneeuserid").AsString(128).Nullable() + .WithColumn("assigneeunitid").AsInt32().Nullable() + .WithColumn("assigneegroupid").AsInt32().Nullable() + .WithColumn("assigneerole").AsString(100).Nullable() + .WithColumn("purpose").AsString(32).NotNullable() + .WithColumn("note").AsString(1000).Nullable() + .WithColumn("sourcecontextjson").AsString(1000).Nullable() + .WithColumn("dueon").AsDateTime2().Nullable() + .WithColumn("state").AsInt32().NotNullable() + .WithColumn("acknowledgedon").AsDateTime2().Nullable() + .WithColumn("acknowledgedbyuserid").AsString(128).Nullable() + .WithColumn("completedon").AsDateTime2().Nullable() + .WithColumn("completedbyuserid").AsString(128).Nullable() + .WithColumn("cancelledon").AsDateTime2().Nullable() + .WithColumn("cancelledbyuserid").AsString(128).Nullable() + .WithColumn("cancelreason").AsString(500).Nullable() + .WithColumn("originclient").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("modifiedbyuserid").AsString(128).Nullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordworkassignments_record ON rmsrecordworkassignments (departmentid, recordid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordworkassignments_person ON rmsrecordworkassignments (departmentid, assigneeuserid, state);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordworkassignments_unit ON rmsrecordworkassignments (departmentid, assigneeunitid, state);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordworkassignments_modified ON rmsrecordworkassignments (departmentid, modifiedon);"); + } + + public override void Down() + { + if (Schema.Table("rmsrecordworkassignments").Exists()) + Delete.Table("rmsrecordworkassignments"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs index a2b367b6..6cae74d7 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs @@ -153,7 +153,8 @@ public async Task CountTextResidueAsync(AdpTableBinding binding, int depar CancellationToken cancellationToken = default) { var textColumns = binding.Columns.Where(c => c.StorageKind == ProtectedFieldStorageKind.Text).ToList(); - if (textColumns.Count == 0) + var packedColumns = binding.Columns.Where(c => c.StorageKind == ProtectedFieldStorageKind.PackedJson).ToList(); + if (textColumns.Count == 0 && packedColumns.Count == 0) return 0; // Note: citext makes LIKE case-insensitive on PostgreSQL. Envelopes are always written @@ -162,7 +163,14 @@ public async Task CountTextResidueAsync(AdpTableBinding binding, int depar // suspect. Acceptable for a residue gate that requires zero. var predicates = textColumns.Select(c => enveloped ? $"({Ident(c.ColumnName)} LIKE 'rgdp:%')" - : $"({Ident(c.ColumnName)} IS NOT NULL AND {Ident(c.ColumnName)} <> '' AND {Ident(c.ColumnName)} NOT LIKE 'rgdp:%')"); + : $"({Ident(c.ColumnName)} IS NOT NULL AND {Ident(c.ColumnName)} <> '' AND {Ident(c.ColumnName)} NOT LIKE 'rgdp:%')").ToList(); + + // PackedJson (catalog v11): enrollment residue is a row still carrying its typed siblings (no envelope yet); + // offboarding residue is a row still carrying an envelope. The row filter in Scope() keeps this to the rows + // that need protection at all. + predicates.AddRange(packedColumns.Select(c => enveloped + ? $"({Ident(c.ColumnName)} LIKE 'rgdp:%')" + : $"({Ident(c.ColumnName)} IS NULL)")); return await CountWhereAsync(binding, departmentId, string.Join(" OR ", predicates), cancellationToken); } @@ -210,6 +218,7 @@ public async Task CountSupersededKeyVersionResidueAsync(AdpTableBinding bi switch (column.StorageKind) { case ProtectedFieldStorageKind.Text: + case ProtectedFieldStorageKind.PackedJson: predicates.Add(TextSupersededPredicate(Ident(column.ColumnName), textTargetPrefix)); break; @@ -283,15 +292,25 @@ private List SelectColumns(AdpTableBinding binding) if (!string.IsNullOrEmpty(binding.ProtectedMarkerColumn)) columns.Add(binding.ProtectedMarkerColumn); + // PackedJson carriers ride along so the engine can pack them on the way in and unpack on the way out. + if (binding.CarrierColumns != null) + columns.AddRange(binding.CarrierColumns); + return columns.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } private string Scope(AdpTableBinding binding) { - if (!string.IsNullOrEmpty(binding.DepartmentColumn)) - return $"{Ident(binding.DepartmentColumn)} = @DepartmentId"; + var scope = !string.IsNullOrEmpty(binding.DepartmentColumn) + ? $"{Ident(binding.DepartmentColumn)} = @DepartmentId" + : $"{Ident(binding.ParentFkColumn)} IN (SELECT {Ident(binding.ParentPkColumn)} FROM {Table(binding.ParentTable)} WHERE {Ident("DepartmentId")} = @DepartmentId)"; + + // A boolean row filter narrows the sweep (RmsRecordValues.ProtectionRequired): rows outside it are never + // read, counted or verified, so a Standard-classified value stays plaintext by construction. + if (!string.IsNullOrEmpty(binding.RowFilterColumn)) + scope = $"({scope} AND {Ident(binding.RowFilterColumn)} = {(_isPostgres ? "TRUE" : "1")})"; - return $"{Ident(binding.ParentFkColumn)} IN (SELECT {Ident(binding.ParentPkColumn)} FROM {Table(binding.ParentTable)} WHERE {Ident("DepartmentId")} = @DepartmentId)"; + return scope; } private string Table(string name) => diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 248d1818..bd03f47f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -317,6 +317,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Field Records work assignments (RMS-1D, registry M0179) + builder.RegisterType().As().InstancePerLifetimeScope(); // NERIS incident report aggregate (RMS-2, registry M0164-M0166) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -354,6 +356,18 @@ protected override void Load(ContainerBuilder builder) // RMS department report exports (registry M0177, worker 45) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-1B definitions, typed values, saved reports; RMS-1C packs, profiles, external orders (registry M0158-M0163) + 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(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 56638028..2d3bc5b9 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -277,6 +277,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Field Records work assignments (RMS-1D, registry M0179) + builder.RegisterType().As().InstancePerLifetimeScope(); // NERIS incident report aggregate (RMS-2, registry M0164-M0166) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -314,6 +316,18 @@ protected override void Load(ContainerBuilder builder) // RMS department report exports (registry M0177, worker 45) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-1B definitions, typed values, saved reports; RMS-1C packs, profiles, external orders (registry M0158-M0163) + 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(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs new file mode 100644 index 00000000..48f73e5f --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs @@ -0,0 +1,232 @@ +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-1B/1C repositories (registry M0158, M0159, M0161, M0162, M0163). Every department query begins at + // DepartmentId; the product catalog tables are read with DepartmentId 0. + + public class RmsRecordDefinitionsRepository : RmsRepositoryBase, IRmsRecordDefinitionsRepository + { + public RmsRecordDefinitionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByKeyAsync(int departmentId, string definitionKey) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsRecordDefinitions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DefinitionKey")} = {P}Key AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Key = definitionKey }); + + public Task GetByIdForDepartmentAsync(int departmentId, string definitionId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsRecordDefinitions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordDefinitionId")} = {P}Id AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = definitionId }); + + public Task> GetForDepartmentAsync(int departmentId, bool includeRetired) + { + var retired = includeRetired ? string.Empty : $" AND {Col("IsRetired")} = {(IsPostgres ? "FALSE" : "0")}"; + return QueryAsync($"SELECT * FROM {Tbl("RmsRecordDefinitions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL{retired} ORDER BY {Col("Name")}", new { DepartmentId = departmentId }); + } + + public async Task TryBumpRowVersionAsync(int departmentId, string definitionId, long expectedVersion, CancellationToken cancellationToken = default) + => await ExecuteAsync($"UPDATE {Tbl("RmsRecordDefinitions")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordDefinitionId")} = {P}Id AND {Col("RowVersion")} = {P}Version", new { DepartmentId = departmentId, Id = definitionId, Version = expectedVersion }, cancellationToken) == 1; + } + + public class RmsRecordDefinitionVersionsRepository : RmsRepositoryBase, IRmsRecordDefinitionVersionsRepository + { + public RmsRecordDefinitionVersionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string versionId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsRecordDefinitionVersions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordDefinitionVersionId")} = {P}Id", new { DepartmentId = departmentId, Id = versionId }); + + public Task GetAsync(int departmentId, string definitionKey, int version) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsRecordDefinitionVersions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DefinitionKey")} = {P}Key AND {Col("Version")} = {P}Version", new { DepartmentId = departmentId, Key = definitionKey, Version = version }); + + public Task> GetForDefinitionAsync(int departmentId, string definitionKey) + => QueryAsync($"SELECT * FROM {Tbl("RmsRecordDefinitionVersions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DefinitionKey")} = {P}Key ORDER BY {Col("Version")}", new { DepartmentId = departmentId, Key = definitionKey }); + + public Task> GetPublishedForDepartmentAsync(int departmentId) + => QueryAsync($"SELECT * FROM {Tbl("RmsRecordDefinitionVersions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {(int)RmsDefinitionVersionState.Published} ORDER BY {Col("DefinitionKey")}, {Col("Version")}", new { DepartmentId = departmentId }); + + public async Task TryBumpRowVersionAsync(int departmentId, string versionId, long expectedVersion, CancellationToken cancellationToken = default) + => await ExecuteAsync($"UPDATE {Tbl("RmsRecordDefinitionVersions")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordDefinitionVersionId")} = {P}Id AND {Col("RowVersion")} = {P}Version", new { DepartmentId = departmentId, Id = versionId, Version = expectedVersion }, cancellationToken) == 1; + } + + public class RmsRecordSectionDefinitionsRepository : RmsRepositoryBase, IRmsRecordSectionDefinitionsRepository + { + public RmsRecordSectionDefinitionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForVersionAsync(int departmentId, string versionId) + => QueryAsync($"SELECT * FROM {Tbl("RmsRecordSectionDefinitions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordDefinitionVersionId")} = {P}VersionId ORDER BY {Col("Ordinal")}", new { DepartmentId = departmentId, VersionId = versionId }); + + public Task DeleteForVersionAsync(int departmentId, string versionId, CancellationToken cancellationToken = default) + => ExecuteAsync($"DELETE FROM {Tbl("RmsRecordSectionDefinitions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordDefinitionVersionId")} = {P}VersionId", new { DepartmentId = departmentId, VersionId = versionId }, cancellationToken); + } + + public class RmsRecordFieldDefinitionsRepository : RmsRepositoryBase, IRmsRecordFieldDefinitionsRepository + { + public RmsRecordFieldDefinitionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForVersionAsync(int departmentId, string versionId) + => QueryAsync($"SELECT * FROM {Tbl("RmsRecordFieldDefinitions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordDefinitionVersionId")} = {P}VersionId ORDER BY {Col("SectionKey")}, {Col("Ordinal")}", new { DepartmentId = departmentId, VersionId = versionId }); + + public Task DeleteForVersionAsync(int departmentId, string versionId, CancellationToken cancellationToken = default) + => ExecuteAsync($"DELETE FROM {Tbl("RmsRecordFieldDefinitions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordDefinitionVersionId")} = {P}VersionId", new { DepartmentId = departmentId, VersionId = versionId }, cancellationToken); + } + + public class RmsRecordValueGroupsRepository : RmsRepositoryBase, IRmsRecordValueGroupsRepository + { + public RmsRecordValueGroupsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForRecordAsync(int departmentId, string recordId, string revisionId) + { + var revision = revisionId == null ? $"{Col("RevisionId")} IS NULL" : $"{Col("RevisionId")} = {P}RevisionId"; + return QueryAsync($"SELECT * FROM {Tbl("RmsRecordValueGroups")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {revision} ORDER BY {Col("SectionKey")}, {Col("Ordinal")}", new { DepartmentId = departmentId, RecordId = recordId, RevisionId = revisionId }); + } + + public Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds, bool draftsOnly) + { + var ids = (recordIds ?? Enumerable.Empty()).Distinct().ToArray(); + if (ids.Length == 0) return Task.FromResult(Enumerable.Empty()); + var revision = draftsOnly ? $" AND {Col("RevisionId")} IS NULL" : string.Empty; + return QueryAsync($"SELECT * FROM {Tbl("RmsRecordValueGroups")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RecordId", "Ids")}{revision}", new { DepartmentId = departmentId, Ids = ids }); + } + + public Task> GetForRevisionsAsync(int departmentId, IEnumerable revisionIds) + { + var ids = (revisionIds ?? Enumerable.Empty()).Distinct().ToArray(); + if (ids.Length == 0) return Task.FromResult(Enumerable.Empty()); + return QueryAsync($"SELECT * FROM {Tbl("RmsRecordValueGroups")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RevisionId", "Ids")}", new { DepartmentId = departmentId, Ids = ids }); + } + + public Task DeleteDraftForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default) + => ExecuteAsync($"DELETE FROM {Tbl("RmsRecordValueGroups")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("RevisionId")} IS NULL", new { DepartmentId = departmentId, RecordId = recordId }, cancellationToken); + } + + public class RmsRecordValuesRepository : RmsRepositoryBase, IRmsRecordValuesRepository + { + public RmsRecordValuesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForRecordAsync(int departmentId, string recordId, string revisionId) + { + var revision = revisionId == null ? $"{Col("RevisionId")} IS NULL" : $"{Col("RevisionId")} = {P}RevisionId"; + return QueryAsync($"SELECT * FROM {Tbl("RmsRecordValues")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {revision} ORDER BY {Col("FieldKey")}, {Col("Ordinal")}", new { DepartmentId = departmentId, RecordId = recordId, RevisionId = revisionId }); + } + + public Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds, bool draftsOnly) + { + var ids = (recordIds ?? Enumerable.Empty()).Distinct().ToArray(); + if (ids.Length == 0) return Task.FromResult(Enumerable.Empty()); + var revision = draftsOnly ? $" AND {Col("RevisionId")} IS NULL" : string.Empty; + return QueryAsync($"SELECT * FROM {Tbl("RmsRecordValues")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RecordId", "Ids")}{revision}", new { DepartmentId = departmentId, Ids = ids }); + } + + public Task> GetForRevisionsAsync(int departmentId, IEnumerable revisionIds) + { + var ids = (revisionIds ?? Enumerable.Empty()).Distinct().ToArray(); + if (ids.Length == 0) return Task.FromResult(Enumerable.Empty()); + return QueryAsync($"SELECT * FROM {Tbl("RmsRecordValues")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RevisionId", "Ids")}", new { DepartmentId = departmentId, Ids = ids }); + } + + public Task DeleteDraftForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default) + => ExecuteAsync($"DELETE FROM {Tbl("RmsRecordValues")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("RevisionId")} IS NULL", new { DepartmentId = departmentId, RecordId = recordId }, cancellationToken); + + public Task CountRecordsOnVersionAsync(int departmentId, string versionId, bool draftsOnly) + { + var revision = draftsOnly ? $" AND {Col("RevisionId")} IS NULL" : string.Empty; + return ScalarAsync($"SELECT COUNT(DISTINCT {Col("RecordId")}) FROM {Tbl("RmsRecordValues")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordDefinitionVersionId")} = {P}VersionId{revision}", new { DepartmentId = departmentId, VersionId = versionId }); + } + } + + public class RmsSavedReportDefinitionsRepository : RmsRepositoryBase, IRmsSavedReportDefinitionsRepository + { + public RmsSavedReportDefinitionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string reportId) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsSavedReportDefinitions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsSavedReportDefinitionId")} = {P}Id AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = reportId }); + + public Task> GetForDepartmentAsync(int departmentId) + => QueryAsync($"SELECT * FROM {Tbl("RmsSavedReportDefinitions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL ORDER BY {Col("Name")}", new { DepartmentId = departmentId }); + + public async Task TryBumpRowVersionAsync(int departmentId, string reportId, long expectedVersion, CancellationToken cancellationToken = default) + => await ExecuteAsync($"UPDATE {Tbl("RmsSavedReportDefinitions")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsSavedReportDefinitionId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = reportId, Version = expectedVersion }, cancellationToken) == 1; + } + + public class RmsTemplatePackVersionsRepository : RmsRepositoryBase, IRmsTemplatePackVersionsRepository + { + public RmsTemplatePackVersionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetCatalogAsync() + => QueryAsync($"SELECT * FROM {Tbl("RmsTemplatePackVersions")} WHERE {Col("DepartmentId")} = {P}DepartmentId ORDER BY {Col("PackKey")}, {Col("Version")}", new { DepartmentId = RmsTemplatePackVersion.ProductDepartmentId }); + + public Task GetAsync(string packKey, int version) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsTemplatePackVersions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("PackKey")} = {P}Key AND {Col("Version")} = {P}Version", new { DepartmentId = RmsTemplatePackVersion.ProductDepartmentId, Key = packKey, Version = version }); + } + + public class RmsJurisdictionProfileVersionsRepository : RmsRepositoryBase, IRmsJurisdictionProfileVersionsRepository + { + public RmsJurisdictionProfileVersionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetCatalogAsync() + => QueryAsync($"SELECT * FROM {Tbl("RmsJurisdictionProfileVersions")} WHERE {Col("DepartmentId")} = {P}DepartmentId ORDER BY {Col("ProfileKey")}, {Col("Version")}", new { DepartmentId = RmsTemplatePackVersion.ProductDepartmentId }); + + public Task GetAsync(string profileKey, int version) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsJurisdictionProfileVersions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ProfileKey")} = {P}Key AND {Col("Version")} = {P}Version", new { DepartmentId = RmsTemplatePackVersion.ProductDepartmentId, Key = profileKey, Version = version }); + + public Task GetLatestAsync(string profileKey) + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsJurisdictionProfileVersions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ProfileKey")} = {P}Key ORDER BY {Col("Version")} DESC", new { DepartmentId = RmsTemplatePackVersion.ProductDepartmentId, Key = profileKey }); + } + + public class RmsExternalOrdersRepository : RmsRepositoryBase, IRmsExternalOrdersRepository + { + 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", + "Status", "MobilizedOn", "ReleasedOn", "ClosedOutOn", "ClosedOutByUserId", "CloseoutNotes", "IsProtected", "ProtectedCatalogVersion", "CreatedOn", "CreatedByUserId", "ModifiedOn", "ModifiedByUserId", "RowVersion", "DeletedOn"); + + public RmsExternalOrdersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string orderId, bool includeArtifact) + => QueryFirstOrDefaultAsync($"SELECT {(includeArtifact ? "*" : MetadataColumns)} FROM {Tbl("RmsExternalOrders")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderId")} = {P}Id AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = orderId }); + + public Task GetForRecordAsync(int departmentId, string recordId) + => QueryFirstOrDefaultAsync($"SELECT {MetadataColumns} FROM {Tbl("RmsExternalOrders")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, RecordId = recordId }); + + public Task> GetForDepartmentAsync(int departmentId, bool includeClosed) + { + var closed = includeClosed ? string.Empty : $" AND {Col("Status")} <> {(int)RmsExternalOrderStatus.ClosedOut}"; + return QueryAsync($"SELECT {MetadataColumns} FROM {Tbl("RmsExternalOrders")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL{closed} ORDER BY {Col("CreatedOn")} DESC", new { DepartmentId = departmentId }); + } + + public Task GetArtifactAsync(int departmentId, string orderId) + => ScalarAsync($"SELECT {Col("ArtifactData")} FROM {Tbl("RmsExternalOrders")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderId")} = {P}Id AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = orderId }); + + public async Task TryBumpRowVersionAsync(int departmentId, string orderId, long expectedVersion, CancellationToken cancellationToken = default) + => await ExecuteAsync($"UPDATE {Tbl("RmsExternalOrders")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = orderId, Version = expectedVersion }, cancellationToken) == 1; + } + + public class RmsExternalOrderFillsRepository : RmsRepositoryBase, IRmsExternalOrderFillsRepository + { + public RmsExternalOrderFillsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForOrderAsync(int departmentId, string orderId) + => QueryAsync($"SELECT * FROM {Tbl("RmsExternalOrderFills")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderId")} = {P}OrderId AND {Col("DeletedOn")} IS NULL ORDER BY {Col("RequestNumber")}", new { DepartmentId = departmentId, OrderId = orderId }); + + 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 }); + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsExportRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsExportRepositories.cs index 5d9c4d32..a9f5ef73 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsExportRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsExportRepositories.cs @@ -52,6 +52,16 @@ public async Task TryBumpRowVersionAsync(int departmentId, string template $"UPDATE {Tbl("RmsExportTemplates")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExportTemplateId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = templateId, Version = expectedVersion }, cancellationToken) == 1; } + + public async Task TryClaimDueAsync(int departmentId, string templateId, DateTime expectedNextRunOn, DateTime deferUntil, DateTime utcNow, + CancellationToken cancellationToken = default) + { + // NextRunOn is both the due marker and the claim: matching and moving it in one statement means a + // concurrent sweep reading the same due row loses the race and skips the template. + return await ExecuteAsync( + $"UPDATE {Tbl("RmsExportTemplates")} SET {Col("NextRunOn")} = {P}Defer, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExportTemplateId")} = {P}Id AND {Col("NextRunOn")} = {P}Expected AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, Id = templateId, Expected = expectedNextRunOn, Defer = deferUntil, Now = utcNow }, cancellationToken) == 1; + } } /// Rendered export artifacts (registry M0177). The Data column is read only by the endpoints that serve the file. diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs new file mode 100644 index 00000000..0b6c86b0 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs @@ -0,0 +1,89 @@ +using System; +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 +{ + /// Work assignments (RMS-1D, registry M0179). Dapper over RmsRepositoryBase; list parameters go through InList so Postgres binds arrays. + public class RmsRecordWorkAssignmentsRepository : RmsRepositoryBase, IRmsRecordWorkAssignmentsRepository + { + public RmsRecordWorkAssignmentsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string assignmentId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsRecordWorkAssignments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordWorkAssignmentId")} = {P}Id", + new { DepartmentId = departmentId, Id = assignmentId }); + } + + public Task> GetForRecordAsync(int departmentId, string recordId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordWorkAssignments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("DeletedOn")} IS NULL ORDER BY {Col("CreatedOn")}", + new { DepartmentId = departmentId, RecordId = recordId }); + } + + public Task> GetOpenForAssigneesAsync(int departmentId, string userId, IEnumerable unitIds, IEnumerable groupIds, IEnumerable roles, int take) + { + var units = InListValue(unitIds); + var groups = InListValue(groupIds); + var roleList = (roles ?? Enumerable.Empty()).Where(r => !string.IsNullOrWhiteSpace(r)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var clauses = new List(); + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("Skip", 0); + parameters.Add("Take", take <= 0 ? 200 : Math.Min(take, 1000)); + if (!string.IsNullOrWhiteSpace(userId)) + { + clauses.Add($"({Col("AssigneeKind")} = {(int)RmsWorkAssigneeKind.Person} AND {Col("AssigneeUserId")} = {P}UserId)"); + parameters.Add("UserId", userId); + } + if (units.Length > 0) + { + clauses.Add($"({Col("AssigneeKind")} = {(int)RmsWorkAssigneeKind.Unit} AND {InList("AssigneeUnitId", "UnitIds")})"); + parameters.Add("UnitIds", units); + } + if (groups.Length > 0) + { + clauses.Add($"({Col("AssigneeKind")} = {(int)RmsWorkAssigneeKind.Group} AND {InList("AssigneeGroupId", "GroupIds")})"); + parameters.Add("GroupIds", groups); + } + if (roleList.Length > 0) + { + clauses.Add($"({Col("AssigneeKind")} IN ({(int)RmsWorkAssigneeKind.CommandRole}, {(int)RmsWorkAssigneeKind.DispatchRole}) AND {InList("AssigneeRole", "Roles")})"); + parameters.Add("Roles", roleList); + } + if (clauses.Count == 0) + return Task.FromResult>(new List()); + + return QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordWorkAssignments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {Col("State")} IN ({(int)RmsWorkAssignmentState.Open}, {(int)RmsWorkAssignmentState.Acknowledged}) AND ({string.Join(" OR ", clauses)}) ORDER BY {Col("DueOn")}, {Col("CreatedOn")}, {Col("RmsRecordWorkAssignmentId")} {Paging()}", + parameters); + } + + public Task> GetModifiedSinceAsync(int departmentId, DateTime? since, int take) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("Skip", 0); + parameters.Add("Take", take <= 0 ? 200 : Math.Min(take, 1000)); + var sinceClause = string.Empty; + if (since.HasValue) + { + sinceClause = $" AND {Col("ModifiedOn")} > {P}Since"; + parameters.Add("Since", since.Value); + } + return QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordWorkAssignments")} WHERE {Col("DepartmentId")} = {P}DepartmentId{sinceClause} ORDER BY {Col("ModifiedOn")}, {Col("RmsRecordWorkAssignmentId")} {Paging()}", + parameters); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs index 9027d040..035ef837 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs @@ -115,6 +115,16 @@ public Task> GetRetentionCandidatesAsync(int depa where.Append($" AND {C("StationGroupId")} = {P}StationGroupId"); parameters.Add("StationGroupId", query.StationGroupId.Value); } + if (query.FinalizedOnStart.HasValue) + { + where.Append($" AND {C("FinalizedOn")} IS NOT NULL AND {C("FinalizedOn")} >= {P}FinalizedOnStart"); + parameters.Add("FinalizedOnStart", query.FinalizedOnStart.Value); + } + if (query.FinalizedOnEnd.HasValue) + { + where.Append($" AND {C("FinalizedOn")} IS NOT NULL AND {C("FinalizedOn")} < {P}FinalizedOnEnd"); + parameters.Add("FinalizedOnEnd", query.FinalizedOnEnd.Value); + } if (query.VisibleGroupIds != null) { diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index 650778d0..232f0e8e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -297,6 +297,23 @@ public Task> GetByOwnerAndStatesAsync(int depa new { DepartmentId = departmentId, OwnerUserId = ownerUserId, States = InListValue(states) }); } + public Task> GetByDefinitionVersionAsync(int departmentId, string definitionKey, int definitionVersion, IEnumerable states) + { + return QueryAsync( + $"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DefinitionKey")} = {P}DefinitionKey AND {Col("DefinitionVersion")} = {P}DefinitionVersion AND {InList("State", "States")} AND {Col("DeletedOn")} IS NULL AND {Col("PurgedOn")} IS NULL ORDER BY {Col("CreatedOn")}", + new { DepartmentId = departmentId, DefinitionKey = definitionKey, DefinitionVersion = definitionVersion, States = InListValue(states) }); + } + + public Task> GetByIdsAsync(int departmentId, IEnumerable recordIds) + { + var ids = (recordIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToArray(); + if (ids.Length == 0) + return Task.FromResult>(new List()); + return QueryAsync( + $"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsOperationalRecordId", "Ids")} AND {Col("DeletedOn")} IS NULL AND {Col("PurgedOn")} IS NULL", + new { DepartmentId = departmentId, Ids = ids }); + } + public Task> GetByDepartmentAndStatesAsync(int departmentId, IEnumerable states, int? year, int skip, int take) { var yearClause = year.HasValue ? $" AND {YearOf($"COALESCE({Col("StartedOn")}, {Col("CreatedOn")})")} = {P}Year" : string.Empty; diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index 42ccd126..bf67a8af 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -63,6 +63,8 @@ "RecordSubmissionRejected": 110, "RecordSubmissionFailed": 111, "RecordOverdue": 112, + "RecordDefinitionPublished": 113, + "RecordDefinitionRetired": 114, "RecordApproved": 103, "RecordAttachmentAdded": 115, "RecordDisclosureRequested": 152, diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs index 3affc65d..b683e731 100644 --- a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs +++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs @@ -85,6 +85,27 @@ private static Dictionary Load(string path) "Records|fr|ExportTemplateDescription", "Records|pl|ExportFormat", "Records|sv|ExportFormat", + // Records definitions/deployments (2026-09-06): single words that are the same in the target language. + "Records|de|DefinitionName", // "Name" + "Records|de|DefinitionVersion", // "Version" + "Records|de|DefinitionSchema", // "Schema (JSON)" + "Records|de|DeploymentStatus", // "Status" + "Records|de|DeploymentPosition", // "Position" + "Records|es|No", + "Records|it|No", + "Records|it|DefinitionSchema", // "Schema (JSON)" + "Records|fr|TemplateSources", // "Sources" + "Records|fr|TemplateSections", // "Sections" + "Records|fr|DefinitionDescription", // "Description" + "Records|fr|DefinitionVersion", // "Version" + "Records|fr|DefinitionClassification", + "Records|fr|FieldClassification", + "Records|fr|DeploymentIncident", // "Incident" + "Records|fr|DeploymentSource", // "Source" + "Records|pl|DeploymentStatus", // "Status" + "Records|sv|DefinitionVersion", // "Version" + "Records|sv|DefinitionSchema", // "Schema (JSON)" + "Records|sv|DeploymentStatus", // "Status" // Brand and protocol names carry across every language. "CommunicationTest|de|Push", "CommunicationTest|de|SMS", @@ -129,6 +150,10 @@ private static Dictionary Load(string path) "Records|de|RequesterOrganization", // "Organisation" is German as well. "Records|sv|RequesterOrganization", // And Swedish. "Records|es|Error", // "Error" is the Spanish word. + "Records|es|LayoutVisible", // "Visible" is the Spanish word. + "Records|fr|LayoutVisible", // "Visible" is the French word too. + "Records|fr|ProjectionKind", // "Projection" is French as well. + "Records|fr|Projectionqualifications", // "Qualifications" is French as well. "Records|fr|EvidenceSource", // "Source" is French to begin with. "Records|pl|Model", // Polish spells it "Model" as well. "Records|pl|SearchOnline", "Records|pl|SearchOffline", // Polish uses them verbatim. diff --git a/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs b/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs index 11312049..72e20b14 100644 --- a/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs +++ b/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs @@ -279,6 +279,10 @@ private IEnumerable MatchReports(int departmentId, RmsInciden rows = rows.Where(x => x.OwnerUserId == query.OwnerUserId); if (query?.StationGroupId != null) rows = rows.Where(x => x.StationGroupId == query.StationGroupId.Value); + if (query?.FinalizedOnStart != null) + rows = rows.Where(x => x.FinalizedOn != null && x.FinalizedOn.Value >= query.FinalizedOnStart.Value); + if (query?.FinalizedOnEnd != null) + rows = rows.Where(x => x.FinalizedOn != null && x.FinalizedOn.Value < query.FinalizedOnEnd.Value); return rows; } diff --git a/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs b/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs new file mode 100644 index 00000000..64418412 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Moq; +using Resgrid.Model; +using Resgrid.Model.Repositories; + +namespace Resgrid.Tests.Rms +{ + /// + /// In-memory stand-ins for the RMS-1B/1C repositories (definitions, versions, materialized sections/fields, typed values, + /// saved reports, template packs, jurisdiction profiles, external orders). Only the members the services call are wired. + /// Pass a to also wire the record-side lookups the definition services use. + /// + public sealed class FakeRmsDefinitionStore + { + public List Definitions { get; } = new List(); + public List Versions { get; } = new List(); + public List Sections { get; } = new List(); + public List Fields { get; } = new List(); + public List Groups { get; } = new List(); + public List Values { get; } = new List(); + public List Reports { get; } = new List(); + public List Packs { get; } = new List(); + public List Profiles { get; } = new List(); + public List Orders { get; } = new List(); + public List Fills { get; } = new List(); + public List References { get; } = new List(); + + public Mock DefinitionsRepo { get; } = new Mock(); + public Mock VersionsRepo { get; } = new Mock(); + public Mock SectionsRepo { get; } = new Mock(); + public Mock FieldsRepo { get; } = new Mock(); + public Mock GroupsRepo { get; } = new Mock(); + public Mock ValuesRepo { get; } = new Mock(); + public Mock ReportsRepo { get; } = new Mock(); + public Mock PacksRepo { get; } = new Mock(); + public Mock ProfilesRepo { get; } = new Mock(); + public Mock OrdersRepo { get; } = new Mock(); + public Mock FillsRepo { get; } = new Mock(); + public Mock ReferencesRepo { get; } = new Mock(); + + public FakeRmsDefinitionStore(FakeRmsStore records = null) + { + // Definitions + DefinitionsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordDefinition e, CancellationToken c, bool f) => { Definitions.Add(e); return e; }); + DefinitionsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordDefinition e, CancellationToken c, bool f) => { Definitions.RemoveAll(x => x.RmsRecordDefinitionId == e.RmsRecordDefinitionId); Definitions.Add(e); return e; }); + DefinitionsRepo.Setup(r => r.GetByKeyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string k) => Definitions.FirstOrDefault(x => x.DepartmentId == d && x.DeletedOn == null && string.Equals(x.DefinitionKey, k, StringComparison.OrdinalIgnoreCase))); + DefinitionsRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => Definitions.FirstOrDefault(x => x.DepartmentId == d && x.RmsRecordDefinitionId == id)); + DefinitionsRepo.Setup(r => r.GetForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, bool retired) => Definitions.Where(x => x.DepartmentId == d && x.DeletedOn == null && (retired || !x.IsRetired)).ToList()); + DefinitionsRepo.Setup(r => r.TryBumpRowVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long expected, CancellationToken c) => Bump(Definitions.FirstOrDefault(x => x.DepartmentId == d && x.RmsRecordDefinitionId == id), expected, (x, v) => x.RowVersion = v, x => x.RowVersion)); + + // Versions + VersionsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordDefinitionVersion e, CancellationToken c, bool f) => { Versions.Add(e); return e; }); + VersionsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordDefinitionVersion e, CancellationToken c, bool f) => { Versions.RemoveAll(x => x.RmsRecordDefinitionVersionId == e.RmsRecordDefinitionVersionId); Versions.Add(e); return e; }); + VersionsRepo.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordDefinitionVersion e, CancellationToken c) => Versions.RemoveAll(x => x.RmsRecordDefinitionVersionId == e.RmsRecordDefinitionVersionId) > 0); + VersionsRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => Versions.FirstOrDefault(x => x.DepartmentId == d && x.RmsRecordDefinitionVersionId == id)); + VersionsRepo.Setup(r => r.GetAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string k, int v) => Versions.FirstOrDefault(x => x.DepartmentId == d && string.Equals(x.DefinitionKey, k, StringComparison.OrdinalIgnoreCase) && x.Version == v)); + VersionsRepo.Setup(r => r.GetForDefinitionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string k) => Versions.Where(x => x.DepartmentId == d && string.Equals(x.DefinitionKey, k, StringComparison.OrdinalIgnoreCase)).OrderBy(x => x.Version).ToList()); + VersionsRepo.Setup(r => r.GetPublishedForDepartmentAsync(It.IsAny())) + .ReturnsAsync((int d) => Versions.Where(x => x.DepartmentId == d && x.IsPublished + && Definitions.Any(def => def.DepartmentId == d && def.DefinitionKey == x.DefinitionKey && def.CurrentPublishedVersion == x.Version && !def.IsRetired && def.DeletedOn == null)).ToList()); + VersionsRepo.Setup(r => r.TryBumpRowVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long expected, CancellationToken c) => Bump(Versions.FirstOrDefault(x => x.DepartmentId == d && x.RmsRecordDefinitionVersionId == id), expected, (x, v) => x.RowVersion = v, x => x.RowVersion)); + + // Materialized sections / fields + SectionsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordSectionDefinition e, CancellationToken c, bool f) => { Sections.Add(e); return e; }); + SectionsRepo.Setup(r => r.GetForVersionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string v) => Sections.Where(x => x.DepartmentId == d && x.RmsRecordDefinitionVersionId == v).OrderBy(x => x.Ordinal).ToList()); + SectionsRepo.Setup(r => r.DeleteForVersionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string v, CancellationToken c) => Sections.RemoveAll(x => x.DepartmentId == d && x.RmsRecordDefinitionVersionId == v)); + FieldsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordFieldDefinition e, CancellationToken c, bool f) => { Fields.Add(e); return e; }); + FieldsRepo.Setup(r => r.GetForVersionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string v) => Fields.Where(x => x.DepartmentId == d && x.RmsRecordDefinitionVersionId == v).OrderBy(x => x.Ordinal).ToList()); + FieldsRepo.Setup(r => r.DeleteForVersionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string v, CancellationToken c) => Fields.RemoveAll(x => x.DepartmentId == d && x.RmsRecordDefinitionVersionId == v)); + + // Typed values + GroupsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordValueGroup e, CancellationToken c, bool f) => { Groups.Add(e); return e; }); + GroupsRepo.Setup(r => r.GetForRecordAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, string rev) => Groups.Where(x => x.DepartmentId == d && x.RecordId == id && x.RevisionId == rev).OrderBy(x => x.Ordinal).ToList()); + GroupsRepo.Setup(r => r.GetForRecordsAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, IEnumerable ids, bool drafts) => { var set = ids.ToList(); return Groups.Where(x => x.DepartmentId == d && set.Contains(x.RecordId) && (!drafts || x.RevisionId == null)).ToList(); }); + GroupsRepo.Setup(r => r.GetForRevisionsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((int d, IEnumerable ids) => { var set = ids.ToList(); return Groups.Where(x => x.DepartmentId == d && x.RevisionId != null && set.Contains(x.RevisionId)).ToList(); }); + GroupsRepo.Setup(r => r.DeleteDraftForRecordAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, CancellationToken c) => Groups.RemoveAll(x => x.DepartmentId == d && x.RecordId == id && x.RevisionId == null)); + ValuesRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordValue e, CancellationToken c, bool f) => { Values.Add(e); return e; }); + ValuesRepo.Setup(r => r.GetForRecordAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, string rev) => Values.Where(x => x.DepartmentId == d && x.RecordId == id && x.RevisionId == rev).ToList()); + ValuesRepo.Setup(r => r.GetForRecordsAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, IEnumerable ids, bool drafts) => { var set = ids.ToList(); return Values.Where(x => x.DepartmentId == d && set.Contains(x.RecordId) && (!drafts || x.RevisionId == null)).ToList(); }); + ValuesRepo.Setup(r => r.GetForRevisionsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((int d, IEnumerable ids) => { var set = ids.ToList(); return Values.Where(x => x.DepartmentId == d && x.RevisionId != null && set.Contains(x.RevisionId)).ToList(); }); + ValuesRepo.Setup(r => r.DeleteDraftForRecordAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, CancellationToken c) => Values.RemoveAll(x => x.DepartmentId == d && x.RecordId == id && x.RevisionId == null)); + ValuesRepo.Setup(r => r.CountRecordsOnVersionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string v, bool drafts) => Values.Where(x => x.DepartmentId == d && x.RmsRecordDefinitionVersionId == v && (!drafts || x.RevisionId == null)).Select(x => x.RecordId).Distinct().Count()); + + // Saved reports + ReportsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsSavedReportDefinition e, CancellationToken c, bool f) => { Reports.Add(e); return e; }); + ReportsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsSavedReportDefinition e, CancellationToken c, bool f) => { Reports.RemoveAll(x => x.RmsSavedReportDefinitionId == e.RmsSavedReportDefinitionId); Reports.Add(e); return e; }); + ReportsRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => Reports.FirstOrDefault(x => x.DepartmentId == d && x.RmsSavedReportDefinitionId == id && x.DeletedOn == null)); + ReportsRepo.Setup(r => r.GetForDepartmentAsync(It.IsAny())) + .ReturnsAsync((int d) => Reports.Where(x => x.DepartmentId == d && x.DeletedOn == null).ToList()); + + // Product catalog mirrors + PacksRepo.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsTemplatePackVersion e, CancellationToken c, bool f) => { Packs.RemoveAll(x => x.PackKey == e.PackKey && x.Version == e.Version); Packs.Add(e); return e; }); + PacksRepo.Setup(r => r.GetCatalogAsync()).ReturnsAsync(() => Packs.ToList()); + PacksRepo.Setup(r => r.GetAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string k, int v) => Packs.FirstOrDefault(x => x.PackKey == k && x.Version == v)); + ProfilesRepo.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsJurisdictionProfileVersion e, CancellationToken c, bool f) => { Profiles.RemoveAll(x => x.ProfileKey == e.ProfileKey && x.Version == e.Version); Profiles.Add(e); return e; }); + ProfilesRepo.Setup(r => r.GetCatalogAsync()).ReturnsAsync(() => Profiles.ToList()); + ProfilesRepo.Setup(r => r.GetAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string k, int v) => Profiles.FirstOrDefault(x => x.ProfileKey == k && x.Version == v)); + ProfilesRepo.Setup(r => r.GetLatestAsync(It.IsAny())).ReturnsAsync((string k) => Profiles.Where(x => x.ProfileKey == k).OrderByDescending(x => x.Version).FirstOrDefault()); + + // External orders + OrdersRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsExternalOrder e, CancellationToken c, bool f) => { Orders.Add(e); return e; }); + OrdersRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsExternalOrder e, CancellationToken c, bool f) => { Orders.RemoveAll(x => x.RmsExternalOrderId == e.RmsExternalOrderId); Orders.Add(e); return e; }); + OrdersRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, bool a) => Orders.FirstOrDefault(x => x.DepartmentId == d && x.RmsExternalOrderId == id)); + OrdersRepo.Setup(r => r.GetForRecordAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => Orders.FirstOrDefault(x => x.DepartmentId == d && x.RecordId == id)); + OrdersRepo.Setup(r => r.GetForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, bool closed) => Orders.Where(x => x.DepartmentId == d && (closed || x.Status != (int)RmsExternalOrderStatus.ClosedOut)).ToList()); + OrdersRepo.Setup(r => r.GetArtifactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => Orders.FirstOrDefault(x => x.DepartmentId == d && x.RmsExternalOrderId == id)?.ArtifactData); + FillsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsExternalOrderFill e, CancellationToken c, bool f) => { Fills.Add(e); return e; }); + FillsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsExternalOrderFill e, CancellationToken c, bool f) => { Fills.RemoveAll(x => x.RmsExternalOrderFillId == e.RmsExternalOrderFillId); Fills.Add(e); return e; }); + FillsRepo.Setup(r => r.GetForOrderAsync(It.IsAny(), It.IsAny())) + .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)); + ReferencesRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsExternalReference e, CancellationToken c, bool f) => { References.Add(e); return e; }); + + if (records != null) + { + records.RecordsRepo.Setup(r => r.GetByDefinitionVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .ReturnsAsync((int d, string k, int v, IEnumerable states) => { var set = states?.ToList(); return records.Records.Where(x => x.DepartmentId == d && x.DeletedOn == null && string.Equals(x.DefinitionKey, k, StringComparison.OrdinalIgnoreCase) && x.DefinitionVersion == v && (set == null || set.Count == 0 || set.Contains(x.State))).ToList(); }); + records.RecordsRepo.Setup(r => r.GetByIdsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((int d, IEnumerable ids) => { var set = ids.ToList(); return records.Records.Where(x => x.DepartmentId == d && set.Contains(x.RmsOperationalRecordId)).ToList(); }); + records.ProjectionsRepo.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, RmsRecordQuery q) => records.Projections.Where(p => p.DepartmentId == d + && (string.IsNullOrEmpty(q.DefinitionKey) || string.Equals(p.DefinitionKey, q.DefinitionKey, StringComparison.OrdinalIgnoreCase)) + && (q.States == null || q.States.Count == 0 || q.States.Contains(p.State))) + .OrderByDescending(p => p.RecordCreatedOn).Skip(q.Skip).Take(q.Take <= 0 ? int.MaxValue : q.Take).ToList()); + } + } + + private static bool Bump(T row, long expected, Action set, Func get) where T : class + { + if (row == null || get(row) != expected) return false; + set(row, expected + 1); + return true; + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs b/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs new file mode 100644 index 00000000..403c2ecf --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs @@ -0,0 +1,321 @@ +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.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// FieldRecordCatalogV1 matrix (RMS plan RMS-1D): parent and four child flags, app/client type and minimum + /// version, membership and authoring permission, verified context, Protected Data state, definition retirement + /// and unsupported controls. The governing rule under test is that a forged client, app, capability or context + /// value never widens the returned catalog. + /// + [TestFixture] + public class FieldRecordCatalogTests + { + private const int Dept = 9; + private const string Me = "responder"; + + private Mock _cutover; + private Mock _authorization; + private Mock _flags; + private Mock _definitions; + private Mock _protection; + private Mock _records; + private Mock _assignments; + private Mock _units; + private Mock _groups; + private Mock _calls; + private Mock _command; + private RecordsModuleState _moduleState; + private DepartmentDataProtectionPolicy _policy; + private List _published; + private List _summaries; + private FieldRecordsService _service; + private string _minimumResponder; + + [SetUp] + public void SetUp() + { + _minimumResponder = RecordsFieldConfig.MinimumResponderVersion; + _moduleState = new RecordsModuleState { DepartmentId = Dept, FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active }; + _cutover = new Mock(); + _cutover.Setup(c => c.GetModuleStateAsync(Dept, It.IsAny())).ReturnsAsync(() => _moduleState); + + _authorization = new Mock(); + _authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); + _authorization.Setup(a => a.GetReadScopeStampAsync(It.IsAny(), Dept)).ReturnsAsync("scope-1"); + _authorization.Setup(a => a.GetVisibleGroupIdsAsync(It.IsAny(), Dept)).ReturnsAsync((List)null); + _authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.CanReadSourceCallAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); + + _flags = new Mock(); + _flags.Setup(f => f.IsEnabledAsync(It.IsAny(), Dept, It.IsAny(), It.IsAny>())).ReturnsAsync(true); + + _published = new List(); + _summaries = new List(); + _definitions = new Mock(); + _definitions.Setup(d => d.GetPublishedAsync(Dept)).ReturnsAsync(() => _published); + _definitions.Setup(d => d.ListAsync(Dept, It.IsAny())).ReturnsAsync(() => _summaries); + _definitions.Setup(d => d.GetVersionAsync(Dept, It.IsAny(), It.IsAny())) + .ReturnsAsync((int dept, string key, int version) => _published.FirstOrDefault(v => v.DefinitionKey == key && v.Version == version)); + + _policy = null; + _protection = new Mock(); + _protection.Setup(p => p.GetPolicyByDepartmentIdAsync(Dept, It.IsAny())).ReturnsAsync(() => _policy); + + _records = new Mock(); + _records.Setup(r => r.GetChangesSinceAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _records.Setup(r => r.QueryAsync(Dept, It.IsAny())).ReturnsAsync(new List()); + _assignments = new Mock(); + _assignments.Setup(a => a.GetQueueAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + _units = new Mock(); + _groups = new Mock(); + _calls = new Mock(); + _command = new Mock(); + + _service = new FieldRecordsService(_cutover.Object, _authorization.Object, _flags.Object, _definitions.Object, _protection.Object, _records.Object, + _assignments.Object, _units.Object, _groups.Object, _calls.Object, _command.Object); + } + + [TearDown] + public void TearDown() => RecordsFieldConfig.MinimumResponderVersion = _minimumResponder; + + private static FieldRecordCatalogRequest Request(RmsOriginClient origin = RmsOriginClient.Responder, string capability = RecordsClientCapabilities.Packs, string appVersion = "5.2.0", FieldRecordContext context = null) + => new FieldRecordCatalogRequest { Origin = origin, ClientCapability = capability, AppVersion = appVersion, Context = context ?? new FieldRecordContext() }; + + private RmsRecordDefinitionVersion Publish(string key, string name, Action surface = null, Action schema = null, bool retired = false) + { + var definitionSchema = RmsDefinitionHarness.Schema(RmsDefinitionHarness.Section("main", "Main", RmsDefinitionHarness.Field("summary", RmsFieldType.ShortText))); + schema?.Invoke(definitionSchema); + var clientSurface = new RecordDefinitionClientSurface { Responder = true, AllowOffline = true, AllowAttachments = true, LaunchContexts = { FieldRecordCatalogV1.LaunchContexts.None } }; + surface?.Invoke(clientSurface); + var version = new RmsRecordDefinitionVersion + { + DepartmentId = Dept, DefinitionKey = key, Version = 3, State = (int)RmsDefinitionVersionState.Published, LifecyclePreset = (int)RmsLifecyclePreset.QuickEntry, + Schema = definitionSchema, ClientSurface = clientSurface, SchemaChecksum = "chk-" + key, MinimumClientCapability = RecordsClientCapabilities.Derive(definitionSchema) + }; + _published.Add(version); + _summaries.Add(new RecordDefinitionSummary { Key = key, Name = name, Category = "Operations", PublishedVersion = 3, Retired = retired }); + return version; + } + + [Test] + public async Task Parent_and_child_flags_membership_and_version_each_fail_the_preflight_closed() + { + (await _service.PreflightAsync(Dept, Me, RmsOriginClient.Responder, "5.2.0", RecordsClientCapabilities.Packs)).Ok.Should().BeTrue(); + + (await _service.PreflightAsync(Dept, Me, RmsOriginClient.Web, "5.2.0", null)).Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.OriginNotField); + + _moduleState.FlagEnabled = false; + var moduleOff = await _service.PreflightAsync(Dept, Me, RmsOriginClient.Responder, "5.2.0", null); + moduleOff.Ok.Should().BeFalse(); + moduleOff.Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.ModuleDisabled); + _moduleState.FlagEnabled = true; + + _moduleState.Activated = false; + (await _service.PreflightAsync(Dept, Me, RmsOriginClient.Responder, "5.2.0", null)).Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.RecordsNotUsable); + _moduleState.Activated = true; + + _flags.Setup(f => f.IsEnabledAsync(FeatureFlagKeys.RecordsFieldResponder, Dept, It.IsAny(), It.IsAny>())).ReturnsAsync(false); + (await _service.PreflightAsync(Dept, Me, RmsOriginClient.Responder, "5.2.0", null)).Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.AppDisabled); + (await _service.PreflightAsync(Dept, Me, RmsOriginClient.Unit, "5.2.0", null)).Ok.Should().BeTrue("each app has its own child flag"); + _flags.Setup(f => f.IsEnabledAsync(FeatureFlagKeys.RecordsFieldResponder, Dept, It.IsAny(), It.IsAny>())).ReturnsAsync(true); + + _authorization.Setup(a => a.IsActiveMemberAsync(Me, Dept)).ReturnsAsync(false); + (await _service.PreflightAsync(Dept, Me, RmsOriginClient.Responder, "5.2.0", null)).Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.NotMember); + _authorization.Setup(a => a.IsActiveMemberAsync(Me, Dept)).ReturnsAsync(true); + + RecordsFieldConfig.MinimumResponderVersion = "5.3.0"; + (await _service.PreflightAsync(Dept, Me, RmsOriginClient.Responder, "5.2.9", null)).Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.AppVersionTooOld); + (await _service.PreflightAsync(Dept, Me, RmsOriginClient.Responder, "5.10.0", null)).Ok.Should().BeTrue("versions compare numerically, not as strings"); + (await _service.PreflightAsync(Dept, Me, RmsOriginClient.Responder, null, null)).Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.AppVersionTooOld, "an unreported version never satisfies a minimum"); + } + + [Test] + public async Task Catalog_lists_the_locked_starters_for_each_app_and_only_in_their_launch_contexts() + { + var home = await _service.GetCatalogAsync(Dept, Me, Request()); + home.Ok.Should().BeTrue(); + home.Definitions.Where(d => d.Locked).Select(d => d.DefinitionKey).Should().BeEquivalentTo(FieldRecordCatalogV1.LockedStarterAllowlist(RmsOriginClient.Responder)); + home.Definitions.Should().NotContain(d => d.DefinitionKey == RmsDefinitionKeys.Run, "the run report needs a Call context"); + + var call = new Call { CallId = 501, DepartmentId = Dept, Number = "2026-0501" }; + _calls.Setup(c => c.GetCallByIdAsync(501, It.IsAny())).ReturnsAsync(call); + var onCall = await _service.GetCatalogAsync(Dept, "dispatcher", Request(RmsOriginClient.Dispatch, context: new FieldRecordContext { CallId = 501 })); + onCall.Definitions.Select(d => d.DefinitionKey).Should().Contain(RmsDefinitionKeys.Run).And.Contain(RmsDefinitionKeys.Callback); + onCall.ContextVerified.Should().BeTrue(); + + var dispatchHome = await _service.GetCatalogAsync(Dept, "dispatcher", Request(RmsOriginClient.Dispatch)); + dispatchHome.Definitions.Should().NotContain(d => d.DefinitionKey == RmsDefinitionKeys.Run); + dispatchHome.Exclusions.Should().Contain(e => e.DefinitionKey == RmsDefinitionKeys.Run && e.Reason == FieldRecordCatalogV1.ExclusionReasons.ContextNotAllowed, + "a definition withheld for context carries a coded reason"); + + var starved = await _service.GetCatalogAsync(Dept, Me, Request(capability: "records.v0")); + starved.Definitions.Should().NotBeEmpty("an unknown capability degrades to the oldest, which still renders locked definitions"); + starved.Definitions.Should().OnlyContain(d => d.Locked); + } + + [Test] + public async Task Department_definitions_reach_an_app_only_through_their_client_surface_context_version_and_capability() + { + Publish("shift-log", "Shift log"); + Publish("unit-only", "Unit only", s => { s.Responder = false; s.Unit = true; }); + Publish("call-only", "Call only", s => { s.LaunchContexts.Clear(); s.LaunchContexts.Add(FieldRecordCatalogV1.LaunchContexts.Call); }); + Publish("needs-newer-app", "Needs newer app", s => s.MinimumAppVersion = "9.0.0"); + Publish("retired-one", "Retired", retired: true); + Publish("packs", "Pack fields", schema: sch => sch.Sections[0].Fields.Add(RmsDefinitionHarness.Field("cost", RmsFieldType.Currency))); + + var catalog = await _service.GetCatalogAsync(Dept, Me, Request(capability: RecordsClientCapabilities.Configurable)); + + catalog.Definitions.Where(d => !d.Locked).Select(d => d.DefinitionKey).Should().BeEquivalentTo(new[] { "shift-log" }); + var reasons = catalog.Exclusions.ToDictionary(e => e.DefinitionKey, e => e.Reason, StringComparer.OrdinalIgnoreCase); + reasons["unit-only"].Should().Be(FieldRecordCatalogV1.ExclusionReasons.SurfaceNotEnabled); + reasons["call-only"].Should().Be(FieldRecordCatalogV1.ExclusionReasons.ContextNotAllowed); + reasons["needs-newer-app"].Should().Be(FieldRecordCatalogV1.ExclusionReasons.AppVersionTooOld); + reasons["retired-one"].Should().Be(FieldRecordCatalogV1.ExclusionReasons.Retired); + reasons["packs"].Should().Be(FieldRecordCatalogV1.ExclusionReasons.CapabilityUnsupported, "a client that cannot render a control is refused rather than sent one"); + + var newer = await _service.GetCatalogAsync(Dept, Me, Request(capability: RecordsClientCapabilities.Packs)); + newer.Definitions.Select(d => d.DefinitionKey).Should().Contain("packs"); + } + + [Test] + public async Task Protected_definitions_need_an_enrolled_department_and_never_go_offline() + { + Publish("casualty", "Casualty", schema: sch => sch.Sections[0].Fields.Add(RmsDefinitionHarness.Field("condition", RmsFieldType.LongText, classification: RmsFieldClassification.Protected))); + + var disabled = await _service.GetCatalogAsync(Dept, Me, Request()); + disabled.Exclusions.Should().Contain(e => e.DefinitionKey == "casualty" && e.Reason == FieldRecordCatalogV1.ExclusionReasons.ProtectedDataUnavailable); + + _policy = new DepartmentDataProtectionPolicy { DepartmentId = Dept, State = (int)DepartmentDataProtectionState.Enabled, CatalogVersion = 11 }; + var enrolled = await _service.GetCatalogAsync(Dept, Me, Request()); + var entry = enrolled.Definitions.Single(d => d.DefinitionKey == "casualty"); + entry.RequiresProtectedGrant.Should().BeTrue(); + entry.AllowOffline.Should().BeFalse("a sealed value never sits in an offline draft, whatever the surface asked for"); + entry.Restricted.Should().BeTrue(); + enrolled.ProtectionState.Should().Be(DepartmentDataProtectionState.Enabled.ToString()); + } + + [Test] + public async Task A_forged_context_is_verified_server_side_and_never_widens_the_catalog() + { + Publish("crew-log", "Crew log", s => { s.Responder = false; s.Unit = true; s.LaunchContexts.Clear(); s.LaunchContexts.Add(FieldRecordCatalogV1.LaunchContexts.Unit); }); + _units.Setup(u => u.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = Dept, Name = "Engine 7" }); + _units.Setup(u => u.GetLastUnitStateByUnitIdAsync(7)).ReturnsAsync(new UnitState { UnitStateId = 1, UnitId = 7, Roles = new List() }); + + var unstaffed = await _service.GetCatalogAsync(Dept, "crew", Request(RmsOriginClient.Unit, context: new FieldRecordContext { UnitId = 7 })); + unstaffed.Ok.Should().BeFalse("the Unit app authors only on the apparatus the caller is staffed on"); + unstaffed.Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.ContextNotVerified); + unstaffed.Definitions.Should().BeEmpty(); + + _units.Setup(u => u.GetLastUnitStateByUnitIdAsync(7)).ReturnsAsync(new UnitState { UnitStateId = 2, UnitId = 7, Roles = new List { new UnitStateRole { UserId = "crew", Role = "Driver" } } }); + var staffed = await _service.GetCatalogAsync(Dept, "crew", Request(RmsOriginClient.Unit, context: new FieldRecordContext { UnitId = 7 })); + staffed.Ok.Should().BeTrue(); + staffed.Definitions.Select(d => d.DefinitionKey).Should().Contain("crew-log"); + + _units.Setup(u => u.GetUnitByIdAsync(99)).ReturnsAsync(new Unit { UnitId = 99, DepartmentId = Dept + 1, Name = "Foreign" }); + var foreign = await _service.GetCatalogAsync(Dept, "crew", Request(RmsOriginClient.Unit, context: new FieldRecordContext { UnitId = 99 })); + foreign.Ok.Should().BeFalse("a unit in another department is not a context"); + + var claimedCommand = await _service.GetCatalogAsync(Dept, "crew", Request(RmsOriginClient.IncidentCommand, context: new FieldRecordContext { CallId = 501, CommandRole = "Operations" })); + claimedCommand.Ok.Should().BeFalse("a command role is checked against the active command, never taken from the client"); + claimedCommand.Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.ContextNotVerified); + } + + [Test] + public async Task Prefill_is_server_calculated_provenance_stamped_and_refused_outside_the_callers_catalog() + { + Publish("run-sheet", "Run sheet", s => { s.LaunchContexts.Clear(); s.LaunchContexts.Add(FieldRecordCatalogV1.LaunchContexts.Call); }, sch => + { + sch.Sections[0].Fields.Add(RmsDefinitionHarness.Field("related_call", RmsFieldType.CallReference)); + sch.Sections[0].Fields.Add(RmsDefinitionHarness.Field("scene_location", RmsFieldType.Address)); + sch.Sections[0].Fields.Add(RmsDefinitionHarness.Field("started_at", RmsFieldType.DateTime)); + sch.Sections[0].Fields.Add(RmsDefinitionHarness.Field("reported_by", RmsFieldType.Person)); + sch.Sections[0].Fields.Add(RmsDefinitionHarness.Field("patient_location", RmsFieldType.Address, classification: RmsFieldClassification.Restricted)); + }); + var call = new Call { CallId = 501, DepartmentId = Dept, Number = "2026-0501", Address = "12 Pine St", LoggedOn = new DateTime(2026, 9, 6, 3, 15, 0, DateTimeKind.Utc) }; + _calls.Setup(c => c.GetCallByIdAsync(501, It.IsAny())).ReturnsAsync(call); + _groups.Setup(g => g.GetGroupForUserAsync(Me, Dept)).ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 4, DepartmentId = Dept, Name = "Station 1" }); + var request = Request(context: new FieldRecordContext { CallId = 501 }); + + var prefill = await _service.PrefillAsync(Dept, Me, request, "run-sheet", 3); + + prefill.CallId.Should().Be(501); + prefill.StationGroupId.Should().Be(4); + prefill.SuggestedParticipantUserIds.Should().Contain(Me); + var values = prefill.Values.ToDictionary(v => v.FieldKey, v => v.Value); + values["related_call"].Should().Be("501"); + values["scene_location"].Should().Be("12 Pine St"); + values["started_at"].Should().Be(call.LoggedOn.ToString("O")); + values["reported_by"].Should().Be(Me); + values.Should().NotContainKey("patient_location", "prefill is minimum-necessary: never a restricted or protected value"); + prefill.Provenance.Should().Contain(p => p.FieldKey == "scene_location" && p.Source == "call.address" && p.SourceId == "501"); + + Func outsideCatalog = () => _service.PrefillAsync(Dept, Me, Request(), "run-sheet", 3); + await outsideCatalog.Should().ThrowAsync("prefill answers only for an entry in the same request's catalog"); + + Func unknown = () => _service.PrefillAsync(Dept, Me, request, "run-sheet", 99); + await unknown.Should().ThrowAsync(); + } + + [Test] + public async Task Sync_is_bounded_tombstones_what_the_caller_cannot_read_and_resets_on_a_scope_change() + { + Publish("shift-log", "Shift log"); + var visible = new RmsRecordSearchProjection { RmsRecordSearchProjectionId = "r1", DepartmentId = Dept, ModifiedOn = new DateTime(2026, 9, 6, 1, 0, 0, DateTimeKind.Utc), State = (int)RmsRecordState.Finalized }; + var hidden = new RmsRecordSearchProjection { RmsRecordSearchProjectionId = "r2", DepartmentId = Dept, ModifiedOn = new DateTime(2026, 9, 6, 2, 0, 0, DateTimeKind.Utc), State = (int)RmsRecordState.Finalized }; + var deleted = new RmsRecordSearchProjection { RmsRecordSearchProjectionId = "r3", DepartmentId = Dept, ModifiedOn = new DateTime(2026, 9, 6, 2, 5, 0, DateTimeKind.Utc), DeletedOn = DateTime.UtcNow }; + _records.Setup(r => r.GetChangesSinceAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List { visible, hidden, deleted }); + _authorization.Setup(a => a.CanUserViewRecordAsync(Me, "r2", Dept)).ReturnsAsync(false); + _records.Setup(r => r.QueryAsync(Dept, It.IsAny())).ReturnsAsync(new List { visible }); + _assignments.Setup(a => a.GetQueueAsync(Dept, Me, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new RmsRecordWorkAssignment { RmsRecordWorkAssignmentId = "a1", RecordId = "r1", State = (int)RmsWorkAssignmentState.Open } }); + + var bundle = await _service.SyncAsync(Dept, Me, new FieldRecordSyncRequest { Origin = RmsOriginClient.Responder, AppVersion = "5.2.0", ClientCapability = RecordsClientCapabilities.Packs, Take = 500 }); + + bundle.Ok.Should().BeTrue(); + bundle.ScopeStamp.Should().Be("scope-1"); + bundle.Changes.Select(c => c.RmsRecordSearchProjectionId).Should().Equal("r1"); + bundle.Tombstones.Should().BeEquivalentTo("r2", "r3"); + bundle.Drafts.Should().ContainSingle(); + bundle.Assignments.Should().ContainSingle(); + bundle.Catalog.Should().NotBeNull(); + _records.Verify(r => r.GetChangesSinceAsync(Dept, It.IsAny(), RecordsFieldConfig.SyncTakeMax + 1, It.IsAny()), Times.Once, "a field bundle is a working set, not an archive pull"); + + var stale = await _service.SyncAsync(Dept, Me, new FieldRecordSyncRequest { Origin = RmsOriginClient.Responder, AppVersion = "5.2.0", Since = 1_700_000_000_000, ScopeStamp = "scope-0" }); + stale.ResetRequired.Should().BeTrue(); + stale.Changes.Should().BeEmpty(); + + _flags.Setup(f => f.IsEnabledAsync(FeatureFlagKeys.RecordsFieldResponder, Dept, It.IsAny(), It.IsAny>())).ReturnsAsync(false); + var gated = await _service.SyncAsync(Dept, Me, new FieldRecordSyncRequest { Origin = RmsOriginClient.Responder, AppVersion = "5.2.0", Since = 1_700_000_000_000, ScopeStamp = "scope-1" }); + gated.Ok.Should().BeFalse(); + gated.Reasons.Should().Contain(FieldRecordCatalogV1.ExclusionReasons.AppDisabled); + gated.Changes.Should().BeEmpty(); + } + + [Test] + public void Version_comparison_is_numeric_and_tolerates_prereleases() + { + FieldRecordCatalogV1.CompareVersions("1.2.10", "1.2.9").Should().BePositive(); + FieldRecordCatalogV1.CompareVersions("2.0", "2.0.0").Should().Be(0); + FieldRecordCatalogV1.CompareVersions("v5.1.0-beta.3", "5.1.0").Should().Be(0, "a prerelease suffix is not a version segment"); + FieldRecordCatalogV1.MeetsMinimum("5.0.0", null).Should().BeTrue(); + FieldRecordCatalogV1.MeetsMinimum(null, "5.0.0").Should().BeFalse(); + FieldRecordCatalogV1.IsFieldOrigin(RmsOriginClient.Web).Should().BeFalse(); + FieldRecordCatalogV1.IsFieldOrigin(RmsOriginClient.Unit).Should().BeTrue(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs b/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs index efad2aaf..d7039bd2 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs @@ -99,7 +99,7 @@ public async Task Officer_completes_submits_corrects_and_discloses_an_incident_w pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny(), "Letter")).Returns((string html, string paper) => { rendered.Add(html); return Encoding.ASCII.GetBytes("%PDF-journey-fixture"); }); var branding = new Mock(); branding.Setup(b => b.GetBrandingAsync(Dept)).ReturnsAsync(new DepartmentBranding { DisplayName = "Journey Fire Department" }); var documents = new RecordsDocumentService(_authorization.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, - _store.Shared.RevisionsRepo.Object, _service, branding.Object, Mock.Of(), pdf.Object, evidence, udf, new PassthroughRecordsProtection()); + _store.Shared.RevisionsRepo.Object, _service, branding.Object, Mock.Of(), pdf.Object, evidence, udf, new PassthroughRecordsProtection(), Mock.Of()); var original = await documents.GetAsync(Dept, "author", id, RmsRecordKind.IncidentReport, firstRevision.RmsRevisionId, true); var corrected = await documents.GetAsync(Dept, "author", id, RmsRecordKind.IncidentReport, secondRevision.RmsRevisionId, true); JObject.Parse(original.ContentJson)["CustomFields"]["Fields"][0]["Value"].Value().Should().Be("23"); diff --git a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs index fac86a83..e484b570 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs @@ -241,6 +241,42 @@ public async Task The_promoted_primary_incident_type_is_persisted_not_only_retur hydrated.Types.Count(t => t.IsPrimary).Should().Be(1); } + [Test] + public async Task Reordering_sections_carries_each_row_identity_with_its_own_content() + { + var started = await _service.StartFromCallAsync(Dept, "author", CallId); + var reportId = started.Report.RmsIncidentReportId; + var input = DraftFrom(started); + input.Resources = new List + { + new IncidentResourceInput { ResourceCode = "FOAM", Detail = "Class A foam" }, + new IncidentResourceInput { ResourceCode = "LADDER", Detail = "35 foot" } + }; + var saved = await _service.SaveDraftAsync(Dept, "author", reportId, started.Report.RowVersion, input, true); + var foam = saved.Resources.Single(r => r.ResourceCode == "FOAM"); + var ladder = saved.Resources.Single(r => r.ResourceCode == "LADDER"); + + // The client sends the rows back in the other order, each carrying its own id. + input = DraftFrom(saved); + input.Resources = new List + { + new IncidentResourceInput { ResourceId = ladder.RmsIncidentResourceId, ResourceCode = "LADDER", Detail = "35 foot" }, + new IncidentResourceInput { ResourceId = foam.RmsIncidentResourceId, ResourceCode = "FOAM", Detail = "Class A foam" } + }; + var reordered = await _service.SaveDraftAsync(Dept, "author", reportId, saved.Report.RowVersion, input, true); + + reordered.Resources.Single(r => r.RmsIncidentResourceId == foam.RmsIncidentResourceId).ResourceCode.Should().Be("FOAM", + "identity travels with the row, so reordering cannot hand one row's id and ProtectionId to another row's content"); + reordered.Resources.Single(r => r.RmsIncidentResourceId == ladder.RmsIncidentResourceId).ResourceCode.Should().Be("LADDER"); + reordered.Resources.Single(r => r.RmsIncidentResourceId == foam.RmsIncidentResourceId).ProtectionId.Should().Be(foam.ProtectionId); + + // An identifier from outside the draft is refused rather than silently treated as a new row. + input = DraftFrom(reordered); + input.Resources = new List { new IncidentResourceInput { ResourceId = "not-in-this-draft", ResourceCode = "FOAM" } }; + var act = async () => await _service.SaveDraftAsync(Dept, "author", reportId, reordered.Report.RowVersion, input, true); + await act.Should().ThrowAsync(); + } + [Test] public async Task Save_draft_records_corrections_on_the_provenance_row() { diff --git a/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs b/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs index 8b99e87d..b22dfd7d 100644 --- a/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs +++ b/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs @@ -85,14 +85,14 @@ public RecordsParityHarness() Records = new RecordsService(Store.RecordsRepo.Object, new RmsRecordValueService(Store.DetailsRepo.Object), Store.ParticipantsRepo.Object, Store.UnitsRepo.Object, Store.AttachmentsRepo.Object, Store.RevisionsRepo.Object, evidence.Object, Store.ScopesRepo.Object, Store.SharesRepo.Object, Store.ProjectionsRepo.Object, Store.AuditsRepo.Object, outbox, cutover.Object, settings.Object, groups.Object, profiles.Object, units.Object, calls.Object, adp.Object, - Store.UnitOfWork.Object, queue.Object, new NullRecordAttachmentScanner(), Authorization.Object, udf, new PassthroughRecordsProtection()); + Store.UnitOfWork.Object, queue.Object, new NullRecordAttachmentScanner(), Authorization.Object, udf, new PassthroughRecordsProtection(), Mock.Of(), Mock.Of(), Mock.Of()); var branding = new Mock(); branding.Setup(b => b.GetBrandingAsync(Dept)).ReturnsAsync(new DepartmentBranding { DisplayName = "Parity Fire Department", ShortName = "PFD", AddressText = "100 Station Road" }); var layouts = new Mock(); layouts.Setup(l => l.GetDepartmentDefaultAsync(Dept)).ReturnsAsync(new RmsRecordPrintLayout { Version = 1, Scope = 1, Config = RecordsPrintLayoutConfig.Default() }); Documents = new RecordsDocumentService(Authorization.Object, Store.RecordsRepo.Object, IncidentStore.ReportsRepo.Object, IncidentStore.AnalysesRepo.Object, Store.RevisionsRepo.Object, - Mock.Of(), branding.Object, layouts.Object, Mock.Of(), evidence.Object, udf, new PassthroughRecordsProtection()); + Mock.Of(), branding.Object, layouts.Object, Mock.Of(), evidence.Object, udf, new PassthroughRecordsProtection(), Mock.Of()); } #region Fixtures diff --git a/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs b/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs index 3ca3b395..e6740ca0 100644 --- a/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs +++ b/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs @@ -42,6 +42,9 @@ public sealed class PassthroughRecordsProtection : IRecordsProtectionService public Task ProtectDisclosureProductionAsync(int departmentId, RmsDisclosureProduction row, string userId = null, CancellationToken cancellationToken = default) => Write("disclosure-production"); public Task ProtectLegalHoldAsync(int departmentId, RmsRecordLegalHold row, RmsRecordLegalHold existing, string userId = null, CancellationToken cancellationToken = default) => Write("legal-hold"); public Task ProtectExportRunAsync(int departmentId, RmsExportRun row, string userId = null, CancellationToken cancellationToken = default) => Write("export-run"); + public Task ProtectValuesAsync(int departmentId, IReadOnlyList rows, string userId = null, CancellationToken cancellationToken = default) => Write("values:" + (rows == null ? 0 : System.Linq.Enumerable.Count(rows, r => r.ProtectionRequired))); + public Task RevealValuesAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task RevealValuesForWorkloadAsync(int departmentId, IReadOnlyList rows, string purpose, CancellationToken cancellationToken = default) => Empty(); public Task RevealAsync(int departmentId, RecordAggregate aggregate, CancellationToken cancellationToken = default) { var r = new ProtectedReadResult(); if (aggregate != null) aggregate.Protection = r; return Task.FromResult(r); } public Task RevealAsync(int departmentId, IncidentReportAggregate aggregate, CancellationToken cancellationToken = default) { var r = new ProtectedReadResult(); if (aggregate != null) aggregate.Protection = r; return Task.FromResult(r); } diff --git a/Tests/Resgrid.Tests/Rms/RecordDefinitionsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordDefinitionsServiceTests.cs new file mode 100644 index 00000000..9c64c852 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordDefinitionsServiceTests.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services.Records; +using static Resgrid.Tests.Rms.RmsDefinitionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RMS-1B configurable definitions: designer lifecycle, validation, publish immutability, triggers 113/114, diff and migration. + [TestFixture] + public class RecordDefinitionsServiceTests + { + private RmsDefinitionHarness _h; + + [SetUp] + public void SetUp() => _h = new RmsDefinitionHarness(); + + [Test] + public async Task Template_clones_may_raise_a_pack_policy_floor_but_never_lower_it() + { + var aggregate = await _h.Definitions.CreateAsync(Dept, Admin, new RecordDefinitionCreateInput { DefinitionKey = "sar-mission", Name = "SAR mission", TemplateKey = "pack.sar.mission-summary" }); + var draft = aggregate.Draft; + var input = RecordDefinitionsService.ToDraftInput(draft); + input.Schema.FindField("subject_name").Classification = RmsFieldClassification.Protected; + var raised = await _h.Definitions.SaveDraftAsync(Dept, Admin, "sar-mission", draft.Version, draft.RowVersion, input); + raised.Schema.FindField("subject_name").Classification.Should().Be(RmsFieldClassification.Protected, "a department may raise the floor"); + + var lowered = RecordDefinitionsService.ToDraftInput(raised); + lowered.Schema.FindField("medical_concerns").Classification = RmsFieldClassification.Standard; + Func save = () => _h.Definitions.SaveDraftAsync(Dept, Admin, "sar-mission", raised.Version, raised.RowVersion, lowered); + (await save.Should().ThrowAsync()).Which.Message.Should().Contain("medical_concerns").And.Contain("policy floor"); + } + + [Test] + public async Task Create_from_template_renders_the_profile_overlay_and_starts_as_draft_v1() + { + var aggregate = await _h.Definitions.CreateAsync(Dept, Admin, new RecordDefinitionCreateInput + { + DefinitionKey = "Security-Patrol", Name = "Site patrol", TemplateKey = "template.security-patrol", JurisdictionProfileKey = "ca", Locale = "fr-CA" + }); + + aggregate.Definition.DefinitionKey.Should().Be("security-patrol"); + aggregate.Definition.TemplateKey.Should().Be("template.security-patrol"); + aggregate.Definition.JurisdictionProfileKey.Should().Be("ca"); + aggregate.Definition.Owner.Should().Be((int)RmsDefinitionOwner.Department); + aggregate.Published.Should().BeNull(); + var draft = aggregate.Draft; + draft.Version.Should().Be(1); + draft.Schema.FindField("officer").Label.Should().Be("Agent", "the fr-CA overlay relabels the rendered schema"); + draft.Schema.FindSection("exceptions").Rules.Should().ContainSingle(r => r.Effect == RmsRuleEffect.Show); + draft.Numbering.Prefix.Should().Be("PAT"); + draft.MinimumClientCapability.Should().Be(RecordsClientCapabilities.Configurable); + _h.Store.Audits.Should().ContainSingle(a => a.Purpose.Contains("Create definition")); + + var summaries = await _h.Definitions.ListAsync(Dept); + summaries.Should().ContainSingle(s => s.Key == "security-patrol" && s.DraftVersion == 1 && s.PublishedVersion == null && !s.Locked); + (await _h.Definitions.GetPublishedAsync(Dept)).Should().BeEmpty(); + } + + [Test] + public async Task Create_rejects_reserved_keys_duplicates_and_unknown_templates() + { + Func reserved = () => _h.Definitions.CreateAsync(Dept, Admin, new RecordDefinitionCreateInput { DefinitionKey = "system.run", Name = "x" }); + await reserved.Should().ThrowAsync().WithMessage("*reserved*"); + await _h.CreateAsync("shift-log", "Shift log", Schema(Section("s", "S", Field("summary", RmsFieldType.ShortText)))); + Func duplicate = () => _h.Definitions.CreateAsync(Dept, Admin, new RecordDefinitionCreateInput { DefinitionKey = "shift-log", Name = "again" }); + await duplicate.Should().ThrowAsync().WithMessage("*already exists*"); + Func template = () => _h.Definitions.CreateAsync(Dept, Admin, new RecordDefinitionCreateInput { DefinitionKey = "other", Name = "x", TemplateKey = "template.nope" }); + await template.Should().ThrowAsync().WithMessage("*not a product template*"); + _h.Authorization.Setup(a => a.HasPermissionAsync("viewer", Dept, PermissionTypes.ManageRecordDefinitions)).ReturnsAsync(false); + Func denied = () => _h.Definitions.CreateAsync(Dept, "viewer", new RecordDefinitionCreateInput { DefinitionKey = "denied", Name = "x" }); + await denied.Should().ThrowAsync(); + } + + [Test] + public async Task Blank_definitions_start_with_a_starter_schema_that_validates() + { + var aggregate = await _h.Definitions.CreateAsync(Dept, Admin, new RecordDefinitionCreateInput { DefinitionKey = "blank", Name = "Blank" }); + aggregate.Draft.Schema.Sections.Should().ContainSingle(s => s.Key == "details"); + aggregate.Draft.Numbering.Prefix.Should().NotBeNullOrEmpty(); + (await _h.Definitions.ValidateAsync(Dept, RecordDefinitionsService.ToDraftInput(aggregate.Draft))).IsValid.Should().BeTrue(); + } + + [Test] + public async Task Validation_reports_cycles_flag_misuse_and_bad_policies_with_codes() + { + var a = Field("a", RmsFieldType.Boolean); a.Rules.Add(ShowWhen("b", "true")); + var b = Field("b", RmsFieldType.Boolean); b.Rules.Add(ShowWhen("a", "true")); + var secret = Field("secret", RmsFieldType.ShortText, classification: RmsFieldClassification.Restricted, configure: f => f.Searchable = true); + var notes = Field("notes", RmsFieldType.LongText, configure: f => { f.Groupable = true; f.Sortable = true; }); + var qty = Field("qty", RmsFieldType.Quantity, configure: f => f.UnitFamily = "bogus"); + var dup = Field("a", RmsFieldType.ShortText); + var input = new RecordDefinitionDraftInput + { + Name = "Bad", Numbering = new RecordDefinitionNumbering { Prefix = "toolongprefix", SequenceWidth = 2 }, ReviewDueHours = 0, + Schema = Schema(Section("s", "S", a, b, secret, notes, qty, dup), Section("empty", "Empty")) + }; + var validation = await _h.Definitions.ValidateAsync(Dept, input); + validation.IsValid.Should().BeFalse(); + var codes = validation.Issues.Select(i => i.Code).ToList(); + codes.Should().Contain(new[] { "cycle", "protected_exposed", "not_groupable", "not_filterable", "bad_unit_family", "duplicate_key", "bad_prefix", "out_of_range", "no_fields" }); + validation.MinimumClientCapability.Should().Be(RecordsClientCapabilities.Packs, "a quantity field lifts the floor to records.v1c"); + validation.Issues.Should().Contain(i => i.Severity == "warning" && i.Code == "capability"); + } + + [Test] + public async Task Publish_freezes_the_version_materializes_rows_and_enqueues_trigger_113() + { + var schema = Schema(Section("shift", "Shift", Field("site", RmsFieldType.ShortText, true, configure: f => { f.Searchable = true; f.WorkflowExposed = true; }), Select("status", "Open", "Closed")), + Rows("stops", "Stops", 1, 10, Field("stop", RmsFieldType.ShortText, true), Field("minutes", RmsFieldType.Integer, configure: f => f.Aggregatable = true))); + await _h.CreateAsync("shift-log", "Shift log", schema, d => { d.Numbering.Prefix = "SL"; d.ReviewerRoleIds = new List { 5 }; d.LifecyclePreset = RmsLifecyclePreset.ReviewRequired; }); + + var published = await _h.PublishAsync("shift-log"); + published.IsPublished.Should().BeTrue(); + published.SchemaChecksum.Should().NotBeNullOrEmpty(); + published.PublishedByUserId.Should().Be(Admin); + published.MinimumClientCapability.Should().Be(RecordsClientCapabilities.Configurable); + _h.Defs.Sections.Where(s => s.RmsRecordDefinitionVersionId == published.RmsRecordDefinitionVersionId).Select(s => s.SectionKey).Should().Equal("shift", "stops"); + _h.Defs.Fields.Where(f => f.RmsRecordDefinitionVersionId == published.RmsRecordDefinitionVersionId).Select(f => f.FieldKey).Should().Equal("site", "status", "stop", "minutes"); + _h.Defs.Fields.Single(f => f.FieldKey == "minutes").Aggregatable.Should().BeTrue(); + _h.Defs.Definitions.Single().CurrentPublishedVersion.Should().Be(1); + + var events = _h.Events(WorkflowTriggerEventType.RecordDefinitionPublished).ToList(); + events.Should().HaveCount(1); + events[0].AggregateType.Should().Be(RecordDefinitionsService.DefinitionAggregate); + events[0].PayloadJson.Should().Contain("\"key\":\"shift-log\"").And.Contain("\"version\":1").And.Contain("\"site\""); + _h.Published.Should().ContainSingle(e => e.EventName == WorkflowTriggerEventType.RecordDefinitionPublished.ToString(), "dispatch happens after commit"); + + // Published versions are immutable; the next draft is v2 and publishes over the pointer. + Func mutate = () => _h.Definitions.SaveDraftAsync(Dept, Admin, "shift-log", 1, published.RowVersion, RecordDefinitionsService.ToDraftInput(published)); + await mutate.Should().ThrowAsync().WithMessage("*immutable*"); + (await _h.Definitions.GetPublishedAsync(Dept)).Should().ContainSingle(v => v.Version == 1); + (await _h.Definitions.GetCurrentPublishedAsync(Dept, "shift-log")).Version.Should().Be(1); + + var draft = await _h.Definitions.OpenDraftAsync(Dept, Admin, "shift-log"); + draft.Version.Should().Be(2); draft.IsDraft.Should().BeTrue(); + draft.Schema.FindField("site").Should().NotBeNull("the draft copies the published schema"); + Func second = () => _h.Definitions.OpenDraftAsync(Dept, Admin, "shift-log"); + await second.Should().ThrowAsync("one open draft at a time"); + + var impact = await _h.Definitions.ImpactPreviewAsync(Dept, "shift-log", 2); + impact.Clients.Should().HaveCount(4); + impact.CurrentPublishedVersion.Should().Be(1); + impact.UsesRepeatingGroups.Should().BeTrue(); + + _h.Authorization.Setup(a => a.HasPermissionAsync("editor", Dept, PermissionTypes.PublishRecordDefinitions)).ReturnsAsync(false); + Func denied = () => _h.Definitions.PublishAsync(Dept, "editor", "shift-log", 2, draft.RowVersion); + await denied.Should().ThrowAsync(); + Func stale = () => _h.Definitions.PublishAsync(Dept, Admin, "shift-log", 2, draft.RowVersion + 5); + await stale.Should().ThrowAsync(); + } + + [Test] + public async Task Retire_marks_definition_and_versions_enqueues_114_and_blocks_new_drafts() + { + await _h.CreateAndPublishAsync("shift-log", "Shift log", Schema(Section("s", "S", Field("summary", RmsFieldType.ShortText)))); + var definition = _h.Defs.Definitions.Single(); + Func noReason = () => _h.Definitions.RetireAsync(Dept, Admin, "shift-log", definition.RowVersion, " "); + await noReason.Should().ThrowAsync(); + + var retired = await _h.Definitions.RetireAsync(Dept, Admin, "shift-log", definition.RowVersion, "Replaced by shift-log-v2"); + retired.IsRetired.Should().BeTrue(); + retired.RetiredReason.Should().Be("Replaced by shift-log-v2"); + _h.Version("shift-log", 1).State.Should().Be((int)RmsDefinitionVersionState.Retired); + _h.Events(WorkflowTriggerEventType.RecordDefinitionRetired).Should().ContainSingle().Which.PayloadJson.Should().Contain("Replaced by shift-log-v2"); + (await _h.Definitions.GetPublishedAsync(Dept)).Should().BeEmpty("retired definitions are not offered on New Record"); + (await _h.Definitions.ListAsync(Dept)).Should().NotContain(s => s.Key == "shift-log"); + (await _h.Definitions.ListAsync(Dept, includeRetired: true)).Should().ContainSingle(s => s.Key == "shift-log" && s.Retired); + Func reopen = () => _h.Definitions.OpenDraftAsync(Dept, Admin, "shift-log"); + await reopen.Should().ThrowAsync(); + (await _h.Definitions.RetireAsync(Dept, Admin, "shift-log", 999, "again")).IsRetired.Should().BeTrue("retire is idempotent"); + } + + [Test] + public async Task Diff_reports_breaking_changes_and_migration_moves_open_drafts_forward() + { + var v1 = Schema(Section("s", "S", Field("summary", RmsFieldType.ShortText), Field("count", RmsFieldType.Integer), Field("old_note", RmsFieldType.LongText))); + await _h.CreateAndPublishAsync("shift-log", "Shift log", v1); + var published1 = _h.Version("shift-log", 1); + + // A draft Record on v1 with values, plus a finalized one that must never move. + var record = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput + { + DefinitionKey = "shift-log", Values = new List { Value("s", "summary", "Night shift"), Value("s", "count", "4"), Value("s", "old_note", "keep me") } + }); + var finalized = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "shift-log", Values = new List { Value("s", "summary", "Done") } }); + await _h.Records.FinalizeAsync(Dept, Author, finalized.Record.RmsOperationalRecordId, finalized.Record.RowVersion, "1", null, null); + + var draft2 = await _h.Definitions.OpenDraftAsync(Dept, Admin, "shift-log"); + var input = RecordDefinitionsService.ToDraftInput(draft2); + input.Schema = Schema(Section("s", "S", Field("summary", RmsFieldType.ShortText, true), Field("headcount", RmsFieldType.Integer), Field("new_flag", RmsFieldType.Boolean))); + input.ChangeNotes = "rename count, drop old_note"; + await _h.Definitions.SaveDraftAsync(Dept, Admin, "shift-log", 2, draft2.RowVersion, input); + var diff = await _h.Definitions.DiffAsync(Dept, "shift-log", 1, 2); + diff.Entries.Should().Contain(e => e.Kind == "field" && e.Change == "removed" && e.Key == "count"); + diff.Entries.Should().Contain(e => e.Kind == "field" && e.Change == "added" && e.Key == "headcount"); + diff.Breaking.Should().BeTrue("removing a field breaks older values"); + + Func early = () => _h.Definitions.MigrateDraftsAsync(Dept, Admin, "shift-log", 1, 2, null, true); + await early.Should().ThrowAsync("drafts only migrate to a published version"); + await _h.PublishAsync("shift-log"); + + var mapping = new List { new RecordDefinitionFieldMapping { FromFieldKey = "count", ToFieldKey = "headcount" } }; + var preview = await _h.Definitions.MigrateDraftsAsync(Dept, Admin, "shift-log", 1, 2, mapping, true); + preview.Migrated.Should().Be(1, "only the open draft on v1 counts; finalized Records never move"); + preview.UnmappedFieldKeys.Should().Contain("old_note").And.NotContain("count"); + _h.Store.Records.Single(r => r.RmsOperationalRecordId == record.Record.RmsOperationalRecordId).DefinitionVersion.Should().Be(1, "preview changes nothing"); + + var result = await _h.Definitions.MigrateDraftsAsync(Dept, Admin, "shift-log", 1, 2, mapping, false); + result.Migrated.Should().Be(1); + var moved = _h.Store.Records.Single(r => r.RmsOperationalRecordId == record.Record.RmsOperationalRecordId); + moved.DefinitionVersion.Should().Be(2); + var values = await _h.TypedValues.HydrateAsync(Dept, moved.RmsOperationalRecordId, null, _h.Version("shift-log", 2), true); + values.Scalar("headcount").Value.Should().Be("4"); + values.Scalar("summary").Value.Should().Be("Night shift"); + values.Scalar("old_note").Should().BeNull("dropped fields do not survive migration"); + _h.Store.Records.Single(r => r.RmsOperationalRecordId == finalized.Record.RmsOperationalRecordId).DefinitionVersion.Should().Be(1); + published1.IsPublished.Should().BeTrue("earlier published versions stay readable for their Records"); + } + + [Test] + public async Task Draft_versions_delete_only_while_unreferenced() + { + await _h.CreateAsync("shift-log", "Shift log", Schema(Section("s", "S", Field("summary", RmsFieldType.ShortText)))); + var draft = _h.Version("shift-log", 1); + _h.Defs.Values.Add(new RmsRecordValue { RmsRecordValueId = "v", DepartmentId = Dept, RecordId = "r1", RmsRecordDefinitionVersionId = draft.RmsRecordDefinitionVersionId, FieldKey = "summary", TextValue = "x" }); + Func referenced = () => _h.Definitions.DeleteDraftAsync(Dept, Admin, "shift-log", 1); + await referenced.Should().ThrowAsync(); + _h.Defs.Values.Clear(); + (await _h.Definitions.DeleteDraftAsync(Dept, Admin, "shift-log", 1)).Should().BeTrue(); + _h.Defs.Versions.Should().BeEmpty(); + (await _h.Definitions.GetAsync(Dept, "shift-log")).Should().BeNull("the last version deleted removes the definition"); + (await _h.Definitions.DeleteDraftAsync(Dept, Admin, "shift-log", 1)).Should().BeFalse(); + } + + [Test] + public async Task Clone_copies_the_published_version_under_a_new_key() + { + await _h.CreateAndPublishAsync("shift-log", "Shift log", Schema(Section("s", "S", Field("summary", RmsFieldType.ShortText))), d => d.Numbering.Prefix = "SL"); + var clone = await _h.Definitions.CreateAsync(Dept, Admin, new RecordDefinitionCreateInput { DefinitionKey = "shift-log-b", Name = "Shift log B", CloneFromDefinitionKey = "shift-log" }); + clone.Draft.Schema.FindField("summary").Should().NotBeNull(); + clone.Draft.Numbering.Prefix.Should().Be("SL"); + clone.Definition.Name.Should().Be("Shift log B"); + clone.Published.Should().BeNull(); + } + + [Test] + public void Cycle_detection_finds_a_loop_and_ignores_trees() + { + var graph = new Dictionary>(StringComparer.OrdinalIgnoreCase) { ["a"] = new HashSet { "b" }, ["b"] = new HashSet { "c" }, ["c"] = new HashSet() }; + RecordDefinitionsService.FindCycle(graph).Should().BeNull(); + graph["c"].Add("a"); + RecordDefinitionsService.FindCycle(graph).Should().NotBeNull().And.Contain("a"); + } + [Test] + public async Task Rules_may_reference_a_repeating_row_only_from_a_field_of_the_same_section() + { + var sameRow = Field("failure_reason", RmsFieldType.ShortText); sameRow.Rules.Add(ShowWhen("delivered", "false")); + var ok = new RecordDefinitionDraftInput { Name = "Ok", Numbering = new RecordDefinitionNumbering { Prefix = "OK" }, Schema = Schema(Section("run", "Run", Field("summary", RmsFieldType.ShortText)), Rows("stops", "Stops", null, null, Field("delivered", RmsFieldType.Boolean), sameRow)) }; + (await _h.Definitions.ValidateAsync(Dept, ok)).Issues.Should().NotContain(i => i.Code == "repeating_reference"); + + var crossSection = Field("note", RmsFieldType.ShortText); crossSection.Rules.Add(ShowWhen("delivered", "false")); + var sectionRule = Rows("extras", "Extras", null, null, Field("x", RmsFieldType.ShortText)); sectionRule.Rules.Add(ShowWhen("delivered", "true")); + var bad = new RecordDefinitionDraftInput { Name = "Bad", Numbering = new RecordDefinitionNumbering { Prefix = "BAD" }, Schema = Schema(Section("run", "Run", crossSection), Rows("stops", "Stops", null, null, Field("delivered", RmsFieldType.Boolean)), sectionRule) }; + var issues = (await _h.Definitions.ValidateAsync(Dept, bad)).Issues.Where(i => i.Code == "repeating_reference").ToList(); + issues.Should().HaveCount(2, "a scalar field and a section rule both reach into the repeating section"); + } + + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordDeploymentsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordDeploymentsServiceTests.cs new file mode 100644 index 00000000..e077f351 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordDeploymentsServiceTests.cs @@ -0,0 +1,195 @@ +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.Services.Records; +using static Resgrid.Tests.Rms.RmsDefinitionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RMS-1C deployments (Preview): fixture-driven external orders for IROC, CIFFC and cross-border profiles, the fill lifecycle, closeout refusal and snapshot supersession. + [TestFixture] + public class RecordDeploymentsServiceTests + { + private RmsDefinitionHarness _h; + + [SetUp] + public void SetUp() => _h = new RmsDefinitionHarness(); + + private static RecordDeploymentCreateInput Iroc() => new RecordDeploymentCreateInput + { + ProfileKey = RmsDeploymentProfiles.UsWildland, OrderNumber = "O-1234", IncidentName = "Bear Creek", IncidentNumber = "OR-UPF-000123", IncidentCountry = "us", IncidentSubdivision = "OR", + OrderingOffice = "ORCOC", DispatchOffice = "Central Oregon", RequestingAgency = "USFS", SendingAgency = "Test County Fire", CostCode = "P4NABC", AgreementReference = "MA-2026-01", + ArtifactData = Encoding.UTF8.GetBytes("{\"order\":\"O-1234\"}"), ArtifactFileName = "resource-order.json", ArtifactContentType = "application/json", ArtifactSafeUrl = "https://iroc.example.gov/orders/O-1234", + Fills = new List + { + new RecordDeploymentFillInput { RequestNumber = "O-1", RequestCategory = "Overhead", ResourceKind = "person", Position = "DIVS", AssignedUserId = Author, HomeUnit = "Station 1", NeededOn = new DateTime(2026, 9, 7, 8, 0, 0, DateTimeKind.Utc) }, + new RecordDeploymentFillInput { RequestNumber = "E-3", RequestCategory = "Equipment", ResourceKind = "unit", ResourceType = "Engine T3", AssignedUnitId = 5, AssignedUserId = "p2", Position = "ENGB" } + } + }; + + [Test] + public async Task Creating_from_an_external_order_provisions_the_deployment_definition_and_records_the_order_and_fills() + { + var deployment = await _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, Iroc()); + deployment.IsPreview.Should().BeTrue(); + deployment.Order.SourceScheme.Should().Be("iroc"); + deployment.Order.CurrencyCode.Should().Be("USD"); deployment.Order.MeasurementSystem.Should().Be("customary"); + deployment.Order.HomeProfileKey.Should().Be("us"); deployment.Order.HostProfileKey.Should().Be("us"); + deployment.Order.ArtifactChecksum.Should().NotBeNullOrEmpty(); + deployment.Order.ArtifactSafeUrl.Should().Be("https://iroc.example.gov/orders/O-1234"); + deployment.Order.Status.Should().Be((int)RmsExternalOrderStatus.Open); + deployment.Fills.Select(f => f.RequestNumber).Should().Equal("E-3", "O-1"); + deployment.Fills.Should().OnlyContain(f => f.Status == (int)RmsDeploymentFillStatus.Requested && f.RecordId == deployment.Order.RecordId); + deployment.Fills.Single(f => f.RequestNumber == "E-3").ResourceTypeScheme.Should().Be("iroc"); + deployment.Profile.ProfileKey.Should().Be("us"); + + var definition = (await _h.Definitions.ListAsync(Dept)).Single(d => !d.Locked); + definition.Key.Should().Be(RecordDeploymentsService.DefaultDefinitionKey); + definition.TemplateKey.Should().Be(RecordDeploymentsService.DeploymentTemplateKey); + definition.PublishedVersion.Should().Be(1); + definition.JurisdictionProfileKey.Should().Be("us"); + + var record = deployment.Record; + record.Record.DefinitionKey.Should().Be(RecordDeploymentsService.DefaultDefinitionKey); + record.Record.ExternalId.Should().Be("O-1234"); + record.Values.Scalar("profile").Display.Should().Be("US wildland"); + record.Values.Scalar("order_number").ReferenceId.Should().Be("O-1234"); + record.Values.Scalar("incident_subdivision").Value.Should().Be("US-OR"); + record.Values.Scalar("coordinator").Display.Should().Be("Ada Admin"); + var roster = record.Values.Section("roster").Rows; + roster.Should().HaveCount(2); + roster.Select(r => r.Cell("position").Value).Should().Equal("DIVS", "ENGB"); + roster[1].Cell("unit").Display.Should().Be("Engine 5"); + + (await _h.Deployments.ListAsync(Dept, Author, false)).Should().ContainSingle(o => o.OrderNumber == "O-1234"); + (await _h.Deployments.GetForRecordAsync(Dept, Author, deployment.Order.RecordId)).Order.RmsExternalOrderId.Should().Be(deployment.Order.RmsExternalOrderId); + var second = await _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, new RecordDeploymentCreateInput { ProfileKey = RmsDeploymentProfiles.CaWildland, OrderNumber = "CIFFC-77", IncidentName = "Lac Rouge", IncidentCountry = "CA", IncidentSubdivision = "QC", Fills = new List { new RecordDeploymentFillInput { RequestNumber = "R-1", ResourceKind = "crew", ResourceType = "Type 2 IA" } } }); + second.Order.SourceScheme.Should().Be("ciffc"); second.Order.CurrencyCode.Should().Be("CAD"); second.Order.MeasurementSystem.Should().Be("metric"); + (await _h.Definitions.ListAsync(Dept)).Where(d => !d.Locked).Should().HaveCount(1, "the deployment definition is provisioned once"); + } + + [Test] + public async Task Cross_border_orders_carry_home_and_host_profiles_and_reject_bad_inputs() + { + var deployment = await _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, new RecordDeploymentCreateInput + { + ProfileKey = RmsDeploymentProfiles.CrossBorder, OrderNumber = "IMG-9", IncidentName = "Boundary Fire", IncidentCountry = "CA", IncidentSubdivision = "BC", CurrencyCode = "cad", TimeZoneId = "America/Vancouver", + Fills = new List { new RecordDeploymentFillInput { RequestNumber = "C-1", ResourceKind = "crew", ResourceType = "Type 1 crew", ResourceTypeScheme = "nwcg" } } + }); + deployment.Order.HomeProfileKey.Should().Be("us"); deployment.Order.HostProfileKey.Should().Be("ca"); + deployment.Order.SourceScheme.Should().Be("iroc-ciffc"); deployment.Order.CurrencyCode.Should().Be("CAD"); + deployment.HomeProfile.ProfileKey.Should().Be("us"); deployment.HostProfile.ProfileKey.Should().Be("ca"); + deployment.Record.Values.Scalar("profile").Display.Should().Be("US-CA cross-border"); + + Func profile = () => _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, new RecordDeploymentCreateInput { ProfileKey = "mars", OrderNumber = "x", IncidentName = "x" }); + await profile.Should().ThrowAsync().WithMessage("*not a deployment profile*"); + Func number = () => _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, new RecordDeploymentCreateInput { ProfileKey = RmsDeploymentProfiles.Generic, IncidentName = "x" }); + await number.Should().ThrowAsync(); + Func fill = () => _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, new RecordDeploymentCreateInput { ProfileKey = RmsDeploymentProfiles.Generic, OrderNumber = "L-1", IncidentName = "x", Fills = new List { new RecordDeploymentFillInput { ResourceKind = "person" } } }); + await fill.Should().ThrowAsync().WithMessage("*request number*"); + _h.Authorization.Setup(a => a.HasPermissionAsync("viewer", Dept, PermissionTypes.CreateRecord)).ReturnsAsync(false); + Func denied = () => _h.Deployments.CreateFromExternalOrderAsync(Dept, "viewer", Iroc()); + await denied.Should().ThrowAsync(); + var unsafeUrl = await _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, new RecordDeploymentCreateInput { ProfileKey = RmsDeploymentProfiles.LocalMutualAid, OrderNumber = "L-2", IncidentName = "Local", ArtifactSafeUrl = "http://share.example/orders?token=abc" }); + unsafeUrl.Order.ArtifactSafeUrl.Should().BeNull("only plain https links without a query are kept"); + unsafeUrl.Order.SourceScheme.Should().Be("local"); + } + + [Test] + public async Task Fills_walk_the_lifecycle_and_closeout_waits_for_every_resource_to_return() + { + var deployment = await _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, Iroc()); + var orderId = deployment.Order.RmsExternalOrderId; + var overhead = deployment.Fills.Single(f => f.RequestNumber == "O-1"); + var engine = deployment.Fills.Single(f => f.RequestNumber == "E-3"); + + Func skip = () => _h.Deployments.TransitionFillAsync(Dept, Admin, overhead.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = RmsDeploymentFillStatus.CheckedIn }); + await skip.Should().ThrowAsync().WithMessage("*cannot move from Requested to CheckedIn*"); + Func declineNoReason = () => _h.Deployments.TransitionFillAsync(Dept, Admin, engine.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = RmsDeploymentFillStatus.Declined }); + await declineNoReason.Should().ThrowAsync(); + await _h.Deployments.TransitionFillAsync(Dept, Admin, engine.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = RmsDeploymentFillStatus.Declined, Reason = "Engine out of service" }); + + foreach (var status in new[] { RmsDeploymentFillStatus.Accepted, RmsDeploymentFillStatus.Mobilized, RmsDeploymentFillStatus.CheckedIn, RmsDeploymentFillStatus.Assigned }) + await _h.Deployments.TransitionFillAsync(Dept, Admin, overhead.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = status, Notes = status.ToString() }); + var current = await _h.Deployments.GetAsync(Dept, Admin, orderId); + current.Order.Status.Should().Be((int)RmsExternalOrderStatus.Mobilized); + current.Order.MobilizedOn.Should().NotBeNull(); + var assigned = current.Fills.Single(f => f.RequestNumber == "O-1"); + assigned.Status.Should().Be((int)RmsDeploymentFillStatus.Assigned); + assigned.FilledOn.Should().NotBeNull(); assigned.MobilizedOn.Should().NotBeNull(); assigned.CheckedInOn.Should().NotBeNull(); assigned.AssignedOn.Should().NotBeNull(); + assigned.Notes.Should().Contain("Accepted").And.Contain("Assigned"); + current.AllReturned.Should().BeFalse(); + + await _h.Deployments.TransitionFillAsync(Dept, Admin, overhead.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = RmsDeploymentFillStatus.Released }); + current = await _h.Deployments.GetAsync(Dept, Admin, orderId); + current.Order.Status.Should().Be((int)RmsExternalOrderStatus.Released, "every active fill is released"); + Func early = () => _h.Deployments.CloseoutAsync(Dept, Admin, orderId, current.Order.RowVersion, "done"); + await early.Should().ThrowAsync().WithMessage("*still out: O-1*", "an external release flag never returns a resource"); + + await _h.Deployments.TransitionFillAsync(Dept, Admin, overhead.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = RmsDeploymentFillStatus.Demobilized }); + await _h.Deployments.TransitionFillAsync(Dept, Admin, overhead.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = RmsDeploymentFillStatus.Returned }); + current = await _h.Deployments.GetAsync(Dept, Admin, orderId); + current.AllReturned.Should().BeTrue("declined fills do not block closeout"); + Func stale = () => _h.Deployments.CloseoutAsync(Dept, Admin, orderId, current.Order.RowVersion - 1, "done"); + await stale.Should().ThrowAsync(); + var closed = await _h.Deployments.CloseoutAsync(Dept, Admin, orderId, current.Order.RowVersion, "All returned 09-10"); + closed.Status.Should().Be((int)RmsExternalOrderStatus.ClosedOut); + closed.CloseoutNotes.Should().Be("All returned 09-10"); + Func afterClose = () => _h.Deployments.AddFillAsync(Dept, Admin, orderId, new RecordDeploymentFillInput { RequestNumber = "O-9" }); + await afterClose.Should().ThrowAsync().WithMessage("*closed out*"); + (await _h.Deployments.ListAsync(Dept, Admin, false)).Should().BeEmpty(); + (await _h.Deployments.ListAsync(Dept, Admin, true)).Should().HaveCount(1); + _h.Store.Audits.Count(a => a.RecordId == deployment.Order.RecordId && a.Purpose.StartsWith("Fill ")).Should().Be(8, "every transition is audited"); + } + + [Test] + public async Task Later_source_snapshots_supersede_without_erasing_the_earlier_artifact_reference() + { + var deployment = await _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, Iroc()); + var orderId = deployment.Order.RmsExternalOrderId; + var firstChecksum = deployment.Order.ArtifactChecksum; + Func empty = () => _h.Deployments.RecordSourceSnapshotAsync(Dept, Admin, orderId, null, new byte[0], "x.json", "application/json"); + await empty.Should().ThrowAsync(); + + var updated = await _h.Deployments.RecordSourceSnapshotAsync(Dept, Admin, orderId, null, Encoding.UTF8.GetBytes("{\"order\":\"O-1234\",\"rev\":2}"), "resource-order-v2.json", "application/json"); + updated.SourceVersion.Should().Be("2", "an unnamed snapshot increments the numeric source version"); + updated.ArtifactChecksum.Should().NotBe(firstChecksum); + updated.ArtifactFileName.Should().Be("resource-order-v2.json"); + var superseded = _h.Defs.References.Single(); + superseded.RecordId.Should().Be(deployment.Order.RecordId); + superseded.SemanticRole.Should().Be("superseded-snapshot"); + superseded.Checksum.Should().Be(firstChecksum); + superseded.SourceVersion.Should().Be("1"); + superseded.IdentifierScheme.Should().Be("iroc"); + + var named = await _h.Deployments.RecordSourceSnapshotAsync(Dept, Admin, orderId, "2026-09-06T14:00", Encoding.UTF8.GetBytes("{\"rev\":3}"), "v3.json", "application/json"); + named.SourceVersion.Should().Be("2026-09-06T14:00"); + _h.Defs.References.Should().HaveCount(2); + var added = await _h.Deployments.AddFillAsync(Dept, Admin, orderId, new RecordDeploymentFillInput { RequestNumber = "O-2", RequestCategory = "Overhead", Position = "TFLD", AssignedUserId = "p2" }); + added.HostAgency.Should().BeNull(); added.CostCode.Should().Be("P4NABC", "fills inherit the order's cost code"); + (await _h.Deployments.GetAsync(Dept, Admin, orderId)).Fills.Should().HaveCount(3); + _h.Authorization.Setup(a => a.CanUserViewRecordAsync("stranger", It.IsAny(), Dept)).ReturnsAsync(false); + Func hidden = () => _h.Deployments.GetAsync(Dept, "stranger", orderId); + await hidden.Should().ThrowAsync(); + } + + [Test] + public void Profile_option_keys_and_default_schemes_cover_every_deployment_profile() + { + foreach (var profile in RmsDeploymentProfiles.All) + { + RecordDeploymentsService.ProfileOptionKey(profile).Should().NotBeNullOrEmpty(); + RecordDeploymentsService.DefaultScheme(profile).Should().NotBeNullOrEmpty(); + } + RecordDeploymentsService.DefaultScheme(RmsDeploymentProfiles.Compact).Should().Be("emac"); + RecordDeploymentsService.ProfileOptionKey("unknown").Should().Be("generic"); + RmsDeploymentProfiles.IsKnown("US-WILDLAND").Should().BeTrue(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordEvidenceAdapterTests.cs b/Tests/Resgrid.Tests/Rms/RecordEvidenceAdapterTests.cs index 0523082a..bf30f1a9 100644 --- a/Tests/Resgrid.Tests/Rms/RecordEvidenceAdapterTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordEvidenceAdapterTests.cs @@ -19,6 +19,34 @@ namespace Resgrid.Tests.Rms public class RecordEvidenceAdapterTests { private static RecordEvidenceCaptureRequest Request() => new() { DepartmentId = 9, RecordId = "report", CapturedByUserId = "officer", CallId = 501, CaptureReason = "Officer selected supporting evidence", CoverageStart = new DateTime(2026,9,1,0,0,0,DateTimeKind.Utc), CoverageEnd = new DateTime(2026,9,1,1,0,0,DateTimeKind.Utc) }; + [Test] + public async Task Pack_projection_composes_a_bounded_personnel_check_in_from_the_owning_modules_with_source_ids() + { + var records = new Mock(); var participants = new Mock(); var states = new Mock(); + var departments = new Mock(); var auth = new Mock(); + records.Setup(r => r.GetByIdForDepartmentAsync(9, "report")).ReturnsAsync(new RmsOperationalRecord { RmsOperationalRecordId = "report", DepartmentId = 9, CallId = 501 }); + participants.Setup(p => p.GetForRecordAsync(9, "report", null)).ReturnsAsync(new List { new() { RecordId = "report", UserId = "member", Role = "Crew", GroupNameSnapshot = "Station 1" } }); + states.Setup(s => s.GetLastUserStateByUserIdAsync("member")).ReturnsAsync(new UserState { UserId = "member", State = 3, Timestamp = new DateTime(2026, 9, 1, 0, 30, 0, DateTimeKind.Utc) }); + departments.Setup(d => d.GetAllPersonnelNamesForDepartmentAsync(9)).ReturnsAsync(new List { new() { UserId = "member", FirstName = "Sam", LastName = "Rivera" } }); + var adapter = new PackProjectionEvidenceAdapter(records.Object, participants.Object, Mock.Of(), states.Object, departments.Object, Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), new Lazy(() => auth.Object)); + adapter.Kind.Should().Be(RmsEvidenceKind.ModuleProjection); + + var request = Request(); request.SourceIds = new() { "not-a-projection" }; + Func unknown = () => adapter.CaptureAsync(request); await unknown.Should().ThrowAsync(); + + request.SourceIds = new() { RecordPackProjectionKinds.PersonnelCheckIn }; + Func denied = () => adapter.CaptureAsync(request); await denied.Should().ThrowAsync(); + states.Verify(s => s.GetLastUserStateByUserIdAsync(It.IsAny()), Times.Never, "no module is read before the person check passes"); + + auth.Setup(a => a.CanUserViewPersonAsync("officer", "member", 9)).ReturnsAsync(true); + var capture = await adapter.CaptureAsync(request); + capture.SourceEntityId.Should().Be(RecordPackProjectionKinds.PersonnelCheckIn); + capture.SourceItemCount.Should().Be(1); + var frozen = RecordsEvidenceService.Serialize(capture.Manifest); + frozen.Should().Contain("Sam Rivera").And.Contain("Station 1").And.Contain("\"state_id\":3").And.Contain("source_id"); + } + [Test] public async Task Tracking_requires_unit_tenant_and_location_permission_and_freezes_only_fixes_in_the_window() { diff --git a/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs index baa78ad6..d096e552 100644 --- a/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs @@ -67,7 +67,7 @@ public void SetUp() _records = new RecordsService(_store.RecordsRepo.Object, new RmsRecordValueService(_store.DetailsRepo.Object), _store.ParticipantsRepo.Object, _store.UnitsRepo.Object, _store.AttachmentsRepo.Object, _store.RevisionsRepo.Object, evidence.Object, _store.ScopesRepo.Object, _store.SharesRepo.Object, _store.ProjectionsRepo.Object, _store.AuditsRepo.Object, outbox, cutover.Object, settings.Object, groups.Object, profiles.Object, units.Object, calls.Object, adp.Object, - _store.UnitOfWork.Object, queue.Object, new NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), new PassthroughRecordsProtection()); + _store.UnitOfWork.Object, queue.Object, new NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), new PassthroughRecordsProtection(), Mock.Of(), Mock.Of(), Mock.Of()); _store.ProjectionsRepo.Setup(r => r.GetModifiedSinceAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((int d, DateTime? since, int take, string sinceId) => _store.Projections diff --git a/Tests/Resgrid.Tests/Rms/RecordSavedReportsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordSavedReportsServiceTests.cs new file mode 100644 index 00000000..a1a717b0 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordSavedReportsServiceTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using static Resgrid.Tests.Rms.RmsDefinitionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RMS-1B saved reports: validation against the schema flags, visibility-filtered runs, grouping, aggregates, version mappings and CSV. + [TestFixture] + public class RecordSavedReportsServiceTests + { + private RmsDefinitionHarness _h; + + [SetUp] + public void SetUp() => _h = new RmsDefinitionHarness(); + + private static RecordDefinitionSchema ShiftLog() => Schema( + Section("shift", "Shift", + Field("site", RmsFieldType.ShortText, true, configure: f => { f.Searchable = true; f.Filterable = true; f.Sortable = true; }), + Field("hours", RmsFieldType.Quantity, configure: f => { f.UnitFamily = "time"; f.DefaultUnit = "h"; f.Aggregatable = true; f.Filterable = true; f.Sortable = true; }), + Select("status", "Open", "Closed"), Field("secret", RmsFieldType.ShortText, classification: RmsFieldClassification.Restricted), Field("notes", RmsFieldType.LongText))); + + private static RmsSavedReportDefinition Report(string name, RecordReportSpec spec, bool restricted = false) => new RmsSavedReportDefinition + { + Name = name, DefinitionKey = "shift-log", SpecJson = JsonConvert.SerializeObject(spec), IncludeRestricted = restricted + }; + + private async Task RecordAsync(string site, string hours, string status, bool finalize = true) + { + var draft = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput + { + DefinitionKey = "shift-log", + Values = new List { Value("shift", "site", site), new RecordValueInput { SectionKey = "shift", FieldKey = "hours", Value = hours, UnitCode = "h" }, Value("shift", "status", status), Value("shift", "secret", "S-" + site) } + }); + if (finalize) await _h.Records.FinalizeAsync(Dept, Author, draft.Record.RmsOperationalRecordId, draft.Record.RowVersion, "1", null, null); + return draft.Record.RmsOperationalRecordId; + } + + [Test] + public async Task Validation_checks_columns_filters_group_by_and_aggregates_against_the_schema_flags() + { + await _h.CreateAndPublishAsync("shift-log", "Shift log", ShiftLog()); + var bad = Report("Bad", new RecordReportSpec + { + Columns = new List { "record.number", "notes", "secret", "missing" }, + Filters = new List { new RecordReportFilter { FieldKey = "notes", Operator = RmsRuleOperator.Equals, Value = "x" }, new RecordReportFilter { FieldKey = "site", Operator = RmsRuleOperator.And } }, + GroupByFieldKey = "hours", Aggregates = new List { new RecordReportAggregateSpec { Aggregate = RmsReportAggregate.Sum, FieldKey = "site" } } + }); + var validation = await _h.Reports.ValidateAsync(Dept, bad); + validation.IsValid.Should().BeFalse(); + validation.Issues.Select(i => i.Code).Should().Contain(new[] { "unknown_field", "restricted", "not_filterable", "bad_operator", "not_groupable", "not_aggregatable" }); + validation.Issues.Should().NotContain(i => i.Path == "columns" && i.Message.Contains("notes"), "long text is exportable even though it cannot filter"); + + Func save = () => _h.Reports.SaveAsync(Dept, Admin, bad); + await save.Should().ThrowAsync(); + var noDefinition = await _h.Reports.ValidateAsync(Dept, new RmsSavedReportDefinition { Name = "x", DefinitionKey = "nope", SpecJson = "{}" }); + noDefinition.Issues.Should().ContainSingle(i => i.Code == "unknown_definition"); + + var good = Report("Good", new RecordReportSpec { Columns = new List { "record.number", "site", "hours" }, GroupByFieldKey = "status", Aggregates = new List { new RecordReportAggregateSpec { Aggregate = RmsReportAggregate.Sum, FieldKey = "hours" } } }); + (await _h.Reports.ValidateAsync(Dept, good)).IsValid.Should().BeTrue(); + var saved = await _h.Reports.SaveAsync(Dept, Admin, good); + saved.RmsSavedReportDefinitionId.Should().NotBeNullOrEmpty(); + saved.RowVersion.Should().Be(1); + (await _h.Reports.GetForDepartmentAsync(Dept)).Should().ContainSingle(r => r.Name == "Good"); + + _h.Authorization.Setup(a => a.HasPermissionAsync("analyst", Dept, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + Func restricted = () => _h.Reports.SaveAsync(Dept, "analyst", Report("Secrets", new RecordReportSpec { Columns = new List { "secret" } }, restricted: true)); + await restricted.Should().ThrowAsync(); + var staleCopy = new RmsSavedReportDefinition { RmsSavedReportDefinitionId = saved.RmsSavedReportDefinitionId, Name = "Renamed", DefinitionKey = saved.DefinitionKey, SpecJson = saved.SpecJson, RowVersion = 99 }; + Func stale = () => _h.Reports.SaveAsync(Dept, Admin, staleCopy); + await stale.Should().ThrowAsync(); + } + + [Test] + public async Task Runs_group_aggregate_sort_and_respect_row_limits_and_restricted_gates() + { + await _h.CreateAndPublishAsync("shift-log", "Shift log", ShiftLog()); + await RecordAsync("Depot 4", "2", "open"); + await RecordAsync("Depot 5", "1.5", "closed"); + await RecordAsync("Depot 6", "3", "open"); + await RecordAsync("Depot 7", "8", "open", finalize: false); + + var spec = new RecordReportSpec + { + Columns = new List { "record.number", "site", "hours", "status" }, GroupByFieldKey = "status", SortFieldKey = "site", SortDescending = true, + Aggregates = new List { new RecordReportAggregateSpec { Aggregate = RmsReportAggregate.Sum, FieldKey = "hours" }, new RecordReportAggregateSpec { Aggregate = RmsReportAggregate.Count } }, + Filters = new List { new RecordReportFilter { FieldKey = "site", Operator = RmsRuleOperator.NotEquals, Value = "Depot 5" } } + }; + var report = await _h.Reports.SaveAsync(Dept, Admin, Report("Hours by status", spec)); + + var result = await _h.Reports.RunAsync(Dept, Author, report.RmsSavedReportDefinitionId); + result.ColumnLabels.Should().Equal("Record number", "Site", "Hours", "Status"); + result.TotalMatched.Should().Be(2, "drafts are excluded and the filter drops Depot 5"); + result.Rows.Select(r => r[1]).Should().Equal("Depot 6", "Depot 4"); + result.Rows[0][0].Should().StartWith("SHI"); + result.Groups.Should().ContainSingle(g => g.GroupKey == "Open" && g.Count == 2); + result.Groups.Single().Aggregates["sum:hours"].Should().Be(300m, "aggregates sum the canonical (minutes) value"); + result.Groups.Single().Aggregates["count"].Should().Be(2); + _h.Defs.Reports.Single().LastRunByUserId.Should().Be(Author); + + spec.IncludeDrafts = true; spec.Filters.Clear(); report.SpecJson = JsonConvert.SerializeObject(spec); report.MaxRowsPerRun = 2; + await _h.Reports.SaveAsync(Dept, Admin, report); + var limited = await _h.Reports.RunAsync(Dept, Author, report.RmsSavedReportDefinitionId); + limited.Rows.Should().HaveCount(2); + limited.Truncated.Should().BeTrue(); + limited.Warnings.Should().ContainSingle(w => w.Contains("stopped at 2")); + + _h.Authorization.Setup(a => a.HasPermissionAsync("viewer", Dept, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + var secrets = await _h.Reports.SaveAsync(Dept, Admin, Report("Secrets", new RecordReportSpec { Columns = new List { "site", "secret" } }, restricted: true)); + Func denied = () => _h.Reports.RunAsync(Dept, "viewer", secrets.RmsSavedReportDefinitionId); + await denied.Should().ThrowAsync(); + var revealed = await _h.Reports.RunAsync(Dept, Admin, secrets.RmsSavedReportDefinitionId); + revealed.Rows.Should().OnlyContain(r => r[1].StartsWith("S-Depot")); + } + + [Test] + public async Task Version_mappings_carry_older_records_into_a_report_on_the_newer_version() + { + await _h.CreateAndPublishAsync("shift-log", "Shift log", ShiftLog()); + await RecordAsync("Depot 4", "2", "open"); + var v2 = await _h.Definitions.OpenDraftAsync(Dept, Admin, "shift-log"); + var input = Resgrid.Services.Records.RecordDefinitionsService.ToDraftInput(v2); + var site = input.Schema.FindField("site"); site.Key = "location"; site.Label = "Location"; + await _h.Definitions.SaveDraftAsync(Dept, Admin, "shift-log", 2, v2.RowVersion, input); + await _h.PublishAsync("shift-log"); + var v2Record = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "shift-log", Values = new List { Value("shift", "location", "Depot 9") } }); + await _h.Records.FinalizeAsync(Dept, Author, v2Record.Record.RmsOperationalRecordId, v2Record.Record.RowVersion, "1", null, null); + + var report = await _h.Reports.SaveAsync(Dept, Admin, Report("Locations", new RecordReportSpec { Columns = new List { "location", "record.definition_version" } })); + var unmapped = await _h.Reports.RunAsync(Dept, Author, report.RmsSavedReportDefinitionId); + unmapped.Rows.Should().HaveCount(2); + unmapped.UnmappedVersions.Should().Equal(1); + unmapped.Rows.Single(r => r[1] == "1")[0].Should().BeEmpty(); + + report.SpecJson = JsonConvert.SerializeObject(new RecordReportSpec { Columns = new List { "location", "record.definition_version" }, VersionMappings = new Dictionary> { [1] = new Dictionary { ["location"] = "site" } } }); + await _h.Reports.SaveAsync(Dept, Admin, report); + var mapped = await _h.Reports.RunAsync(Dept, Author, report.RmsSavedReportDefinitionId); + mapped.UnmappedVersions.Should().BeEmpty(); + mapped.Rows.Select(r => r[0]).Should().BeEquivalentTo(new[] { "Depot 4", "Depot 9" }); + (await _h.Reports.DeleteAsync(Dept, Admin, report.RmsSavedReportDefinitionId)).Should().BeTrue(); + (await _h.Reports.GetForDepartmentAsync(Dept)).Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordTemplateCatalogTests.cs b/Tests/Resgrid.Tests/Rms/RecordTemplateCatalogTests.cs new file mode 100644 index 00000000..984a8199 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordTemplateCatalogTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services.Records; +using static Resgrid.Tests.Rms.RmsDefinitionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RMS-1B launch templates and RMS-1C operational packs: every rendering validates, overlays apply, provenance is stated, Preview is labeled. + [TestFixture] + public class RecordTemplateCatalogTests + { + private RmsDefinitionHarness _h; + + [SetUp] + public void SetUp() => _h = new RmsDefinitionHarness(); + + [Test] + public async Task Every_template_validates_under_every_supported_profile_and_locale() + { + var catalog = await _h.Templates.GetCatalogAsync(); + catalog.Select(p => p.PackKey).Should().BeEquivalentTo(new[] { "template.launch", "pack.cert", "pack.sar", "pack.disaster-assessment", "pack.eoc", "pack.hazmat", "pack.industrial", "pack.exercise", "pack.mutual-aid" }); + catalog.Single(p => p.PackKey == "template.launch").Definitions.Select(d => d.Key).Should().BeEquivalentTo(new[] + { + "template.security-patrol", "template.security-incident", "template.delivery-run", "template.bus-route-eod", "template.shift-summary", "template.job-completion" + }); + foreach (var pack in catalog) + { + if (pack.PackKey != "template.launch") pack.Sources.Should().NotBeEmpty($"{pack.PackKey} declares its sources"); + pack.ReviewedOn.Should().NotBeNull(); + foreach (var template in pack.Definitions) + foreach (var profile in pack.SupportedProfiles) + foreach (var locale in pack.SupportedLocales) + { + var rendering = await _h.Templates.RenderAsync(template.Key, profile, locale); + rendering.Should().NotBeNull(); + var draft = new RecordDefinitionDraftInput + { + Name = rendering.Template.Name, Schema = rendering.Schema, LifecyclePreset = rendering.Template.LifecyclePreset, + Numbering = new RecordDefinitionNumbering { Prefix = rendering.Template.NumberPrefix }, PermittedSubjectTypes = rendering.Template.PermittedSubjectTypes, + Classification = rendering.Template.Classification, RetentionYears = rendering.Template.RetentionYears, ClientSurface = rendering.Template.ClientSurface + }; + var validation = await _h.Definitions.ValidateAsync(Dept, draft); + validation.Issues.Where(i => i.Severity == "error").Should().BeEmpty($"{template.Key} under {profile}/{locale}: {string.Join("; ", validation.Issues.Where(i => i.Severity == "error").Select(i => i.Path + " " + i.Code + " " + i.Message))}"); + rendering.ProvenanceStatement.Should().NotBeNullOrWhiteSpace(); + rendering.Schema.AllFields().Should().OnlyContain(f => !string.IsNullOrWhiteSpace(f.Label)); + } + } + } + + [Test] + public async Task Overlays_relabel_convert_units_and_set_currency_without_touching_the_base_template() + { + var generic = await _h.Templates.RenderAsync("template.security-patrol", "generic", null); + var ca = await _h.Templates.RenderAsync("template.security-patrol", "ca", "fr-CA"); + ca.Schema.FindField("officer").Label.Should().Be("Agent"); + generic.Schema.FindField("officer").Label.Should().Be("Officer"); + RecordTemplateCatalog.Find("template.security-patrol").Schema.FindField("officer").Label.Should().Be("Officer", "rendering deep-copies the schema"); + ca.Locale.Should().Be("fr-CA"); ca.MeasurementSystem.Should().Be("metric"); ca.CurrencyCode.Should().Be("CAD"); + ca.ArtifactStatus.Should().Be(RmsArtifactStatus.Compatible); + generic.ArtifactStatus.Should().Be(RmsArtifactStatus.DepartmentLocal); + generic.ProvenanceStatement.Should().StartWith("Department-local template"); + + var usDebrief = await _h.Templates.RenderAsync("pack.sar.segment-debrief", "us", null); + var caDebrief = await _h.Templates.RenderAsync("pack.sar.segment-debrief", "ca", null); + usDebrief.Schema.FindField("track_spacing").DefaultUnit.Should().Be("ft"); + caDebrief.Schema.FindField("track_spacing").DefaultUnit.Should().Be("m"); + usDebrief.ProvenanceStatement.Should().StartWith("Compatible with"); + usDebrief.Sources.Should().NotBeEmpty(); + + var usDelivery = await _h.Templates.RenderAsync("template.delivery-run", "us", null); + var caDelivery = await _h.Templates.RenderAsync("template.delivery-run", "ca", null); + usDelivery.Schema.FindField("mileage").FixedUnitLabel.Should().Be("mi"); + caDelivery.Schema.FindField("mileage").FixedUnitLabel.Should().Be("km"); + } + + [Test] + public async Task Preview_packs_are_labeled_and_locked_classification_floors_apply() + { + var catalog = await _h.Templates.GetCatalogAsync(); + catalog.Where(p => p.IsPreview).Select(p => p.PackKey).Should().BeEquivalentTo(new[] { "pack.cert", "pack.mutual-aid" }); + catalog.Single(p => p.PackKey == "pack.hazmat").IsPreview.Should().BeFalse(); + var incident = await _h.Templates.RenderAsync("template.security-incident", "generic", null); + incident.Schema.FindField("name").Classification.Should().Be(RmsFieldClassification.Restricted, "involved-person names carry the pack's restricted floor"); + incident.Schema.FindField("contact").Classification.Should().Be(RmsFieldClassification.Restricted); + var deployment = await _h.Templates.RenderAsync("pack.mutual-aid.deployment", "us-ca", null); + deployment.Schema.FindSection("roster").Repeating.Should().BeTrue(); + deployment.Schema.FindField("profile").Options.Select(o => o.Key).Should().Contain("us-ca-cross-border"); + RecordsClientCapabilities.Derive(deployment.Schema).Should().Be(RecordsClientCapabilities.Packs); + } + + [Test] + public async Task Pack_protected_data_policies_set_classification_floors_in_every_rendering() + { + var mission = await _h.Templates.RenderAsync("pack.sar.mission-summary", "generic", null); + mission.Policies.Should().NotBeEmpty(); + mission.Policies.Select(p => p.Category).Should().Contain("subject-clue-recovery").And.Contain("treatment-casualty"); + mission.Schema.FindField("subject_name").Classification.Should().Be(RmsFieldClassification.Restricted); + mission.Schema.FindField("medical_concerns").Classification.Should().Be(RmsFieldClassification.Protected, "medical concerns are health information"); + mission.Schema.FindField("mission_number").Classification.Should().Be(RmsFieldClassification.Standard, "policies touch only the fields they name"); + + var release = await _h.Templates.RenderAsync("pack.hazmat.release-response", "generic", null); + release.Schema.FindField("exposure_details").Classification.Should().Be(RmsFieldClassification.Protected); + release.Schema.FindField("entrant").Classification.Should().Be(RmsFieldClassification.Restricted, "entrants are identifiable persons"); + release.Schema.FindField("persons_deconned").Classification.Should().Be(RmsFieldClassification.Standard, "counts stay Workflow-exposed"); + + var deployment = await _h.Templates.RenderAsync("pack.mutual-aid.deployment", "generic", null); + deployment.Schema.FindField("travel_instructions").Classification.Should().Be(RmsFieldClassification.Restricted); + deployment.Schema.FindField("receipts").Classification.Should().Be(RmsFieldClassification.Restricted); + + foreach (var pack in await _h.Templates.GetCatalogAsync()) + foreach (var template in pack.Definitions) + { + var definition = _h.Templates.GetTemplate(template.Key); + foreach (var policy in definition.ProtectedDataPolicies) + { + policy.FieldKeys.Should().NotBeEmpty(); + policy.Rationale.Should().NotBeNullOrWhiteSpace(); + foreach (var key in policy.FieldKeys) + definition.Schema.FindField(key).Should().NotBeNull($"policy '{policy.Category}' of {template.Key} names '{key}'"); + } + } + } + + [Test] + public async Task Unsupported_profiles_and_unknown_templates_fail_closed() + { + (await _h.Templates.RenderAsync("template.missing", "generic", null)).Should().BeNull(); + Func bad = () => _h.Templates.RenderAsync("template.security-patrol", "mars", null); + await bad.Should().ThrowAsync(); + Func unsupported = () => _h.Templates.RenderAsync("template.security-patrol", "us-ca", null); + await unsupported.Should().ThrowAsync().WithMessage("*does not support profile*"); + } + + [Test] + public async Task Catalog_mirrors_to_product_scope_rows_once() + { + var written = await _h.Templates.EnsureCatalogAsync(); + written.Should().BeGreaterThan(0); + _h.Defs.Packs.Should().HaveCount(RecordTemplateCatalog.Packs.Count).And.OnlyContain(p => p.DepartmentId == RmsTemplatePackVersion.ProductDepartmentId && p.ContentChecksum != null); + _h.Defs.Profiles.Select(p => p.ProfileKey).Should().BeEquivalentTo(new[] { "generic", "us", "ca", "us-ca" }); + (await _h.Templates.EnsureCatalogAsync()).Should().Be(0, "a second call is a no-op within the process"); + var profiles = await _h.Templates.GetProfilesAsync(); + profiles.Single(p => p.ProfileKey == "us").CurrencyCode.Should().Be("USD"); + profiles.Single(p => p.ProfileKey == "ca").MeasurementSystem.Should().Be("metric"); + profiles.Single(p => p.ProfileKey == "ca").SupportedLocales.Should().Contain("fr-CA"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordTypedValuesServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordTypedValuesServiceTests.cs new file mode 100644 index 00000000..a10acb23 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordTypedValuesServiceTests.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services.Records; +using static Resgrid.Tests.Rms.RmsDefinitionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RMS-1B/1C typed values: every field type parses into exactly one column group, rules, finalize validation, projections and units. + [TestFixture] + public class RecordTypedValuesServiceTests + { + private RmsDefinitionHarness _h; + + [SetUp] + public void SetUp() + { + _h = new RmsDefinitionHarness(); + _h.Store.Attachments.Add(new RmsRecordAttachment { RmsRecordAttachmentId = "att-1", DepartmentId = Dept, RecordId = "rec-1", FileName = "photo.jpg" }); + } + + private static RecordDefinitionSchema AllTypes() => Schema( + Section("main", "Main", + Field("short", RmsFieldType.ShortText, configure: f => f.Searchable = true), Field("long", RmsFieldType.LongText, configure: f => f.Searchable = true), + Field("whole", RmsFieldType.Integer, configure: f => { f.Min = 0; f.Max = 10; f.WorkflowExposed = true; }), Field("dec", RmsFieldType.Decimal, configure: f => f.FixedUnitLabel = "km"), + Field("flag", RmsFieldType.Boolean, configure: f => f.WorkflowExposed = true), Field("day", RmsFieldType.Date), Field("when", RmsFieldType.DateTime), Field("dur", RmsFieldType.Duration), + Select("pick", "Alpha", "Beta"), Multi("many", "One", "Two", "Three"), Field("addr", RmsFieldType.Address), Field("person", RmsFieldType.Person), + Field("unit", RmsFieldType.Unit), Field("group", RmsFieldType.Group), Field("contact", RmsFieldType.Contact), Field("file", RmsFieldType.Attachment), + Field("sig", RmsFieldType.Signature), Field("ext", RmsFieldType.ExternalReference, configure: f => f.ReferenceType = "ticket"), + Field("money", RmsFieldType.Currency, configure: f => f.DefaultCurrency = "CAD"), Field("length", RmsFieldType.Quantity, configure: f => { f.UnitFamily = "length"; f.DefaultUnit = "m"; f.Aggregatable = true; }), + Field("where", RmsFieldType.CountrySubdivision), Field("call", RmsFieldType.CallReference), Field("item", RmsFieldType.InventoryReference), Field("task", RmsFieldType.ChecklistWorkOrderReference), + Field("secret", RmsFieldType.ShortText, classification: RmsFieldClassification.Restricted))); + + [Test] + public async Task Every_field_type_parses_to_one_column_group_and_shapes_back_with_a_display() + { + var version = DetachedVersion("all", AllTypes()); + var inputs = new List + { + Value("main", "short", "Night shift"), Value("main", "long", "Long narrative"), Value("main", "whole", "7"), Value("main", "dec", "12.5"), Value("main", "flag", "true"), + Value("main", "day", "2026-09-06"), Value("main", "when", "2026-09-06T10:00:00-05:00"), Value("main", "dur", "01:30"), Value("main", "pick", "beta"), + new RecordValueInput { SectionKey = "main", FieldKey = "many", Values = new List { "one", "three" } }, + new RecordValueInput { SectionKey = "main", FieldKey = "addr", Value = "1 Main St", ReferenceId = "45.5, -122.6" }, + Reference("main", "person", "author"), Reference("main", "unit", "5"), Reference("main", "group", "11"), Reference("main", "contact", "c1"), Reference("main", "file", "att-1"), + Value("main", "sig", "I confirm this report"), Reference("main", "ext", "TCK-9", "jira"), + new RecordValueInput { SectionKey = "main", FieldKey = "money", Value = "12.5", CurrencyCode = "usd" }, + new RecordValueInput { SectionKey = "main", FieldKey = "length", Value = "10", UnitCode = "ft" }, + Value("main", "where", "us-ca"), Reference("main", "call", "77"), Reference("main", "item", "9"), Reference("main", "task", "wo-1", "workorder"), Value("main", "secret", "hidden text") + }; + (await _h.TypedValues.ValidateAsync(Dept, version, inputs, false)).IsValid.Should().BeTrue(); + + var saved = await _h.TypedValues.SaveDraftValuesAsync(Dept, Author, "rec-1", version, inputs); + _h.Defs.Values.Should().HaveCount(inputs.Count + 1, "multi-select stores one row per option"); + _h.Defs.Values.Should().OnlyContain(v => v.PopulatedColumnGroups() == 1 && v.RmsRecordDefinitionVersionId == version.RmsRecordDefinitionVersionId && v.RevisionId == null); + _h.Defs.Values.Single(v => v.FieldKey == "secret").TextValue.Should().Be("hidden text", "restricted values are stored in clear until ADP catalog v11 seals them"); + + var hydrated = await _h.TypedValues.HydrateAsync(Dept, "rec-1", null, version, true); + hydrated.DefinitionKey.Should().Be("all"); + hydrated.Scalar("short").Display.Should().Be("Night shift"); + hydrated.Scalar("whole").Number.Should().Be(7); + hydrated.Scalar("dec").Display.Should().Be("12.5 km"); + hydrated.Scalar("flag").Value.Should().Be("true"); + hydrated.Scalar("day").Display.Should().Be("2026-09-06"); + hydrated.Scalar("when").Value.Should().Be("2026-09-06T15:00:00.0000000Z"); + hydrated.Scalar("when").OffsetMinutes.Should().Be(-300); + hydrated.Scalar("when").Display.Should().Be("2026-09-06 10:00 -05:00"); + hydrated.Scalar("dur").Number.Should().Be(5400); hydrated.Scalar("dur").Display.Should().Be("01:30"); + hydrated.Scalar("pick").Display.Should().Be("Beta"); + hydrated.Scalar("many").Values.Should().Equal("one", "three"); hydrated.Scalar("many").Display.Should().Be("One, Three"); + hydrated.Scalar("addr").ReferenceId.Should().Be("45.5,-122.6"); hydrated.Scalar("addr").Display.Should().Be("1 Main St (45.5,-122.6)"); + hydrated.Scalar("person").Display.Should().Be("Pat Author"); hydrated.Scalar("person").ReferenceType.Should().Be("user"); + hydrated.Scalar("unit").Display.Should().Be("Engine 5"); + hydrated.Scalar("group").Display.Should().Be("Station 1"); + hydrated.Scalar("contact").Display.Should().Be("Acme Logistics"); + hydrated.Scalar("file").ReferenceId.Should().Be("att-1"); + hydrated.Scalar("sig").ReferenceType.Should().Be("signature"); hydrated.Scalar("sig").ReferenceId.Should().Be(Author.ToUpperInvariant()); + hydrated.Scalar("sig").Display.Should().Contain("Pat Author").And.Contain("signed"); + hydrated.Scalar("ext").ReferenceType.Should().Be("external:jira"); hydrated.Scalar("ext").Display.Should().Be("TCK-9 (jira)"); + hydrated.Scalar("money").CurrencyCode.Should().Be("USD"); hydrated.Scalar("money").Display.Should().Be("12.50 USD"); + hydrated.Scalar("length").UnitCode.Should().Be("ft"); hydrated.Scalar("length").CanonicalNumber.Should().Be(3.048m); hydrated.Scalar("length").CanonicalUnitCode.Should().Be("m"); + hydrated.Scalar("where").Value.Should().Be("US-CA"); hydrated.Scalar("where").Display.Should().Contain("California"); + hydrated.Scalar("call").Display.Should().Be("C-77 Structure fire"); + hydrated.Scalar("item").Display.Should().Be("Hose 50ft"); + hydrated.Scalar("task").ReferenceType.Should().Be("workorder"); hydrated.Scalar("task").Display.Should().Be("wo-1"); + hydrated.Scalar("secret").Display.Should().Be("hidden text"); + hydrated.WithheldFieldKeys.Should().BeEmpty(); + + // Round trip: inputs regenerated from the set re-validate and re-save identically. + var again = await _h.TypedValues.SaveDraftValuesAsync(Dept, Author, "rec-1", version, hydrated.ToInputs()); + again.Scalar("length").CanonicalNumber.Should().Be(3.048m); + again.Scalar("many").Values.Should().Equal("one", "three"); + saved.AllCells().Count(c => c.Display != null).Should().Be(again.AllCells().Count(c => c.Display != null)); + } + + [Test] + public async Task Invalid_inputs_produce_coded_issues_instead_of_rows() + { + var version = DetachedVersion("all", AllTypes()); + var validation = await _h.TypedValues.ValidateAsync(Dept, version, new List + { + Value("main", "whole", "11"), Value("main", "whole", "2"), Value("main", "dec", "abc"), Value("main", "flag", "maybe"), Value("main", "day", "06/09/2026"), + Value("main", "pick", "gamma"), Reference("main", "unit", "99"), Reference("main", "person", "nobody"), Reference("main", "file", "att-9"), + new RecordValueInput { SectionKey = "main", FieldKey = "money", Value = "1", CurrencyCode = "XXX" }, new RecordValueInput { SectionKey = "main", FieldKey = "length", Value = "1", UnitCode = "kg" }, + Value("main", "where", "US-ZZ"), Reference("main", "call", "1"), Reference("main", "task", "x", "recipe"), Value("main", "nope", "x"), Value("other", "short", "wrong section") + }, false); + validation.IsValid.Should().BeFalse(); + validation.Issues.Select(i => i.Code).Should().Contain(new[] { "out_of_range", "duplicate_value", "not_number", "not_boolean", "not_date", "unknown_option", "unknown_unit", "unknown_person", "unknown_currency", "unknown_subdivision", "unknown_call", "bad_reference", "unknown_field", "wrong_section" }); + validation.Issues.Should().Contain(i => i.FieldKey == "unit" && i.Code == "unknown_unit", "a unit from another department is rejected"); + Func save = () => _h.TypedValues.SaveDraftValuesAsync(Dept, Author, "rec-1", version, new List { Value("main", "whole", "99") }); + await save.Should().ThrowAsync(); + _h.Defs.Values.Should().BeEmpty(); + } + + [Test] + public async Task Rules_show_and_require_fields_only_at_finalize_and_hidden_sections_never_block() + { + var escalated = Field("escalated", RmsFieldType.Boolean); + var escalatedTo = Field("escalated_to", RmsFieldType.ShortText); escalatedTo.Rules.Add(ShowWhen("escalated", "true")); escalatedTo.Rules.Add(RequireWhen("escalated", "true")); + var hiddenSection = Section("police", "Police", Field("police_reference", RmsFieldType.ShortText, true)); hiddenSection.Rules.Add(ShowWhen("escalated", "true")); + var schema = Schema(Section("main", "Main", Field("summary", RmsFieldType.ShortText, true), escalated, escalatedTo), hiddenSection); + var version = DetachedVersion("rules", schema); + + var draft = await _h.TypedValues.ValidateAsync(Dept, version, new List(), false); + draft.IsValid.Should().BeTrue("autosave never enforces requiredness"); + + var finalizeEmpty = await _h.TypedValues.ValidateAsync(Dept, version, new List(), true); + finalizeEmpty.IsValid.Should().BeFalse(); + finalizeEmpty.Issues.Select(i => i.FieldKey).Should().Contain("summary").And.NotContain("escalated_to").And.NotContain("police_reference", "hidden fields and sections are never required"); + + var escalatedNoTarget = await _h.TypedValues.ValidateAsync(Dept, version, new List { Value("main", "summary", "x"), Value("main", "escalated", "true") }, true); + escalatedNoTarget.IsValid.Should().BeFalse(); + escalatedNoTarget.Issues.Select(i => i.FieldKey).Should().Contain("escalated_to").And.Contain("police_reference"); + + var complete = await _h.TypedValues.ValidateAsync(Dept, version, new List { Value("main", "summary", "x"), Value("main", "escalated", "true"), Value("main", "escalated_to", "Duty officer"), Value("police", "police_reference", "P-1") }, true); + complete.IsValid.Should().BeTrue(); + + var evaluation = _h.TypedValues.EvaluateRules(schema, RecordTypedValuesService.Shape(schema, null, new[] { new RmsRecordValue { FieldKey = "escalated", BoolValue = false, ValueType = (int)RmsFieldType.Boolean } }, true)); + evaluation.HiddenFieldKeys.Should().Contain("escalated_to").And.Contain("police_reference"); + evaluation.HiddenSectionKeys.Should().Contain("police"); + evaluation.RequiredFieldKeys.Should().Contain("summary").And.NotContain("escalated_to"); + } + + [Test] + public async Task Repeating_sections_keep_dense_ordinals_and_enforce_row_limits() + { + var schema = Schema(Rows("stops", "Stops", 1, 2, Field("stop", RmsFieldType.ShortText, true), Field("minutes", RmsFieldType.Integer))); + var version = DetachedVersion("rows", schema); + var inputs = new List + { + Value("stops", "stop", "Second", "b", 5), Value("stops", "minutes", "20", "b", 5), + Value("stops", "stop", "First", "a", 1), Value("stops", "minutes", "10", "a", 1) + }; + var set = await _h.TypedValues.SaveDraftValuesAsync(Dept, Author, "rec-1", version, inputs); + var rows = set.Section("stops").Rows; + rows.Select(r => r.RowKey).Should().Equal("a", "b"); + rows.Select(r => r.Ordinal).Should().Equal(0, 1); + rows[0].Cell("minutes").Number.Should().Be(10); + _h.Defs.Groups.Should().HaveCount(2); + _h.Defs.Values.Should().OnlyContain(v => v.RmsRecordValueGroupId != null); + + inputs.Add(Value("stops", "stop", "Third", "c", 9)); + var tooMany = await _h.TypedValues.ValidateAsync(Dept, version, inputs, false); + tooMany.Issues.Should().Contain(i => i.Code == "too_many_rows"); + var tooFew = await _h.TypedValues.ValidateAsync(Dept, version, new List(), true); + tooFew.Issues.Should().Contain(i => i.SectionKey == "stops" && i.Code == "too_few_rows"); + var missingCell = await _h.TypedValues.ValidateAsync(Dept, version, new List { Value("stops", "minutes", "3", "a", 0) }, true); + missingCell.Issues.Should().Contain(i => i.FieldKey == "stop" && i.RowKey == "a"); + } + + [Test] + public async Task Restricted_values_are_withheld_from_readers_and_kept_out_of_search_workflow_and_snapshots() + { + var schema = Schema(Section("main", "Main", + Field("site", RmsFieldType.ShortText, configure: f => { f.Searchable = true; f.WorkflowExposed = true; }), Field("notes", RmsFieldType.LongText, configure: f => f.Searchable = true), + Field("secret", RmsFieldType.ShortText, classification: RmsFieldClassification.Restricted), Field("count", RmsFieldType.Integer, configure: f => f.WorkflowExposed = true), Select("status", "Open", "Closed")), + Rows("crew", "Crew", null, null, Field("name", RmsFieldType.ShortText, configure: f => f.WorkflowExposed = true), Field("phone", RmsFieldType.ShortText, classification: RmsFieldClassification.Restricted))); + var version = DetachedVersion("r", schema); + await _h.TypedValues.SaveDraftValuesAsync(Dept, Author, "rec-1", version, new List + { + Value("main", "site", "Depot 4"), Value("main", "notes", "very long narrative"), Value("main", "secret", "SSN"), Value("main", "count", "3"), Value("main", "status", "open"), + Value("crew", "name", "Pat", "r1", 0), Value("crew", "phone", "555", "r1", 0) + }); + + var reader = await _h.TypedValues.HydrateAsync(Dept, "rec-1", null, version, false); + reader.Scalar("secret").Withheld.Should().BeTrue(); reader.Scalar("secret").Display.Should().Be(RecordTypedValuesService.Redacted); reader.Scalar("secret").Value.Should().BeNull(); + reader.Section("crew").Rows[0].Cell("phone").Withheld.Should().BeTrue(); + reader.WithheldFieldKeys.Should().BeEquivalentTo(new[] { "secret", "phone" }); + var full = await _h.TypedValues.HydrateAsync(Dept, "rec-1", null, version, true); + full.Scalar("secret").Display.Should().Be("SSN"); + + _h.TypedValues.ToSearchText(schema, full).Should().Be("Depot 4", "long text and restricted values never enter the projection"); + var workflow = _h.TypedValues.ToWorkflowBlock(schema, full); + workflow.Keys.Should().BeEquivalentTo(new[] { "site", "count", "status", "crew", "crew_count" }); + workflow["count"].Should().Be(3L); + ((List>)workflow["crew"])[0].Keys.Should().BeEquivalentTo(new[] { "name" }); + var snapshot = _h.TypedValues.ToSnapshot(schema, full); + var main = (Dictionary)snapshot["Main"]; + main.Keys.Should().Contain("Secret" + RecordSnapshotSerializer.RestrictedValueSuffix).And.Contain("Site"); + main["Secret" + RecordSnapshotSerializer.RestrictedValueSuffix].Should().Be("SSN"); + ((List>)snapshot["Crew"])[0].Keys.Should().Contain("Phone" + RecordSnapshotSerializer.RestrictedValueSuffix); + _h.TypedValues.ToDisplaySummary(schema, full).Should().Be("Depot 4"); + } + + [Test] + public async Task Draft_values_copy_to_a_revision_and_restore_from_it() + { + var schema = Schema(Section("main", "Main", Field("site", RmsFieldType.ShortText)), Rows("crew", "Crew", null, null, Field("name", RmsFieldType.ShortText))); + var version = DetachedVersion("copy", schema); + await _h.TypedValues.SaveDraftValuesAsync(Dept, Author, "rec-1", version, new List { Value("main", "site", "A"), Value("crew", "name", "Pat", "r1", 0) }); + await _h.TypedValues.CopyDraftToRevisionAsync(Dept, "rec-1", "rev-1"); + _h.Defs.Values.Count(v => v.RevisionId == "rev-1").Should().Be(2); + _h.Defs.Groups.Count(g => g.RevisionId == "rev-1").Should().Be(1); + _h.Defs.Values.Single(v => v.RevisionId == "rev-1" && v.FieldKey == "name").RmsRecordValueGroupId.Should().Be(_h.Defs.Groups.Single(g => g.RevisionId == "rev-1").RmsRecordValueGroupId, "revision rows point at the revision's group copy"); + + await _h.TypedValues.SaveDraftValuesAsync(Dept, Author, "rec-1", version, new List { Value("main", "site", "B") }); + (await _h.TypedValues.HydrateAsync(Dept, "rec-1", null, version, true)).Scalar("site").Value.Should().Be("B"); + (await _h.TypedValues.HydrateAsync(Dept, "rec-1", "rev-1", version, true)).Scalar("site").Value.Should().Be("A", "revision rows are immutable"); + + await _h.TypedValues.RestoreDraftFromRevisionAsync(Dept, Author, "rec-1", "rev-1", version); + var restored = await _h.TypedValues.HydrateAsync(Dept, "rec-1", null, version, true); + restored.Scalar("site").Value.Should().Be("A"); + restored.Section("crew").Rows.Should().ContainSingle(r => r.Cell("name").Value == "Pat"); + (await _h.TypedValues.DeleteDraftAsync(Dept, "rec-1")).Should().Be(2); + _h.Defs.Values.Should().OnlyContain(v => v.RevisionId == "rev-1"); + } + + [Test] + public void Units_canonicalize_with_offsets_and_currencies_and_subdivisions_validate() + { + RmsUnits.Canonicalize(10m, "ft").Should().Be((3.048m, "m")); + RmsUnits.Canonicalize(32m, "F").Should().Be((0m, "C")); + RmsUnits.Canonicalize(212m, "F").Should().Be((100m, "C")); + RmsUnits.Canonicalize(2m, "h").Should().Be((120m, "min")); + RmsUnits.Canonicalize(1m, "furlong").Should().BeNull(); + RmsUnits.FromCanonical(3.048m, "ft").Should().Be(10m); + RmsUnits.PreferredUnit("length", "customary").Should().Be("ft"); + RmsUnits.PreferredUnit("length", "metric").Should().Be("m"); + RmsCurrencies.IsSupported("cad").Should().BeTrue(); RmsCurrencies.IsSupported("XXX").Should().BeFalse(); + RmsCountrySubdivisions.IsValid("CA-BC").Should().BeTrue(); RmsCountrySubdivisions.IsValid("US").Should().BeTrue(); RmsCountrySubdivisions.IsValid("US-ZZ").Should().BeFalse(); + RmsCountrySubdivisions.Label("CA-BC").Should().Contain("British Columbia"); + RecordTypedValuesService.ParseDuration("90").Should().Be(5400, "bare numbers are minutes"); + RecordTypedValuesService.ParseDuration("PT2H").Should().Be(7200); + RecordTypedValuesService.ParseBool("no").Should().BeFalse(); + } + [Test] + public void Protected_rows_pack_and_unpack_their_typed_columns_byte_for_byte() + { + var row = new RmsRecordValue { NumberValue = 12.50m, UnitCode = "ft", CanonicalNumberValue = 3.81m, CanonicalUnitCode = "m", DateTimeValue = new DateTime(2026, 9, 6, 15, 0, 0, DateTimeKind.Utc), DateTimeOffsetMinutes = -300, DurationSeconds = 5400, ReferenceType = "geo", ReferenceSnapshotJson = "{\"coordinates\":\"45.5,-122.6\"}", BoolValue = true, OptionKey = "beta" }; + var packed = RmsRecordValuePack.Pack(row); + packed.Should().Contain("\"NumberValue\":\"12.50\"").And.Contain("\"DateTimeValue\":\"2026-09-06T15:00:00.0000000Z\"").And.NotContain("TextValue"); + RmsRecordValuePack.Pack(new RmsRecordValue()).Should().BeNull("an empty row packs to nothing"); + + var copy = new RmsRecordValue(); + RmsRecordValuePack.Unpack(copy, packed); + copy.NumberValue.Should().Be(12.50m); copy.UnitCode.Should().Be("ft"); copy.CanonicalNumberValue.Should().Be(3.81m); copy.DateTimeValue.Should().Be(row.DateTimeValue); + copy.DateTimeValue.Value.Kind.Should().Be(DateTimeKind.Utc); copy.DateTimeOffsetMinutes.Should().Be(-300); copy.DurationSeconds.Should().Be(5400); copy.BoolValue.Should().BeTrue(); copy.OptionKey.Should().Be("beta"); + copy.ReferenceSnapshotJson.Should().Be(row.ReferenceSnapshotJson); copy.TextValue.Should().BeNull(); + RmsRecordValuePack.Pack(copy).Should().Be(packed); + + // The engine works on raw column dictionaries and must agree with the entity path. + var columns = RmsRecordValuePack.UnpackColumns(packed); + columns["NumberValue"].Should().Be(12.50m); columns["DurationSeconds"].Should().Be(5400L); columns["TextValue"].Should().BeNull(); + RmsRecordValuePack.PackColumns(columns).Should().Be(packed); + + RmsRecordValuePack.Clear(copy); + copy.PopulatedColumnGroups().Should().Be(0); + var sealedRow = new RmsRecordValue { ProtectedEnvelope = "rgdp:1:1:abc", IsProtected = true }; + sealedRow.IsSealed.Should().BeTrue(); new RmsRecordValue { ProtectedEnvelope = "plain" }.IsSealed.Should().BeFalse(); + + // The seam accessor: packing to seal, unpacking to reveal, and the sentinel leaving a sealed row alone. + var accessor = RmsProtectedFields.Values[RmsProtectedFields.ValueFieldId]; + var protectedRow = new RmsRecordValue { TextValue = "SSN 123", ProtectionRequired = true }; + accessor.Get(protectedRow).Should().Contain("SSN 123"); + accessor.Get(new RmsRecordValue { TextValue = "public" }).Should().BeNull("only flagged rows are offered to the seam"); + accessor.Set(protectedRow, "rgdp:1:1:sealed"); + protectedRow.TextValue.Should().BeNull(); protectedRow.ProtectedEnvelope.Should().Be("rgdp:1:1:sealed"); protectedRow.IsSealed.Should().BeTrue(); + accessor.Set(protectedRow, Resgrid.Model.ProtectedDataEnvelope.RedactionValue); + protectedRow.IsSealed.Should().BeTrue("a refused reveal changes nothing"); + accessor.Set(protectedRow, "{\"TextValue\":\"SSN 123\"}"); + protectedRow.TextValue.Should().Be("SSN 123"); protectedRow.ProtectedEnvelope.Should().BeNull(); + } + + [Test] + public async Task Protected_fields_flag_their_rows_and_sealed_rows_survive_a_save_that_could_not_reveal_them() + { + var schema = Schema(Section("main", "Main", Field("site", RmsFieldType.ShortText), Field("ssn", RmsFieldType.ShortText, classification: RmsFieldClassification.Protected)), + Rows("crew", "Crew", null, null, Field("name", RmsFieldType.ShortText), Field("dob", RmsFieldType.Date, classification: RmsFieldClassification.Protected))); + var version = DetachedVersion("adp", schema); + await _h.TypedValues.SaveDraftValuesAsync(Dept, Author, "rec-1", version, new List + { + Value("main", "site", "Depot 4"), Value("main", "ssn", "123-45-6789"), Value("crew", "name", "Pat", "r1", 0), Value("crew", "dob", "1990-01-02", "r1", 0), Value("crew", "name", "Sam", "r2", 1) + }); + _h.Defs.Values.Single(v => v.FieldKey == "ssn").ProtectionRequired.Should().BeTrue(); + _h.Defs.Values.Single(v => v.FieldKey == "dob").ProtectionRequired.Should().BeTrue(); + _h.Defs.Values.Where(v => v.FieldKey == "site" || v.FieldKey == "name").Should().OnlyContain(v => !v.ProtectionRequired); + _h.Protection.Writes.Should().Contain("values:2", "the seam sees every flagged row"); + + // Seal the stored rows as the engine/seam would; the next save (a viewer without a reveal) posts nothing for them. + foreach (var row in _h.Defs.Values.Where(v => v.ProtectionRequired)) + { + row.ProtectedEnvelope = "rgdp:1:1:" + RmsRecordValuePack.Pack(row); row.IsProtected = true; row.ProtectedCatalogVersion = 11; RmsRecordValuePack.Clear(row); + } + var sealedSsnId = _h.Defs.Values.Single(v => v.FieldKey == "ssn").RmsRecordValueId; + var sealedDobId = _h.Defs.Values.Single(v => v.FieldKey == "dob").RmsRecordValueId; + + var saved = await _h.TypedValues.SaveDraftValuesAsync(Dept, "viewer", "rec-1", version, new List + { + Value("main", "site", "Depot 5"), Value("crew", "name", "Pat", "r1", 0), Value("crew", "name", "Sam", "r2", 1) + }); + _h.Defs.Values.Single(v => v.FieldKey == "ssn").RmsRecordValueId.Should().Be(sealedSsnId, "the sealed scalar keeps its identity so the envelope's row key still binds"); + _h.Defs.Values.Single(v => v.FieldKey == "dob").RmsRecordValueId.Should().Be(sealedDobId); + _h.Defs.Values.Single(v => v.FieldKey == "dob").RmsRecordValueGroupId.Should().Be(_h.Defs.Groups.Single(g => g.ClientRowKey == "r1").RmsRecordValueGroupId, "the sealed cell follows its row by client row key"); + saved.Scalar("ssn").Withheld.Should().BeTrue(); saved.Scalar("ssn").Display.Should().Be(RecordTypedValuesService.Redacted); + saved.Section("crew").Rows.Single(r => r.RowKey == "r1").Cell("dob").Withheld.Should().BeTrue(); + saved.Section("crew").Rows.Single(r => r.RowKey == "r2").Cell("dob").Display.Should().BeNull(); + saved.Scalar("site").Value.Should().Be("Depot 5"); + + // A revealed editor posting a new value replaces the sealed row; a removed repeating row drops its sealed cell. + await _h.TypedValues.SaveDraftValuesAsync(Dept, Author, "rec-1", version, new List { Value("main", "ssn", "987-65-4321"), Value("crew", "name", "Sam", "r2", 0) }); + _h.Defs.Values.Single(v => v.FieldKey == "ssn").RmsRecordValueId.Should().NotBe(sealedSsnId); + _h.Defs.Values.Single(v => v.FieldKey == "ssn").TextValue.Should().Be("987-65-4321"); + _h.Defs.Values.Should().NotContain(v => v.FieldKey == "dob", "row r1 was removed"); + _h.TypedValues.ToSearchText(schema, await _h.TypedValues.HydrateAsync(Dept, "rec-1", null, version, true)).Should().NotContain("987", "protected values never enter the projection"); + } + + [Test] + public async Task Rules_inside_a_repeating_section_evaluate_per_row_against_that_rows_cells() + { + var reason = Field("failure_reason", RmsFieldType.ShortText); reason.Rules.Add(ShowWhen("delivered", "false")); reason.Rules.Add(RequireWhen("delivered", "false")); + var photo = Field("photo", RmsFieldType.ShortText); photo.Rules.Add(RequireWhen("high_value", "true")); + var schema = Schema(Section("run", "Run", Field("high_value", RmsFieldType.Boolean)), + Rows("stops", "Stops", null, null, Field("stop", RmsFieldType.ShortText, true), Field("delivered", RmsFieldType.Boolean), reason, photo)); + var version = DetachedVersion("rows", schema); + var set = await _h.TypedValues.SaveDraftValuesAsync(Dept, Author, "rec-1", version, new List + { + Value("run", "high_value", "true"), + Value("stops", "stop", "Gate", "a", 0), Value("stops", "delivered", "true", "a", 0), + Value("stops", "stop", "Yard", "b", 1), Value("stops", "delivered", "false", "b", 1) + }); + + var evaluation = _h.TypedValues.EvaluateRules(schema, set); + evaluation.IsHidden("stops", "a", "failure_reason").Should().BeTrue("row a was delivered"); + evaluation.IsHidden("stops", "b", "failure_reason").Should().BeFalse("row b was not"); + evaluation.IsRequired("stops", "b", "failure_reason").Should().BeTrue(); + evaluation.IsRequired("stops", "a", "failure_reason").Should().BeFalse("a hidden field is never required"); + evaluation.IsRequired("stops", "a", "photo").Should().BeTrue("a per-row rule may still look at a scalar"); + evaluation.IsRequired("stops", "b", "photo").Should().BeTrue(); + evaluation.HiddenFieldKeys.Should().NotContain("failure_reason", "per-row outcomes never collapse onto the field"); + + var finalize = await _h.TypedValues.ValidateAsync(Dept, version, set.ToInputs(), true); + finalize.Issues.Should().Contain(i => i.FieldKey == "failure_reason" && i.RowKey == "b"); + finalize.Issues.Should().NotContain(i => i.FieldKey == "failure_reason" && i.RowKey == "a"); + finalize.Issues.Count(i => i.FieldKey == "photo").Should().Be(2); + + var inputs = set.ToInputs(); + inputs.Add(Value("stops", "failure_reason", "Closed", "b", 1)); inputs.Add(Value("stops", "photo", "p1", "a", 0)); inputs.Add(Value("stops", "photo", "p2", "b", 1)); + (await _h.TypedValues.ValidateAsync(Dept, version, inputs, true)).IsValid.Should().BeTrue(); + } + + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordWorkAssignmentsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordWorkAssignmentsServiceTests.cs new file mode 100644 index 00000000..d92685e5 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordWorkAssignmentsServiceTests.cs @@ -0,0 +1,181 @@ +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; + +namespace Resgrid.Tests.Rms +{ + /// + /// Work assignments (RMS plan section 5.2, RMS-1D): who may assign, who may acknowledge or complete, the + /// ETag guard, and the rule that an assignment narrows a work queue but never grants access to a Record. + /// + [TestFixture] + public class RecordWorkAssignmentsServiceTests + { + private const int Dept = 9; + private const string Officer = "officer"; + private const string Member = "member"; + + private List _rows; + private Mock _assignments; + private Mock _records; + private Mock _authorization; + private Mock _audits; + private List _audited; + private Mock _units; + private Mock _groups; + private Mock _command; + private RmsOperationalRecord _record; + private RecordWorkAssignmentsService _service; + + [SetUp] + public void SetUp() + { + _rows = new List(); + _audited = new List(); + _record = new RmsOperationalRecord { RmsOperationalRecordId = "r1", DepartmentId = Dept, State = (int)RmsRecordState.Draft, OwnerUserId = Officer, DefinitionKey = "shift-log" }; + + _assignments = new Mock(); + _assignments.Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordWorkAssignment row, CancellationToken c, bool f) => { _rows.Add(row); return row; }); + _assignments.Setup(a => a.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordWorkAssignment row, CancellationToken c, bool f) => row); + _assignments.Setup(a => a.GetByIdForDepartmentAsync(Dept, It.IsAny())) + .ReturnsAsync((int d, string id) => _rows.FirstOrDefault(r => r.RmsRecordWorkAssignmentId == id)); + _assignments.Setup(a => a.GetForRecordAsync(Dept, It.IsAny())) + .ReturnsAsync((int d, string recordId) => _rows.Where(r => r.RecordId == recordId).ToList()); + _assignments.Setup(a => a.GetOpenForAssigneesAsync(Dept, It.IsAny(), It.IsAny>(), It.IsAny>(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(() => _rows.Where(r => r.IsOpen).ToList()); + + _records = new Mock(); + _records.Setup(r => r.GetByIdForDepartmentAsync(Dept, "r1")).ReturnsAsync(() => _record); + + _authorization = new Mock(); + _authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.HasPermissionAsync(Officer, Dept, PermissionTypes.ReviewRecords)).ReturnsAsync(true); + _authorization.Setup(a => a.HasPermissionAsync(Member, Dept, It.IsAny())).ReturnsAsync(false); + _authorization.Setup(a => a.IsDepartmentAdminAsync(It.IsAny(), Dept)).ReturnsAsync(false); + _authorization.Setup(a => a.CanCreateSourceCallAsync(It.IsAny(), Dept)).ReturnsAsync(false); + + _audits = new Mock(); + _audits.Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsAccessAudit audit, CancellationToken c, bool f) => { _audited.Add(audit); return audit; }); + + _units = new Mock(); + _groups = new Mock(); + _command = new Mock(); + + _service = new RecordWorkAssignmentsService(_assignments.Object, _records.Object, _authorization.Object, _audits.Object, _units.Object, _groups.Object, _command.Object); + } + + private static RecordWorkAssignmentInput Input(string assignee = Member) => new RecordWorkAssignmentInput + { + RecordId = "r1", AssigneeKind = RmsWorkAssigneeKind.Person, AssigneeUserId = assignee, Purpose = RmsWorkAssignmentPurposes.Complete, + Note = "Finish the crew section", OriginClient = RmsOriginClient.Responder, SourceContext = new FieldRecordContext { CallId = 501 } + }; + + [Test] + public async Task Assigning_needs_review_permission_ownership_or_administration_and_is_audited_with_the_origin() + { + var assignment = await _service.AssignAsync(Dept, Officer, Input()); + + assignment.State.Should().Be((int)RmsWorkAssignmentState.Open); + assignment.Purpose.Should().Be(RmsWorkAssignmentPurposes.Complete); + assignment.OriginClient.Should().Be((int)RmsOriginClient.Responder); + assignment.SourceContextJson.Should().Contain("501"); + _audited.Should().ContainSingle(); + _audited[0].Action.Should().Be((int)RmsAccessAuditAction.Admin); + _audited[0].DetailJson.Should().Contain("Responder"); + + Func stranger = () => _service.AssignAsync(Dept, "stranger", Input()); + await stranger.Should().ThrowAsync(); + + Func unknownPurpose = () => _service.AssignAsync(Dept, Officer, new RecordWorkAssignmentInput { RecordId = "r1", AssigneeUserId = Member, Purpose = "shred-it" }); + await unknownPurpose.Should().ThrowAsync(); + + _authorization.Setup(a => a.IsActiveMemberAsync("former", Dept)).ReturnsAsync(false); + Func former = () => _service.AssignAsync(Dept, Officer, Input("former")); + await former.Should().ThrowAsync(); + + _record.State = (int)RmsRecordState.Voided; + Func voided = () => _service.AssignAsync(Dept, Officer, Input()); + await voided.Should().ThrowAsync(); + } + + [Test] + public async Task Only_an_addressee_may_acknowledge_and_the_row_version_guards_the_transition() + { + var assignment = await _service.AssignAsync(Dept, Officer, Input()); + + Func notAddressee = () => _service.AcknowledgeAsync(Dept, "someone-else", assignment.RmsRecordWorkAssignmentId, null, null, RmsOriginClient.Responder); + await notAddressee.Should().ThrowAsync(); + + Func stale = () => _service.AcknowledgeAsync(Dept, Member, assignment.RmsRecordWorkAssignmentId, 99, null, RmsOriginClient.Responder); + await stale.Should().ThrowAsync(); + + var acknowledged = await _service.AcknowledgeAsync(Dept, Member, assignment.RmsRecordWorkAssignmentId, assignment.RowVersion, null, RmsOriginClient.Responder); + acknowledged.State.Should().Be((int)RmsWorkAssignmentState.Acknowledged); + acknowledged.AcknowledgedByUserId.Should().Be(Member); + acknowledged.RowVersion.Should().Be(2); + + Func twice = () => _service.AcknowledgeAsync(Dept, Member, assignment.RmsRecordWorkAssignmentId, acknowledged.RowVersion, null, RmsOriginClient.Responder); + await twice.Should().ThrowAsync(); + + var completed = await _service.CompleteAsync(Dept, Member, assignment.RmsRecordWorkAssignmentId, acknowledged.RowVersion, null, RmsOriginClient.Responder); + completed.State.Should().Be((int)RmsWorkAssignmentState.Completed); + completed.IsOpen.Should().BeFalse(); + + Func closed = () => _service.CancelAsync(Dept, Officer, assignment.RmsRecordWorkAssignmentId, null, "changed my mind", RmsOriginClient.Web); + await closed.Should().ThrowAsync(); + } + + [Test] + public async Task Unit_and_command_assignees_are_resolved_from_live_staffing_and_the_active_command() + { + _units.Setup(u => u.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = Dept, Name = "Engine 7" }); + _units.Setup(u => u.GetLastUnitStateByUnitIdAsync(7)).ReturnsAsync(new UnitState { UnitStateId = 1, UnitId = 7, Roles = new List { new UnitStateRole { UserId = "driver" } } }); + var unitAssignment = await _service.AssignAsync(Dept, Officer, new RecordWorkAssignmentInput { RecordId = "r1", AssigneeKind = RmsWorkAssigneeKind.Unit, AssigneeUnitId = 7 }); + + (await _service.IsAssigneeAsync(Dept, "driver", unitAssignment, null)).Should().BeTrue(); + (await _service.IsAssigneeAsync(Dept, "passenger", unitAssignment, null)).Should().BeFalse(); + + _record.CallId = 501; + _command.Setup(c => c.GetActiveCommandForCallAsync(Dept, 501)).ReturnsAsync(new IncidentCommand { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = 501, CurrentCommanderUserId = "ic" }); + _command.Setup(c => c.GetCommandBoardAsync(Dept, 501)).ReturnsAsync(new IncidentCommandBoard { Nodes = new List { new CommandStructureNode { Name = "Operations", SupervisorUserId = "ops" } } }); + var roleAssignment = await _service.AssignAsync(Dept, Officer, new RecordWorkAssignmentInput { RecordId = "r1", AssigneeKind = RmsWorkAssigneeKind.CommandRole, AssigneeRole = "Operations" }); + + (await _service.IsAssigneeAsync(Dept, "ops", roleAssignment, null)).Should().BeTrue(); + (await _service.IsAssigneeAsync(Dept, "ic", roleAssignment, null)).Should().BeTrue("the incident commander holds every command role"); + (await _service.IsAssigneeAsync(Dept, "bystander", roleAssignment, null)).Should().BeFalse(); + + _units.Setup(u => u.GetUnitByIdAsync(99)).ReturnsAsync(new Unit { UnitId = 99, DepartmentId = Dept + 1 }); + Func foreign = () => _service.AssignAsync(Dept, Officer, new RecordWorkAssignmentInput { RecordId = "r1", AssigneeKind = RmsWorkAssigneeKind.Unit, AssigneeUnitId = 99 }); + await foreign.Should().ThrowAsync(); + } + + [Test] + public async Task The_queue_narrows_but_never_grants_a_record_the_caller_may_no_longer_read() + { + await _service.AssignAsync(Dept, Officer, Input()); + _records.Setup(r => r.GetByIdForDepartmentAsync(Dept, "r2")).ReturnsAsync(new RmsOperationalRecord { RmsOperationalRecordId = "r2", DepartmentId = Dept, State = (int)RmsRecordState.Draft, OwnerUserId = Officer }); + await _service.AssignAsync(Dept, Officer, new RecordWorkAssignmentInput { RecordId = "r2", AssigneeUserId = Member }); + + _authorization.Setup(a => a.CanUserViewRecordAsync(Member, "r2", Dept)).ReturnsAsync(false); + var queue = await _service.GetQueueAsync(Dept, Member, new FieldRecordContext(), 50); + + queue.Select(q => q.RecordId).Should().Equal(new[] { "r1" }, "an assignment narrows a queue; visibility still decides what the caller sees"); + + var forRecord = await _service.GetForRecordAsync(Dept, Member, "r2"); + forRecord.Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs new file mode 100644 index 00000000..9ac4b3e2 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsBulkPacketServiceTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// Bulk packets and bulk assign-for-review (RMS plan section 4.7): authorized-selection semantics with per-record + /// skips, one stored ADP-sealed export run per packet, per-record Export audits, optional email delivery, and the + /// review assignment that touches only Records awaiting review. + /// + [TestFixture] + public class RecordsBulkPacketServiceTests + { + private const int Dept = 9; + private const string Exporter = "exporter"; + private Mock _documents; + private Mock _records; + private Mock _rows; + private Mock _runs; + private Mock _authorization; + private Mock _audits; + private Mock _email; + private Mock _pdf; + private Mock _unitOfWork; + private PassthroughRecordsProtection _protection; + private List _storedRuns; + private List _storedAudits; + private RecordsBulkPacketService _service; + + [SetUp] + public void SetUp() + { + _documents = new Mock(); + _records = new Mock(); + _rows = new Mock(); + _runs = new Mock(); + _authorization = new Mock(); + _audits = new Mock(); + _email = new Mock(); + _pdf = new Mock(); + _unitOfWork = new Mock(); + _protection = new PassthroughRecordsProtection(); + _storedRuns = new List(); + _storedAudits = new List(); + + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, Name = "Pine Valley Fire" }); + _authorization.Setup(a => a.HasPermissionAsync(Exporter, Dept, PermissionTypes.ExportRecords)).ReturnsAsync(true); + _authorization.Setup(a => a.HasPermissionAsync(Exporter, Dept, PermissionTypes.ReviewRecords)).ReturnsAsync(true); + _authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.CanUserViewRecordAsync(Exporter, It.IsAny(), Dept)).ReturnsAsync((string u, string id, int d) => id != "hidden"); + _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny(), It.IsAny())).Returns((string html, string size) => System.Text.Encoding.UTF8.GetBytes("%PDF-" + html)); + _runs.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((RmsExportRun run, CancellationToken c, bool b) => { _storedRuns.Add(run); return run; }); + _runs.Setup(r => r.GetWithDataAsync(Dept, It.IsAny())).ReturnsAsync((int d, string id) => _storedRuns.FirstOrDefault(r => r.RmsExportRunId == id)); + _audits.Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((RmsAccessAudit audit, CancellationToken c, bool b) => { _storedAudits.Add(audit); return audit; }); + _email.Setup(e => e.SendReportDeliveryEmail(It.IsAny())).ReturnsAsync(true); + + var rows = new[] { Row("r1", "RG-1"), Row("r2", "RG-2"), Row("hidden", "RG-3"), Row("draft", null, revision: null) }; + _rows.Setup(r => r.GetByIdsAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => rows.Where(r => ids.Contains(r.RmsOperationalRecordId)).ToList()); + _documents.Setup(d => d.GetAsync(Dept, Exporter, It.IsAny(), RmsRecordKind.Operational, It.IsAny(), true)) + .ReturnsAsync((int d, string u, string id, RmsRecordKind k, string rev, bool e) => new RecordDocument { RecordId = id, RecordNumber = rows.First(r => r.RmsOperationalRecordId == id).RecordNumber, RevisionId = rev, RevisionNumber = 1, FinalizedOn = new DateTime(2026, 9, 1), OriginalChecksum = "chk-" + id, ContentChecksum = "chk-" + id, ContentJson = "{}" }); + _documents.Setup(d => d.RenderHtmlAsync(Dept, Exporter, It.IsAny())).ReturnsAsync((int d, string u, RecordDocument doc) => "

Record " + doc.RecordNumber + "

"); + + _service = new RecordsBulkPacketService(_documents.Object, _records.Object, _rows.Object, _runs.Object, _authorization.Object, _protection, _audits.Object, departments.Object, _email.Object, _pdf.Object, _unitOfWork.Object); + } + + private static RmsOperationalRecord Row(string id, string number, string revision = "rev") + => new RmsOperationalRecord { RmsOperationalRecordId = id, DepartmentId = Dept, RecordNumber = number, DefinitionKey = "shift-log", DefinitionVersion = 1, CurrentRevisionId = revision == null ? null : revision + "-" + id, State = (int)RmsRecordState.Finalized }; + + [Test] + public async Task Compiled_pdf_packet_skips_unauthorized_or_unfinalized_records_stores_one_sealed_run_and_audits_each_record() + { + var result = await _service.BuildPacketAsync(Dept, Exporter, new RecordsBulkPacketRequest { RecordIds = new List { "r1", "r2", "hidden", "draft", "missing", "r1" }, Title = "Board packet", Purpose = "Quarterly board review" }); + + result.Processed.Should().Be(2); + result.Skips.Select(s => s.RecordId + ":" + s.Reason).Should().BeEquivalentTo("hidden:not_visible", "draft:no_revision", "missing:not_found"); + result.Run.TemplateKey.Should().Be(RecordsBulkPacketService.PacketTemplateKey); + result.Run.Trigger.Should().Be((int)RmsExportTrigger.Bulk); + result.Run.ContentType.Should().Be("application/pdf"); + result.Run.FileName.Should().StartWith("Board-packet-").And.EndWith(".pdf"); + result.Run.RecordCount.Should().Be(2); + result.Run.ExpiresOn.Should().BeCloseTo(DateTime.UtcNow.AddDays(RecordsBulkPacketService.RunRetentionDays), TimeSpan.FromMinutes(1)); + System.Text.Encoding.UTF8.GetString(result.Run.Data).Should().Contain("Manifest").And.Contain("RG-1").And.Contain("RG-2").And.Contain("Record RG-2").And.Contain("Packet item 2 of 2"); + result.Delivered.Should().BeFalse(); + + _storedRuns.Should().ContainSingle(); + _protection.Writes.Should().Contain("export-run", "the stored bytes go through the ADP seam"); + _storedAudits.Should().HaveCount(2); + _storedAudits.Select(a => a.RecordId).Should().BeEquivalentTo("r1", "r2"); + _storedAudits.Should().OnlyContain(a => a.Action == (int)RmsAccessAuditAction.Export && a.Purpose == "Quarterly board review"); + _unitOfWork.Verify(u => u.CommitChanges(), Times.Once); + _email.Verify(e => e.SendReportDeliveryEmail(It.IsAny()), Times.Never); + } + + [Test] + public async Task Bundle_packet_zips_one_pdf_per_record_with_a_manifest_and_optionally_rides_the_report_email_path() + { + var result = await _service.BuildPacketAsync(Dept, Exporter, new RecordsBulkPacketRequest { RecordIds = new List { "r2", "r1" }, Mode = RecordsBulkPacketMode.Bundle, Title = "Insurance", DeliverToEmail = "claims@example.org" }); + + result.Run.ContentType.Should().Be("application/zip"); + using var archive = new ZipArchive(new MemoryStream(result.Run.Data), ZipArchiveMode.Read); + archive.Entries.Select(e => e.Name).Should().BeEquivalentTo("manifest.json", "001-RG-2.pdf", "002-RG-1.pdf"); + using (var reader = new StreamReader(archive.GetEntry("manifest.json").Open())) + reader.ReadToEnd().Should().Contain("RG-2").And.Contain("chk-r1"); + result.Delivered.Should().BeTrue(); + _email.Verify(e => e.SendReportDeliveryEmail(It.Is(n => n.To == "claims@example.org" && n.AttachmentName == result.Run.FileName && n.AttachmentData.Length == result.Run.Data.Length)), Times.Once); + + var download = await _service.GetPacketAsync(Dept, Exporter, result.Run.RmsExportRunId); + download.Should().NotBeNull(); + download.FileName.Should().Be(result.Run.FileName); + (await _service.GetPacketAsync(Dept, Exporter, "nope")).Should().BeNull(); + } + + [Test] + public async Task Packets_fail_closed_on_permission_size_email_and_empty_selections() + { + _authorization.Setup(a => a.HasPermissionAsync("viewer", Dept, PermissionTypes.ExportRecords)).ReturnsAsync(false); + Func denied = () => _service.BuildPacketAsync(Dept, "viewer", new RecordsBulkPacketRequest { RecordIds = new List { "r1" } }); + await denied.Should().ThrowAsync(); + + Func tooMany = () => _service.BuildPacketAsync(Dept, Exporter, new RecordsBulkPacketRequest { RecordIds = Enumerable.Range(0, RecordsBulkPacketRequest.MaxRecords + 1).Select(i => "id" + i).ToList() }); + await tooMany.Should().ThrowAsync(); + + Func badEmail = () => _service.BuildPacketAsync(Dept, Exporter, new RecordsBulkPacketRequest { RecordIds = new List { "r1" }, DeliverToEmail = "not an address" }); + await badEmail.Should().ThrowAsync(); + + Func nothing = () => _service.BuildPacketAsync(Dept, Exporter, new RecordsBulkPacketRequest { RecordIds = new List { "hidden", "missing" } }); + (await nothing.Should().ThrowAsync()).Which.Message.Should().Contain("hidden (not_visible)"); + _storedRuns.Should().BeEmpty(); + _unitOfWork.Verify(u => u.CommitChanges(), Times.Never); + } + + [Test] + public async Task Assign_for_review_touches_only_records_awaiting_review_and_reports_the_rest_as_skips() + { + _records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "r1", "reviewer", "rotation", It.IsAny())).ReturnsAsync(new RecordAggregate()); + _records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "draft", "reviewer", "rotation", It.IsAny())).ThrowsAsync(new RecordTransitionException("draft", RmsRecordState.Draft, RmsRecordState.Draft, "only a Record awaiting review can be assigned a reviewer")); + _records.Setup(r => r.AssignReviewerAsync(Dept, Exporter, "hidden", "reviewer", "rotation", It.IsAny())).ThrowsAsync(new UnauthorizedAccessException()); + + var result = await _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List { "r1", "draft", "hidden" }, ReviewerUserId = "reviewer", Reason = "rotation" }); + + result.Processed.Should().Be(1); + result.Skips.Select(s => s.RecordId + ":" + s.Reason).Should().BeEquivalentTo("draft:not_awaiting_review", "hidden:not_visible"); + result.Run.Should().BeNull(); + + _authorization.Setup(a => a.IsActiveMemberAsync("gone", Dept)).ReturnsAsync(false); + Func inactive = () => _service.AssignForReviewAsync(Dept, Exporter, new RecordsBulkAssignRequest { RecordIds = new List { "r1" }, ReviewerUserId = "gone" }); + await inactive.Should().ThrowAsync(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsDefinitionLayoutTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDefinitionLayoutTests.cs new file mode 100644 index 00000000..c4f6e6cd --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsDefinitionLayoutTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Services.Records; +using Resgrid.Web.Areas.User.Models.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// Definition-scope print layouts (RMS plan section 4.10.1): normalized versioned saves, resolution against the + /// department default and the definition version, layout-aware rendering, and the designer view model round trip. + /// + [TestFixture] + public class RecordsDefinitionLayoutTests + { + private const int Dept = 6; + private Mock _layouts; + private Dictionary _stored; + private RecordsPrintLayoutService _service; + + [SetUp] + public void SetUp() + { + _stored = new Dictionary(); + _layouts = new Mock(); + _layouts.Setup(l => l.GetAsync(Dept, It.IsAny(), It.IsAny())).ReturnsAsync((int d, int scope, string key) => _stored.TryGetValue(scope + "|" + key, out var row) ? row : null); + _layouts.Setup(l => l.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordPrintLayout row, CancellationToken c, bool b) => { _stored[row.Scope + "|" + row.DefinitionKey] = row; return row; }); + _service = new RecordsPrintLayoutService(_layouts.Object); + } + + private static RecordDefinitionSchema Schema() => RmsDefinitionHarness.Schema( + RmsDefinitionHarness.Section("summary", "Summary", RmsDefinitionHarness.Field("title", RmsFieldType.ShortText), RmsDefinitionHarness.Field("internal_note", RmsFieldType.LongText)), + RmsDefinitionHarness.Rows("crew", "Crew", null, null, RmsDefinitionHarness.Field("member", RmsFieldType.ShortText), RmsDefinitionHarness.Field("hours", RmsFieldType.Integer)), + RmsDefinitionHarness.Section("signoff", "Sign-off", RmsDefinitionHarness.Field("signature", RmsFieldType.Signature))); + + [Test] + public async Task Definition_layout_saves_are_versioned_normalized_and_resolve_only_for_the_version_they_apply_to() + { + (await _service.ResolveForDefinitionAsync(Dept, "shift-log", 1)).Definition.Should().BeNull("nothing saved yet resolves to the department default alone"); + + var saved = await _service.SaveDefinitionLayoutAsync(Dept, "admin", "Shift-Log", new RecordsDefinitionLayoutConfig + { + SectionOrder = new List { " Signoff ", "summary", "summary" }, HiddenFieldKeys = new List { "Internal_Note" }, + SignatureBlockPlacement = "bogus", AttachmentListStyle = "LIST", AppliesToVersion = 2, + BrandingOverrides = new RecordsPrintLayoutConfig { PageSize = "a4" } + }); + + saved.Version.Should().Be(1); + saved.LayoutVersion.Should().Be("shift-log/1"); + saved.DefinitionConfig.SectionOrder.Should().Equal("signoff", "summary"); + saved.DefinitionConfig.HiddenFieldKeys.Should().Equal("internal_note"); + saved.DefinitionConfig.SignatureBlockPlacement.Should().Be(RecordsDefinitionLayoutConfig.SignatureAtEnd); + saved.DefinitionConfig.AttachmentListStyle.Should().Be(RecordsDefinitionLayoutConfig.AttachmentsList); + + var other = await _service.ResolveForDefinitionAsync(Dept, "shift-log", 1); + other.Definition.Should().BeNull("the layout is pinned to version 2"); + other.LayoutVersion.Should().Be(RmsRecordPrintLayout.GeneratedLayoutVersion); + + var resolved = await _service.ResolveForDefinitionAsync(Dept, "shift-log", 2); + resolved.Definition.Should().NotBeNull(); + resolved.DefinitionLayoutVersion.Should().Be("shift-log/1"); + resolved.Branding.PageSize.Should().Be("A4", "branding overrides replace the department block"); + resolved.LayoutVersion.Should().Be("shift-log/1+shift-log/1/branding"); + + var second = await _service.SaveDefinitionLayoutAsync(Dept, "admin", "shift-log", new RecordsDefinitionLayoutConfig()); + second.Version.Should().Be(2); + second.RmsRecordPrintLayoutId.Should().Be(saved.RmsRecordPrintLayoutId); + (await _service.ResolveForDefinitionAsync(Dept, "shift-log", 7)).LayoutVersion.Should().Be("shift-log/2+" + RmsRecordPrintLayout.GeneratedLayoutVersion, "an unpinned layout applies to every version and keeps the department branding"); + (await _service.ResolveForDefinitionAsync(Dept, RmsDefinitionKeys.NerisIncidentReport, 1)).Definition.Should().BeNull("locked definitions never take a definition layout"); + } + + [Test] + public void Layout_rendering_orders_sections_hides_fields_renames_headings_breaks_pages_and_moves_signatures() + { + var values = JObject.Parse("{\"Summary\":{\"title\":\"Night shift\",\"internal_note\":\"do not print\"},\"Crew\":[{\"member\":\"A\",\"hours\":\"4\"},{\"member\":\"B\",\"hours\":\"2\"}],\"Sign-off\":{\"signature\":\"signed:abc\"}}"); + var layout = new RecordsDefinitionLayoutConfig + { + SectionOrder = new List { "crew", "summary" }, HiddenFieldKeys = new List { "internal_note" }, + SectionHeadings = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["crew"] = "Crew hours" }, PageBreakBeforeSectionKeys = new List { "summary" } + }; + var html = new StringBuilder(); + + RecordsDocumentService.RenderValuesWithLayout(html, values, Schema(), layout); + var text = html.ToString(); + + text.IndexOf("Crew hours", StringComparison.Ordinal).Should().BePositive().And.BeLessThan(text.IndexOf("Night shift", StringComparison.Ordinal), "crew renders before summary"); + text.Should().NotContain("do not print").And.Contain("

Summary

"); + text.Should().Contain("

Signatures

").And.Contain("Sign-off / signature"); + text.IndexOf("Signatures", StringComparison.Ordinal).Should().BeGreaterThan(text.IndexOf("Night shift", StringComparison.Ordinal), "signature blocks print at the end by default"); + text.Should().Contain("member").And.Contain("2", "repeating rows render as a numbered table"); + + var inline = new StringBuilder(); + RecordsDocumentService.RenderValuesWithLayout(inline, values, Schema(), new RecordsDefinitionLayoutConfig { SignatureBlockPlacement = RecordsDefinitionLayoutConfig.SignatureInline, HiddenSectionKeys = new List { "crew" } }); + inline.ToString().Should().NotContain("Signatures").And.Contain("signature").And.NotContain("Crew"); + } + + [Test] + public void Designer_view_model_round_trips_the_configuration() + { + var aggregate = new RecordDefinitionAggregate + { + Definition = new RmsRecordDefinition { DefinitionKey = "shift-log", Name = "Shift log", Owner = (int)RmsDefinitionOwner.Department }, + Versions = new List { new RmsRecordDefinitionVersion { Version = 1, State = (int)RmsDefinitionVersionState.Published, Schema = Schema() } } + }; + var stored = new RmsRecordPrintLayout + { + Scope = (int)RmsRecordPrintLayoutScope.Definition, DefinitionKey = "shift-log", Version = 3, + DefinitionConfig = new RecordsDefinitionLayoutConfig + { + SectionOrder = new List { "signoff", "crew" }, HiddenSectionKeys = new List { "crew" }, HiddenFieldKeys = new List { "internal_note" }, + SectionHeadings = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["summary"] = "Overview" }, PageBreakBeforeSectionKeys = new List { "signoff" }, + AppliesToVersion = 1, SignatureBlockPlacement = RecordsDefinitionLayoutConfig.SignatureNone, AttachmentListStyle = RecordsDefinitionLayoutConfig.AttachmentsNone, + BrandingOverrides = new RecordsPrintLayoutConfig { WatermarkLabel = "DRAFT" } + } + }; + + var model = RecordDefinitionLayoutView.From(aggregate, aggregate.Versions[0], stored, "department-default/2"); + + model.LayoutVersion.Should().Be("shift-log/3"); + model.DepartmentLayoutVersion.Should().Be("department-default/2"); + model.OrderedSectionKeys().Should().Equal(new[] { "signoff", "crew", "summary" }, "hidden sections stay listed so they can be shown again; unmentioned sections follow"); + model.Visible["crew"].Should().BeFalse(); + model.OverrideBranding.Should().BeTrue(); + model.Versions.Should().ContainSingle(v => v.Value == "1"); + + var config = model.ToConfig(); + config.SectionOrder.Should().Equal("signoff", "crew", "summary"); + config.HiddenSectionKeys.Should().Equal("crew"); + config.HiddenFieldKeys.Should().Equal("internal_note"); + config.SectionHeadings["summary"].Should().Be("Overview"); + config.PageBreakBeforeSectionKeys.Should().Equal("signoff"); + config.AppliesToVersion.Should().Be(1); + config.SignatureBlockPlacement.Should().Be(RecordsDefinitionLayoutConfig.SignatureNone); + config.BrandingOverrides.WatermarkLabel.Should().Be("DRAFT"); + + model.OverrideBranding = false; + model.ToConfig().BrandingOverrides.Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs index 0f6cea1a..ffebe93c 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs @@ -65,7 +65,7 @@ public void SetUp() incidents.Setup(s => s.BuildSnapshotAsync(Dept, It.IsAny(), It.IsAny())).ReturnsAsync((int d, string id, string revision) => JsonConvert.DeserializeObject(_store.Revisions.Single(r => r.RmsRevisionId == revision).SnapshotJson)); var udf = new RecordsUdfService(Mock.Of(), Mock.Of(), Mock.Of(), _authorization.Object, Mock.Of(), _store.UnitOfWork.Object, Mock.Of()); var documents = new RecordsDocumentService(_authorization.Object, _store.RecordsRepo.Object, _incidentStore.ReportsRepo.Object, _incidentStore.AnalysesRepo.Object, _store.RevisionsRepo.Object, - incidents.Object, Mock.Of(), Mock.Of(), _pdf.Object, Mock.Of(), udf, new PassthroughRecordsProtection()); + incidents.Object, Mock.Of(), Mock.Of(), _pdf.Object, Mock.Of(), udf, new PassthroughRecordsProtection(), Mock.Of()); _service = new RecordsDisclosureService(_store.DisclosureRequestsRepo.Object, _store.DisclosureProductionsRepo.Object, _store.RecordsRepo.Object, _store.RevisionsRepo.Object, _store.AuditsRepo.Object, _authorization.Object, _settings.Object, _store.UnitOfWork.Object, _incidentStore.ReportsRepo.Object, documents, _store.AttachmentsRepo.Object, _pdf.Object, _incidentStore.AnalysesRepo.Object, _scanner.Object, udf, diff --git a/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs index fef7c29a..58adf2f3 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs @@ -52,7 +52,7 @@ public void Setup() var brand = new Mock(); brand.Setup(b => b.GetBrandingAsync(1)).ReturnsAsync(new DepartmentBranding { DisplayName = "Example Fire Department", AddressText = "100 Example Street", PhoneNumber = "555-0100", Website = "example.invalid" }); var layouts = new Mock(); layouts.Setup(l => l.GetDepartmentDefaultAsync(1)).ReturnsAsync(new RmsRecordPrintLayout { Version = 3, Scope = 1, Config = new RecordsPrintLayoutConfig { LetterheadLine1 = "Fire Prevention and Emergency Response", FooterText = "Departmental record copy", WatermarkLabel = "TRAINING FIXTURE" } }); var udf = new RecordsUdfService(Mock.Of(), Mock.Of(), Mock.Of(), _auth.Object, _groups.Object, Mock.Of(), Mock.Of()); - _service = new RecordsDocumentService(_auth.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, _store.Shared.RevisionsRepo.Object, _incidents.Object, brand.Object, layouts.Object, _pdf.Object, Mock.Of(), udf, new PassthroughRecordsProtection()); + _service = new RecordsDocumentService(_auth.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, _store.Shared.RevisionsRepo.Object, _incidents.Object, brand.Object, layouts.Object, _pdf.Object, Mock.Of(), udf, new PassthroughRecordsProtection(), Mock.Of()); } private void CaptureCustomFields() diff --git a/Tests/Resgrid.Tests/Rms/RecordsExportServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsExportServiceTests.cs index 99c3b5e9..cd0a152f 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsExportServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsExportServiceTests.cs @@ -55,6 +55,15 @@ public void SetUp() _templatesRepo.Setup(r => r.GetByKeyAsync(Dept, It.IsAny())).ReturnsAsync((int d, string key) => _templates.FirstOrDefault(t => t.TemplateKey == key && t.DeletedOn == null)); _templatesRepo.Setup(r => r.GetForDepartmentAsync(Dept)).ReturnsAsync(() => _templates.Where(t => t.DeletedOn == null).ToList()); _templatesRepo.Setup(r => r.GetDueAsync(It.IsAny(), It.IsAny())).ReturnsAsync((DateTime now, int take) => _templates.Where(t => t.IsEnabled && t.ScheduleKind != 0 && t.NextRunOn <= now && t.DeletedOn == null).ToList()); + // The claim is the real conditional UPDATE: it only succeeds while the stored NextRunOn still matches. + _templatesRepo.Setup(r => r.TryClaimDueAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, DateTime expected, DateTime defer, DateTime now, CancellationToken c) => + { + var row = _templates.FirstOrDefault(t => t.RmsExportTemplateId == id && t.DeletedOn == null && t.NextRunOn == expected); + if (row == null) return false; + row.NextRunOn = defer; row.ModifiedOn = now; row.RowVersion += 1; + return true; + }); _runsRepo = new Mock(); _runsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((RmsExportRun run, CancellationToken c, bool f) => { _runs.Add(run); return run; }); @@ -172,6 +181,50 @@ public async Task Save_normalizes_the_template_records_the_acknowledgement_and_s narrowed.EgressAcknowledgedOn.Should().BeNull(); } + [Test] + public async Task Widening_the_carried_tiers_drops_the_acknowledgement_and_a_clerk_keeps_the_restricted_half() + { + // The edit form posts a freshly bound template every time, never the stored instance. + RmsExportTemplate Post(string id, bool restricted, params string[] columns) => new RmsExportTemplate + { + RmsExportTemplateId = id, TemplateKey = "state-runs", Name = "State runs", Format = (int)RmsExportFormat.Csv, + Scope = (int)RmsExportScope.Window, IncludeHeader = true, Delimiter = ",", ScheduleKind = (int)RmsExportScheduleKind.Daily, + ScheduleHourLocal = 6, IsEnabled = true, IncludeNarrative = true, IncludeRestricted = restricted, + ColumnsJson = JsonConvert.SerializeObject(columns) + }; + + var saved = await _service.SaveAsync(Dept, "admin", Post(null, false, "record.number", "details.narrative"), acknowledgeEgress: true); + saved.EgressAcknowledgedOn.Should().NotBeNull(); + var id = saved.RmsExportTemplateId; + + // Adding a Restricted column to a set acknowledged for Narrative content is a new decision, so the + // acknowledgement recorded against the narrower set does not carry over — and without it the save is + // refused rather than quietly reusing the older, narrower consent. + var widen = async () => await _service.SaveAsync(Dept, "admin", Post(id, true, "record.number", "details.narrative", "details.case_number"), acknowledgeEgress: false); + (await widen.Should().ThrowAsync()).WithMessage("*Acknowledge*"); + + var reacknowledged = await _service.SaveAsync(Dept, "admin", Post(id, true, "record.number", "details.narrative", "details.case_number"), acknowledgeEgress: true); + reacknowledged.EgressAcknowledgedOn.Should().NotBeNull(); + + // A member without the restricted grant edits the rest of the template: the disabled restricted + // inputs post nothing, and the stored restricted half has to survive rather than be stripped. + _restricted = false; + var clerkPost = Post(id, false, "record.number", "details.narrative"); + clerkPost.Name = "Renamed"; + var clerkSaved = await _service.SaveAsync(Dept, "clerk", clerkPost, acknowledgeEgress: false); + clerkSaved.Name.Should().Be("Renamed"); + clerkSaved.IncludeRestricted.Should().BeTrue("a member who cannot see the restricted switches cannot turn them off either"); + RecordsExportService.ParseColumns(clerkSaved.ColumnsJson).Should().Contain("details.case_number"); + clerkSaved.EgressAcknowledgedOn.Should().NotBeNull("nothing widened, so the recorded decision stands"); + + // The grant still gates CHANGING the restricted half. + var clerkAdds = Post(id, true, "record.number", "details.narrative", "details.case_number", "details.destination"); + var act = async () => await _service.SaveAsync(Dept, "clerk", clerkAdds, acknowledgeEgress: true); + await act.Should().NotThrowAsync("the clerk's posted restricted half is replaced by the stored one before validation"); + RecordsExportService.ParseColumns((await _service.GetTemplateAsync(Dept, id)).ColumnsJson) + .Should().NotContain("details.destination", "an author without the grant cannot add a restricted column"); + } + [Test] public void Next_run_lands_on_the_department_local_hour_strictly_after_now() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsNfirsLegacyServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsNfirsLegacyServiceTests.cs index afac9a7f..d3611103 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsNfirsLegacyServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsNfirsLegacyServiceTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Moq; @@ -30,6 +31,7 @@ public class RecordsNfirsLegacyServiceTests private Mock _incidents; private Mock _authorization; private Mock _neris; + private Mock _protectedReads; private Call _call; private RecordsNfirsLegacyService _service; @@ -55,7 +57,10 @@ public void SetUp() _neris = new Mock(); _neris.Setup(n => n.GetProfileAsync(Dept)).ReturnsAsync(new RmsNerisProfile { DepartmentId = Dept, NerisEntityId = "FD24027000" }); _neris.Setup(n => n.ResolveCrosswalkAsync(Dept, "incident_type", NerisCrosswalkSources.CallType, "Fire")).ReturnsAsync("FIRE||STRUCTURE_FIRE||RESIDENTIAL"); - _service = new RecordsNfirsLegacyService(_calls.Object, _units.Object, _reporting.Object, _incidents.Object, _authorization.Object, _neris.Object); + _protectedReads = new Mock(); + _protectedReads.Setup(p => p.ResolveForReadAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectedReadResult()); + _service = new RecordsNfirsLegacyService(_calls.Object, _units.Object, _reporting.Object, _incidents.Object, _authorization.Object, _neris.Object, _protectedReads.Object); } private NfirsLegacyField Field(NfirsLegacyRendering r, string name) => r.Fields.Single(f => f.Name == name); diff --git a/Tests/Resgrid.Tests/Rms/RecordsServiceDefinitionTests.cs b/Tests/Resgrid.Tests/Rms/RecordsServiceDefinitionTests.cs new file mode 100644 index 00000000..bd694a90 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsServiceDefinitionTests.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services.Records; +using static Resgrid.Tests.Rms.RmsDefinitionHarness; + +namespace Resgrid.Tests.Rms +{ + /// RecordsService on a department definition (RMS-1B): pinned version, typed values, numbering policy, finalize rules, role narrowing and the definition/fields Workflow blocks. + [TestFixture] + public class RecordsServiceDefinitionTests + { + private RmsDefinitionHarness _h; + + [SetUp] + public void SetUp() => _h = new RmsDefinitionHarness(); + + private static RecordDefinitionSchema ShiftLog() => Schema( + Section("shift", "Shift", + Field("site", RmsFieldType.ShortText, true, configure: f => { f.Searchable = true; f.WorkflowExposed = true; f.Filterable = true; f.Sortable = true; }), + Field("hours", RmsFieldType.Quantity, configure: f => { f.UnitFamily = "time"; f.DefaultUnit = "h"; f.Aggregatable = true; f.WorkflowExposed = true; }), + Select("status", "Open", "Closed"), Field("secret", RmsFieldType.ShortText, classification: RmsFieldClassification.Restricted), Field("notes", RmsFieldType.LongText)), + Rows("stops", "Stops", null, 10, Field("stop", RmsFieldType.ShortText, true, configure: f => f.WorkflowExposed = true), Field("minutes", RmsFieldType.Integer, configure: f => f.Aggregatable = true))); + + [Test] + public async Task Records_on_a_definition_pin_the_published_version_store_values_and_reserve_numbers_on_create() + { + await _h.CreateAndPublishAsync("shift-log", "Shift log", ShiftLog(), d => { d.Numbering = new RecordDefinitionNumbering { Prefix = "SL", Assignment = RmsNumberAssignment.OnCreate, SequenceWidth = 3, ResetYearly = true }; }); + Func unpublished = () => _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "nope" }); + await unpublished.Should().ThrowAsync().WithMessage("*not a published definition*"); + + var draft = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput + { + DefinitionKey = "shift-log", + Values = new List { Value("shift", "site", "Depot 4"), new RecordValueInput { SectionKey = "shift", FieldKey = "hours", Value = "2", UnitCode = "h" }, Value("shift", "status", "open"), Value("shift", "secret", "SSN") } + }); + draft.Record.RecordType.Should().BeNull(); + draft.Record.DefinitionVersion.Should().Be(1); + draft.Record.LifecyclePreset.Should().Be((int)RmsLifecyclePreset.QuickEntry); + draft.Record.RecordNumber.Should().Be("SL-" + DateTime.UtcNow.Year + "-001", "OnCreate reserves the number at draft creation with the definition's prefix and width"); + draft.Record.DisplaySummary.Should().Be("Depot 4"); + draft.DefinitionVersionRow.Version.Should().Be(1); + draft.Values.Scalar("hours").CanonicalNumber.Should().Be(120m); + draft.Values.Scalar("status").Display.Should().Be("Open"); + _h.Store.Projections.Single().SearchText.Should().Contain("Depot 4").And.NotContain("SSN"); + + var second = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "shift-log", Values = new List { Value("shift", "site", "Depot 5") } }); + second.Record.RecordNumber.Should().Be("SL-" + DateTime.UtcNow.Year + "-002"); + + // Autosave stores values without enforcing requiredness; finalize enforces them. + var saved = await _h.Records.SaveDraftAsync(Dept, Author, second.Record.RmsOperationalRecordId, second.Record.RowVersion, new RecordDraftInput + { + DefinitionKey = "shift-log", Values = new List { Value("stops", "stop", "Gate", "r1", 0), Value("stops", "minutes", "15", "r1", 0) } + }); + saved.Values.Scalar("site").Display.Should().BeNull("a save replaces the draft values"); + saved.Values.Section("stops").Rows.Should().HaveCount(1); + Func finalizeInvalid = () => _h.Records.FinalizeAsync(Dept, Author, second.Record.RmsOperationalRecordId, saved.Record.RowVersion, "1", null, null); + await finalizeInvalid.Should().ThrowAsync().WithMessage("*Site*required*"); + _h.Store.Revisions.Should().BeEmpty(); + } + + [Test] + public async Task Finalize_snapshots_typed_values_and_lifecycle_events_carry_definition_and_fields_blocks() + { + await _h.CreateAndPublishAsync("shift-log", "Shift log", ShiftLog(), d => d.Numbering.Prefix = "SL"); + var draft = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput + { + DefinitionKey = "shift-log", + Values = new List + { + Value("shift", "site", "Depot 4"), new RecordValueInput { SectionKey = "shift", FieldKey = "hours", Value = "90", UnitCode = "min" }, Value("shift", "secret", "SSN"), Value("shift", "notes", "quiet night"), + Value("stops", "stop", "Gate", "r1", 0), Value("stops", "minutes", "15", "r1", 0), Value("stops", "stop", "Yard", "r2", 1), Value("stops", "minutes", "25", "r2", 1) + } + }); + draft.Record.RecordNumber.Should().BeNull("OnFinalize numbering waits"); + var finalized = await _h.Records.FinalizeAsync(Dept, Author, draft.Record.RmsOperationalRecordId, draft.Record.RowVersion, "1", null, null); + finalized.Record.RecordNumber.Should().StartWith("SL-"); + finalized.Record.State.Should().Be((int)RmsRecordState.Finalized); + + var revision = _h.Store.Revisions.Single(); + var snapshot = JObject.Parse(revision.SnapshotJson); + var values = snapshot["Values"] ?? snapshot["values"]; + values.Should().NotBeNull("typed values ride in the revision snapshot"); + values["Shift"]["Site"].Value().Should().Be("Depot 4"); + values["Shift"]["Secret" + RecordSnapshotSerializer.RestrictedValueSuffix].Value().Should().Be("SSN"); + ((JArray)values["Stops"]).Should().HaveCount(2); + _h.Defs.Values.Count(v => v.RevisionId == revision.RmsRevisionId).Should().Be(8, "every draft row is copied onto the revision"); + _h.Defs.Values.Count(v => v.RevisionId == null).Should().Be(8, "the draft rows stay for the next amendment"); + + var finalizedEvent = _h.Store.Outbox.Single(e => e.TriggerEventType == (int)WorkflowTriggerEventType.RecordFinalized); + var payload = JObject.Parse(finalizedEvent.PayloadJson); + payload["definition"]["key"].Value().Should().Be("shift-log"); + payload["definition"]["version"].Value().Should().Be(1); + ((JArray)payload["definition"]["exposed_field_keys"]).Select(t => t.Value()).Should().Contain("site").And.NotContain("secret"); + payload["fields"]["site"].Value().Should().Be("Depot 4"); + payload["fields"]["hours"].Value().Should().Be(90m); + payload["fields"]["stops_count"].Value().Should().Be(2); + payload["fields"]["secret"].Should().BeNull("restricted values never reach Workflow"); + payload["fields"]["notes"].Should().BeNull("only WorkflowExposed fields are published"); + + // Reading back as a viewer without RecordRestricted_View withholds the restricted cell but keeps the rest. + var full = await _h.Records.GetAsync(Dept, draft.Record.RmsOperationalRecordId, true); + full.Values.Scalar("secret").Display.Should().Be("SSN"); + full.DefinitionVersionRow.Version.Should().Be(1); + } + + [Test] + public async Task Definition_roles_narrow_who_may_review_and_approve() + { + await _h.CreateAndPublishAsync("inspection", "Inspection", ShiftLog(), d => + { + d.LifecyclePreset = RmsLifecyclePreset.ApprovalAcknowledgement; d.ReviewerRoleIds = new List { 5 }; d.ApproverRoleIds = new List { 9 }; d.ReviewDueHours = 12; + }); + var draft = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "inspection", Values = new List { Value("shift", "site", "Depot 4") } }); + draft.Record.LifecyclePreset.Should().Be((int)RmsLifecyclePreset.ApprovalAcknowledgement); + var submitted = await _h.Records.SubmitForReviewAsync(Dept, Author, draft.Record.RmsOperationalRecordId, draft.Record.RowVersion); + submitted.Record.State.Should().Be((int)RmsRecordState.ReadyForReview); + submitted.Record.ReviewDueOn.Should().BeCloseTo(DateTime.UtcNow.AddHours(12), TimeSpan.FromMinutes(2), "the version's review window wins over the department setting"); + + _h.Roles.Setup(r => r.GetRolesForUserAsync("reviewer", Dept)).ReturnsAsync(new List { new PersonnelRole { PersonnelRoleId = 5, DepartmentId = Dept, Name = "Lieutenant" } }); + _h.Roles.Setup(r => r.GetRolesForUserAsync("chief", Dept)).ReturnsAsync(new List { new PersonnelRole { PersonnelRoleId = 9, DepartmentId = Dept, Name = "Chief" } }); + Func notApprover = () => _h.Records.ApproveAsync(Dept, "reviewer", draft.Record.RmsOperationalRecordId); + await notApprover.Should().ThrowAsync().WithMessage("*limits approval*"); + var approved = await _h.Records.ApproveAsync(Dept, "chief", draft.Record.RmsOperationalRecordId); + approved.Record.State.Should().Be((int)RmsRecordState.Approved); + var final = await _h.Records.FinalizeAsync(Dept, Author, draft.Record.RmsOperationalRecordId, approved.Record.RowVersion, "1", null, null); + final.Record.State.Should().Be((int)RmsRecordState.Finalized, "the author acknowledges an approved Record"); + + // ReviewRequired: the reviewer roles gate finalization out of ReadyForReview. + await _h.CreateAndPublishAsync("review-only", "Review only", ShiftLog(), d => { d.LifecyclePreset = RmsLifecyclePreset.ReviewRequired; d.ReviewerRoleIds = new List { 5 }; }); + var second = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "review-only", Values = new List { Value("shift", "site", "Depot 5") } }); + var pending = await _h.Records.SubmitForReviewAsync(Dept, Author, second.Record.RmsOperationalRecordId, second.Record.RowVersion); + Func notReviewer = () => _h.Records.FinalizeAsync(Dept, "chief", second.Record.RmsOperationalRecordId, pending.Record.RowVersion, "1", null, null); + await notReviewer.Should().ThrowAsync().WithMessage("*limits review*"); + (await _h.Records.FinalizeAsync(Dept, "reviewer", second.Record.RmsOperationalRecordId, pending.Record.RowVersion, "1", null, null)).Record.State.Should().Be((int)RmsRecordState.Finalized); + } + + [Test] + public async Task Records_written_on_v1_keep_rendering_on_v1_after_v2_publishes() + { + await _h.CreateAndPublishAsync("shift-log", "Shift log", ShiftLog()); + var draft = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "shift-log", Values = new List { Value("shift", "site", "Depot 4"), Value("shift", "notes", "keep") } }); + await _h.Records.FinalizeAsync(Dept, Author, draft.Record.RmsOperationalRecordId, draft.Record.RowVersion, "1", null, null); + + var v2 = await _h.Definitions.OpenDraftAsync(Dept, Admin, "shift-log"); + var input = RecordDefinitionsService.ToDraftInput(v2); + input.Schema.FindSection("shift").Fields.RemoveAll(f => f.Key == "notes"); + await _h.Definitions.SaveDraftAsync(Dept, Admin, "shift-log", 2, v2.RowVersion, input); + await _h.PublishAsync("shift-log"); + + var old = await _h.Records.GetAsync(Dept, draft.Record.RmsOperationalRecordId, true); + old.Record.DefinitionVersion.Should().Be(1); + old.DefinitionVersionRow.Version.Should().Be(1); + old.Values.Scalar("notes").Display.Should().Be("keep", "the pinned version still labels and shapes the stored values"); + var fresh = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "shift-log", Values = new List { Value("shift", "site", "Depot 9") } }); + fresh.Record.DefinitionVersion.Should().Be(2); + Func stale = () => _h.Records.SaveDraftAsync(Dept, Author, fresh.Record.RmsOperationalRecordId, fresh.Record.RowVersion, new RecordDraftInput { DefinitionKey = "shift-log", Values = new List { Value("shift", "notes", "gone") } }); + await stale.Should().ThrowAsync("v2 no longer has a notes field"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs index ef3ef9f9..53142d4b 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs @@ -94,7 +94,7 @@ public void SetUp() _service = new RecordsService(_store.RecordsRepo.Object, new Resgrid.Services.Records.RmsRecordValueService(_store.DetailsRepo.Object), _store.ParticipantsRepo.Object, _store.UnitsRepo.Object, _store.AttachmentsRepo.Object, _store.RevisionsRepo.Object, _evidence.Object, _store.ScopesRepo.Object, _store.SharesRepo.Object, _store.ProjectionsRepo.Object, _store.AuditsRepo.Object, outbox, _cutover.Object, _settings.Object, _groups.Object, _profiles.Object, _units.Object, _calls.Object, _adp.Object, - _store.UnitOfWork.Object, _outboundQueue.Object, new Resgrid.Services.Records.NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), _protection); + _store.UnitOfWork.Object, _outboundQueue.Object, new Resgrid.Services.Records.NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), _protection, Mock.Of(), Mock.Of(), Mock.Of()); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs b/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs index 38062461..c6f3586f 100644 --- a/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs @@ -63,6 +63,30 @@ public void Records_services_resolve_from_the_container() Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); + // RMS-1B/1C (2026-09-06): configurable definitions, typed values, saved reports, template packs, deployments, reveal + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + // RMS-1D Field Records + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve>().Should().Contain(a => a.Kind == RmsEvidenceKind.ModuleProjection); + 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(); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs b/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs new file mode 100644 index 00000000..34a51968 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// Wires the real RMS-1B/1C services (definitions, typed values, template packs, saved reports, deployments) and the real + /// RecordsService over the in-memory fakes so behavior can be asserted end to end without a database. + /// + public sealed class RmsDefinitionHarness + { + public const int Dept = 7; + public const string Admin = "admin"; + public const string Author = "author"; + + public FakeRmsStore Store { get; } = new FakeRmsStore(); + public FakeRmsDefinitionStore Defs { get; } + public Mock Authorization { get; } = new Mock(); + public Mock Flags { get; } = new Mock(); + public Mock Departments { get; } = new Mock(); + public Mock Units { get; } = new Mock(); + public Mock Groups { get; } = new Mock(); + public Mock Contacts { get; } = new Mock(); + public Mock Calls { get; } = new Mock(); + public Mock Inventory { get; } = new Mock(); + public Mock Roles { get; } = new Mock(); + public Mock Cutover { get; } = new Mock(); + public Mock Settings { get; } = new Mock(); + public Mock Profiles { get; } = new Mock(); + public Mock Adp { get; } = new Mock(); + public Mock Evidence { get; } = new Mock(); + public Mock OutboundQueue { get; } = new Mock(); + public PassthroughRecordsProtection Protection { get; } = new PassthroughRecordsProtection(); + public List Published { get; } = new List(); + + public DomainEventOutboxService Outbox { get; } + public RecordTemplatePacksService Templates { get; } + public RecordTypedValuesService TypedValues { get; } + public RecordDefinitionsService Definitions { get; } + public RecordsService Records { get; } + public RecordSavedReportsService Reports { get; } + public RecordDeploymentsService Deployments { get; } + + public RmsDefinitionHarness() + { + Resgrid.Config.SystemBehaviorConfig.CacheEnabled = false; + Defs = new FakeRmsDefinitionStore(Store); + + Authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + Authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); + Authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); + Authorization.Setup(a => a.CanReadSourceCallAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); + Authorization.Setup(a => a.IsGroupScopedAsync(Dept)).ReturnsAsync(false); + Flags.Setup(f => f.IsEnabledAsync(It.IsAny(), Dept, It.IsAny(), It.IsAny>())).ReturnsAsync(true); + + Departments.Setup(d => d.GetAllPersonnelNamesForDepartmentAsync(Dept)).ReturnsAsync(new List + { + new PersonName { UserId = Admin, FirstName = "Ada", LastName = "Admin" }, new PersonName { UserId = Author, FirstName = "Pat", LastName = "Author" }, new PersonName { UserId = "p2", FirstName = "Sam", LastName = "Second" } + }); + Units.Setup(u => u.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = Dept, Name = "Engine 5", Type = "Engine", StationGroupId = 13 }); + Units.Setup(u => u.GetUnitByIdAsync(99)).ReturnsAsync(new Unit { UnitId = 99, DepartmentId = 1, Name = "Other dept" }); + Groups.Setup(g => g.GetGroupByIdAsync(11, It.IsAny())).ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 11, DepartmentId = Dept, Name = "Station 1" }); + Groups.Setup(g => g.GetGroupForUserAsync(It.IsAny(), Dept)).ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 11, DepartmentId = Dept, Name = "Station 1" }); + Contacts.Setup(c => c.GetContactByIdAsync("c1")).ReturnsAsync(new Contact { ContactId = "c1", DepartmentId = Dept, CompanyName = "Acme Logistics" }); + Calls.Setup(c => c.GetCallByIdAsync(77, It.IsAny())).ReturnsAsync(new Call { CallId = 77, DepartmentId = Dept, Number = "C-77", Name = "Structure fire", Type = "Fire", LoggedOn = new DateTime(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc) }); + Inventory.Setup(i => i.GetInventoryByIdAsync(9)).ReturnsAsync(new Inventory { InventoryId = 9, DepartmentId = Dept, Type = new InventoryType { Type = "Hose 50ft" } }); + Roles.Setup(r => r.GetRolesForUserAsync(It.IsAny(), Dept)).ReturnsAsync(new List()); + + Cutover.Setup(c => c.GetModuleStateAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsModuleState { DepartmentId = Dept, FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active, LegacyWritesBlocked = true }); + Settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsNumberingConfig()); + Settings.Setup(s => s.GetRecordsReviewDueHoursAsync(Dept, It.IsAny())).ReturnsAsync(72); + Profiles.Setup(p => p.GetProfileByUserIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, bool b) => new UserProfile { UserId = id, FirstName = "First", LastName = id }); + Adp.Setup(a => a.GetPinnedCatalogVersionAsync(Dept)).ReturnsAsync(0); + Evidence.Setup(e => e.BindToRevisionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(0); + OutboundQueue.Setup(q => q.EnqueueNotification(It.IsAny())).ReturnsAsync(true); + + var aggregator = new Mock(); + aggregator.Setup(a => a.SendMessage(It.IsAny())).Callback(e => Published.Add(e)); + Outbox = new DomainEventOutboxService(Store.OutboxRepo.Object, aggregator.Object); + + Templates = new RecordTemplatePacksService(Defs.PacksRepo.Object, Defs.ProfilesRepo.Object); + TypedValues = new RecordTypedValuesService(Defs.ValuesRepo.Object, Defs.GroupsRepo.Object, Store.AttachmentsRepo.Object, + Departments.Object, Units.Object, Groups.Object, Contacts.Object, Calls.Object, Inventory.Object, Protection); + Definitions = new RecordDefinitionsService(Defs.DefinitionsRepo.Object, Defs.VersionsRepo.Object, Defs.SectionsRepo.Object, Defs.FieldsRepo.Object, + Store.RecordsRepo.Object, Defs.ValuesRepo.Object, TypedValues, Templates, Authorization.Object, Protection, Outbox, Flags.Object, Store.AuditsRepo.Object, Store.UnitOfWork.Object); + Records = new RecordsService(Store.RecordsRepo.Object, new RmsRecordValueService(Store.DetailsRepo.Object), Store.ParticipantsRepo.Object, Store.UnitsRepo.Object, + Store.AttachmentsRepo.Object, Store.RevisionsRepo.Object, Evidence.Object, Store.ScopesRepo.Object, Store.SharesRepo.Object, Store.ProjectionsRepo.Object, + Store.AuditsRepo.Object, Outbox, Cutover.Object, Settings.Object, Groups.Object, Profiles.Object, Units.Object, Calls.Object, Adp.Object, + Store.UnitOfWork.Object, OutboundQueue.Object, new NullRecordAttachmentScanner(), Authorization.Object, Mock.Of(), Protection, Definitions, TypedValues, Roles.Object); + Reports = new RecordSavedReportsService(Defs.ReportsRepo.Object, Definitions, Records, Store.RecordsRepo.Object, Defs.ValuesRepo.Object, Defs.GroupsRepo.Object, Authorization.Object, Store.AuditsRepo.Object); + Deployments = new RecordDeploymentsService(Defs.OrdersRepo.Object, Defs.FillsRepo.Object, Defs.ReferencesRepo.Object, Records, Definitions, Templates, Authorization.Object, Store.AuditsRepo.Object, Store.UnitOfWork.Object); + } + + /// Creates a blank department definition and replaces the starter schema with in draft v1. + public async Task CreateAsync(string key, string name, RecordDefinitionSchema schema, Action configure = null) + { + var created = await Definitions.CreateAsync(Dept, Admin, new RecordDefinitionCreateInput { DefinitionKey = key, Name = name }); + var draft = created.Draft; + var input = RecordDefinitionsService.ToDraftInput(draft); + input.Schema = schema; + configure?.Invoke(input); + await Definitions.SaveDraftAsync(Dept, Admin, key, draft.Version, draft.RowVersion, input); + return await Definitions.GetAsync(Dept, key); + } + + public async Task PublishAsync(string key) + { + var aggregate = await Definitions.GetAsync(Dept, key); + var draft = aggregate.Draft ?? throw new InvalidOperationException("no draft"); + return await Definitions.PublishAsync(Dept, Admin, key, draft.Version, draft.RowVersion); + } + + public async Task CreateAndPublishAsync(string key, string name, RecordDefinitionSchema schema, Action configure = null) + { + await CreateAsync(key, name, schema, configure); + return await PublishAsync(key); + } + + public RmsRecordDefinitionVersion Version(string key, int version) => Defs.Versions.Single(v => v.DefinitionKey == key && v.Version == version); + + public IEnumerable Events(WorkflowTriggerEventType trigger) => Store.Outbox.Where(e => e.TriggerEventType == (int)trigger); + + // ---- schema builders ------------------------------------------------------------------------- + + public static RecordDefinitionSchema Schema(params RecordSectionSchema[] sections) => new RecordDefinitionSchema { Sections = sections.ToList() }; + public static RecordSectionSchema Section(string key, string label, params RecordFieldSchema[] fields) => new RecordSectionSchema { Key = key, Label = label, Fields = fields.ToList() }; + public static RecordSectionSchema Rows(string key, string label, int? min, int? max, params RecordFieldSchema[] fields) => new RecordSectionSchema { Key = key, Label = label, Repeating = true, MinRows = min, MaxRows = max, Fields = fields.ToList() }; + public static RecordFieldSchema Field(string key, RmsFieldType type, bool required = false, RmsFieldClassification classification = RmsFieldClassification.Standard, Action configure = null) + { + var field = new RecordFieldSchema { Key = key, Label = char.ToUpperInvariant(key[0]) + key.Substring(1).Replace('_', ' '), Type = type, Required = required, Classification = classification }; + configure?.Invoke(field); + return field; + } + public static RecordFieldSchema Select(string key, params string[] options) => Field(key, RmsFieldType.SingleSelect, configure: f => { f.Options = options.Select(o => new RecordOptionSchema { Key = o.ToLowerInvariant().Replace(' ', '-'), Label = o }).ToList(); f.Groupable = true; f.Filterable = true; f.WorkflowExposed = true; }); + public static RecordFieldSchema Multi(string key, params string[] options) => Field(key, RmsFieldType.MultiSelect, configure: f => f.Options = options.Select(o => new RecordOptionSchema { Key = o.ToLowerInvariant().Replace(' ', '-'), Label = o }).ToList()); + public static RecordRuleSchema ShowWhen(string fieldKey, string value) => new RecordRuleSchema { Effect = RmsRuleEffect.Show, Condition = new RecordConditionSchema { Operator = RmsRuleOperator.Equals, FieldKey = fieldKey, Value = value } }; + public static RecordRuleSchema RequireWhen(string fieldKey, string value) => new RecordRuleSchema { Effect = RmsRuleEffect.Require, Condition = new RecordConditionSchema { Operator = RmsRuleOperator.Equals, FieldKey = fieldKey, Value = value } }; + public static RecordValueInput Value(string section, string field, string value, string rowKey = null, int ordinal = 0) => new RecordValueInput { SectionKey = section, FieldKey = field, Value = value, RowKey = rowKey, Ordinal = ordinal }; + public static RecordValueInput Reference(string section, string field, string referenceId, string referenceType = null, string rowKey = null, int ordinal = 0) => new RecordValueInput { SectionKey = section, FieldKey = field, ReferenceId = referenceId, ReferenceType = referenceType, RowKey = rowKey, Ordinal = ordinal }; + + public static RmsRecordDefinitionVersion DetachedVersion(string key, RecordDefinitionSchema schema, int version = 1) => new RmsRecordDefinitionVersion + { + RmsRecordDefinitionVersionId = key + "-v" + version, DepartmentId = Dept, DefinitionKey = key, Version = version, State = (int)RmsDefinitionVersionState.Published, + LifecyclePreset = (int)RmsLifecyclePreset.QuickEntry, SchemaJson = RecordDefinitionSchema.Serialize(schema), NumberingJson = Newtonsoft.Json.JsonConvert.SerializeObject(new RecordDefinitionNumbering { Prefix = "TST" }), + CreatedOn = DateTime.UtcNow, ModifiedOn = DateTime.UtcNow, RowVersion = 1 + }; + } +} diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index 0da23b62..3ca23bd5 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -82,6 +82,8 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() ((int)WorkflowTriggerEventType.RecordVoided).Should().Be(106); ((int)WorkflowTriggerEventType.RecordCancelled).Should().Be(107); ((int)WorkflowTriggerEventType.RecordOverdue).Should().Be(112); + ((int)WorkflowTriggerEventType.RecordDefinitionPublished).Should().Be(113); + ((int)WorkflowTriggerEventType.RecordDefinitionRetired).Should().Be(114); foreach (var value in Enumerable.Range(52, 48)) Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for another plan"); diff --git a/Tests/Resgrid.Tests/Rms/RmsProtectedFieldsCatalogTests.cs b/Tests/Resgrid.Tests/Rms/RmsProtectedFieldsCatalogTests.cs index 21a21619..178c93f5 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().Be(ProtectedFieldCatalog.RecordsCatalogVersion, $"{fieldId} ships with the RMS catalog bump"); + entries[fieldId].AddedInCatalogVersion.Should().BeInRange(ProtectedFieldCatalog.RecordsCatalogVersion, ProtectedFieldCatalog.RecordsTypedValuesCatalogVersion, $"{fieldId} ships with the RMS catalog bump (v10) or the typed-values follow-up (v11)"); } } diff --git a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs index f4b868b4..34cd08c5 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 + 45×30s overhead = 5850s; ×1.25 = 7312.5s → 122 min P50 (catalog v10 binds 45 tables). - result.EstimatedP50Minutes.Should().Be(122); - result.EstimatedP90Minutes.Should().Be(244); + // 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); 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/BrokerOperationServiceTests.cs b/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs index d3d60a33..059ffc39 100644 --- a/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs @@ -366,5 +366,72 @@ public async Task Empty_and_null_requests_are_invalid() nullRequest.Success.Should().BeFalse(); nullRequest.ErrorCode.Should().Be("invalid_request"); } + // ---- workload decrypt lane (RMS plan section 5.9.4) -------------------------------------------- + + private void EnrollDepartment(DepartmentDataProtectionState state) => + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)) + .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, PolicyEpoch = Epoch, State = (int)state }); + + [Test] + public async Task Workload_decrypt_lane_opens_an_allow_listed_purpose_without_a_grant() + { + EnrollDepartment(DepartmentDataProtectionState.Enabled); + var sealedResult = await _service.EncryptAsync(Request(null, "wl-enc", Item("Smoke showing from the rear", "rmsoperationalrecorddetails.narrative", "rec-1")), CancellationToken.None); + sealedResult.Success.Should().BeTrue(); + var envelope = sealedResult.Items[0].Value; + + var opened = await _service.DecryptForWorkloadAsync(Request(null, "wl-dec", Item(envelope, "rmsoperationalrecorddetails.narrative", "rec-1")), "NERIS-Submission", CancellationToken.None); + opened.Success.Should().BeTrue(); + opened.Items.Should().ContainSingle().Which.Value.Should().Be("Smoke showing from the rear"); + + var export = await _service.DecryptForWorkloadAsync(Request(null, "wl-dec-2", Item(envelope, "rmsoperationalrecorddetails.narrative", "rec-1")), "records-export", CancellationToken.None); + export.Success.Should().BeTrue("both shipped lanes are on the default allow-list"); + } + + [Test] + public async Task Workload_decrypt_lane_refuses_unlisted_purposes_unprotected_departments_and_a_disabled_lane() + { + EnrollDepartment(DepartmentDataProtectionState.Enabled); + var sealedResult = await _service.EncryptAsync(Request(null, "wl-enc", Item("Sensitive")), CancellationToken.None); + var envelope = sealedResult.Items[0].Value; + + (await _service.DecryptForWorkloadAsync(Request(null, "wl-1", Item(envelope)), "bulk-dump", CancellationToken.None)).ErrorCode.Should().Be("workload_purpose_denied"); + (await _service.DecryptForWorkloadAsync(Request(null, "wl-2", Item(envelope)), "", CancellationToken.None)).ErrorCode.Should().Be("workload_purpose_denied"); + (await _service.DecryptForWorkloadAsync(Request(null, "wl-3", Item(envelope)), null, CancellationToken.None)).ErrorCode.Should().Be("workload_purpose_denied"); + + // A denied purpose burns nothing: the same request id is still usable once the purpose is right. + (await _service.DecryptForWorkloadAsync(Request(null, "wl-1", Item(envelope)), "records-export", CancellationToken.None)).Success.Should().BeTrue(); + + EnrollDepartment(DepartmentDataProtectionState.Encrypting); + (await _service.DecryptForWorkloadAsync(Request(null, "wl-4", Item(envelope)), "records-export", CancellationToken.None)).ErrorCode.Should().Be("workload_purpose_denied", "an enrolling department has acknowledged no egress yet"); + EnrollDepartment(DepartmentDataProtectionState.Disabled); + (await _service.DecryptForWorkloadAsync(Request(null, "wl-5", Item(envelope)), "records-export", CancellationToken.None)).ErrorCode.Should().Be("workload_purpose_denied"); + + var configured = Resgrid.Config.DataProtectionConfig.BrokerWorkloadPurposes; + try + { + Resgrid.Config.DataProtectionConfig.BrokerWorkloadPurposes = ""; + EnrollDepartment(DepartmentDataProtectionState.Enabled); + (await _service.DecryptForWorkloadAsync(Request(null, "wl-6", Item(envelope)), "records-export", CancellationToken.None)).ErrorCode.Should().Be("workload_purpose_denied", "an empty allow-list disables the lane"); + } + finally { Resgrid.Config.DataProtectionConfig.BrokerWorkloadPurposes = configured; } + + // The attended decrypt path is untouched: no grant is still no decrypt. + (await _service.DecryptAsync(Request(null, "wl-7", Item(envelope)), CancellationToken.None)).Success.Should().BeFalse(); + } + + [Test] + public async Task Workload_decrypt_lane_still_validates_a_presented_grant() + { + EnrollDepartment(DepartmentDataProtectionState.Enabled); + var sealedResult = await _service.EncryptAsync(Request(null, "wl-enc", Item("Sensitive")), CancellationToken.None); + var envelope = sealedResult.Items[0].Value; + var refused = await _service.DecryptForWorkloadAsync(Request("not-a-grant", "wl-8", Item(envelope)), "records-export", CancellationToken.None); + refused.Success.Should().BeFalse(); + refused.ErrorCode.Should().Be("grant_invalid"); + BrokerOperationService.IsAllowedWorkloadPurpose("records-export").Should().BeTrue(); + BrokerOperationService.IsAllowedWorkloadPurpose("Records-Export").Should().BeFalse("purposes are normalized by the caller, not the allow-list"); + } + } } diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index a996ad9d..d1b9a070 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -287,7 +287,9 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude "RmsOperationalRecordDetails", "RmsNarratives", "RmsLocations", "RmsSourceFacts", "RmsCasualtyRescues", "RmsExposures", "RmsIncidentModules", "RmsIncidentProperties", "RmsIncidentVehicles", "RmsIncidentResources", "RmsRevisions", "RmsSubmissions", "RmsSignatures", "RmsEvidenceArtifacts", "RmsDisclosureRequests", "RmsDisclosureProductions", - "RmsRecordLegalHolds", "RmsRecordAttachments", "RmsExportRuns" + "RmsRecordLegalHolds", "RmsRecordAttachments", "RmsExportRuns", + // Typed values of department definitions, catalog v11: read through the same Records resolvers. + "RmsRecordValues" }; AdpTableBindings.V1.Select(b => b.TableName) diff --git a/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs b/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs index eb3f8456..bff74b91 100644 --- a/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs +++ b/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs @@ -22,10 +22,11 @@ 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, - // pinned field-by-field in RmsProtectedFieldsCatalogTests. - _catalog.Version.Should().Be(10); - _catalog.GetAddedBetween(9, 10).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 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()); + _catalog.GetAddedBetween(10, 11).Select(e => e.FieldId).Should().BeEquivalentTo(new[] { Resgrid.Model.RmsProtectedFields.ValueFieldId }); _catalog.GetAddedBetween(8, 9).Select(e => e.FieldId) .Should().BeEquivalentTo(new[] diff --git a/Tests/Resgrid.Tests/Web/Services/FieldRecordsApiControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/FieldRecordsApiControllerTests.cs new file mode 100644 index 00000000..3d07c2f3 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/FieldRecordsApiControllerTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Web.Services +{ + /// + /// The v4 Field Records surface (RMS plan RMS-1D): the origin, app version and context the client sends are + /// normalized and passed to the service, never trusted; a non-field origin is refused; and prefill outside the + /// caller's own catalog is forbidden rather than answered. + /// + [TestFixture] + public class FieldRecordsApiControllerTests + { + private const int Dept = 42; + private const string Me = "responder"; + + private Mock _field; + private Mock _assignments; + private FieldRecordsController _controller; + private DefaultHttpContext _http; + private Activity _activity; + + [SetUp] + public void SetUp() + { + _field = new Mock(); + _assignments = new Mock(); + _http = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, Me), + new Claim(ClaimTypes.PrimaryGroupSid, Dept.ToString()), + new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View), + new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Create) + }, "test")) + }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = _http }; + _activity = new Activity(nameof(FieldRecordsApiControllerTests)).Start(); + _controller = new FieldRecordsController(_field.Object, _assignments.Object) { ControllerContext = new ControllerContext { HttpContext = _http } }; + } + + [TearDown] + public void Cleanup() => _activity?.Stop(); + + [Test] + public async Task Preflight_normalizes_the_claimed_origin_and_reads_the_app_version_header() + { + _http.Request.Headers["X-Resgrid-App-Version"] = "5.4.1"; + _field.Setup(f => f.PreflightAsync(Dept, Me, RmsOriginClient.Responder, "5.4.1", null)) + .ReturnsAsync(new FieldRecordPreflight { Origin = RmsOriginClient.Responder, Ok = true, AppEnabled = true, ModuleEnabled = true, RecordsUsable = true, AppVersion = "5.4.1" }); + + var response = await _controller.Preflight((int)RmsOriginClient.Responder); + + var data = ((response.Result as OkObjectResult)?.Value as FieldRecordPreflightResult)?.Data; + data.Should().NotBeNull(); + data.Ok.Should().BeTrue(); + data.OriginClient.Should().Be(RmsOriginClient.Responder.ToString()); + data.ContractVersion.Should().Be(FieldRecordCatalogV1.ContractVersion); + _field.Verify(f => f.PreflightAsync(Dept, Me, RmsOriginClient.Responder, "5.4.1", null), Times.Once); + } + + [Test] + public async Task A_non_field_origin_is_normalized_to_System_so_every_field_gate_refuses_it() + { + _field.Setup(f => f.PreflightAsync(Dept, Me, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string u, RmsOriginClient o, string v, string c) => new FieldRecordPreflight { Origin = o, Ok = false }); + + await _controller.Preflight((int)RmsOriginClient.Web); + await _controller.Preflight(999); + + _field.Verify(f => f.PreflightAsync(Dept, Me, RmsOriginClient.System, It.IsAny(), It.IsAny()), Times.Exactly(2), "a claimed Web or unknown origin never becomes a field origin"); + } + + [Test] + public async Task Catalog_passes_the_verified_context_through_and_returns_coded_exclusions() + { + _field.Setup(f => f.GetCatalogAsync(Dept, Me, It.IsAny())) + .ReturnsAsync((int d, string u, FieldRecordCatalogRequest r) => new FieldRecordCatalog + { + Origin = r.Origin, Ok = true, ContextKind = r.Context.Kind, ContextVerified = true, ScopeStamp = "scope-1", + Definitions = { new FieldRecordCatalogEntry { DefinitionKey = "shift-log", Version = 3, Name = "Shift log" } }, + Exclusions = { new FieldRecordCatalogExclusion { DefinitionKey = "casualty", Reason = FieldRecordCatalogV1.ExclusionReasons.ProtectedDataUnavailable } } + }); + + var response = await _controller.Catalog(new FieldRecordCatalogInput + { + OriginClient = (int)RmsOriginClient.Unit, AppVersion = "5.4.0", ClientCapability = RecordsClientCapabilities.Packs, + Context = new FieldRecordContextInput { UnitId = 7 } + }); + + var data = ((response.Result as OkObjectResult)?.Value as FieldRecordCatalogResult)?.Data; + data.ContextKind.Should().Be(FieldRecordCatalogV1.LaunchContexts.Unit); + data.Definitions.Should().ContainSingle(d => d.DefinitionKey == "shift-log"); + data.Exclusions.Should().ContainSingle(e => e.Reason == FieldRecordCatalogV1.ExclusionReasons.ProtectedDataUnavailable); + _field.Verify(f => f.GetCatalogAsync(Dept, Me, It.Is(r => r.Origin == RmsOriginClient.Unit && r.Context.UnitId == 7 && r.AppVersion == "5.4.0")), Times.Once); + } + + [Test] + public async Task Prefill_outside_the_callers_catalog_is_forbidden_not_answered() + { + _field.Setup(f => f.PrefillAsync(Dept, Me, It.IsAny(), "shift-log", 3)).ThrowsAsync(new UnauthorizedAccessException()); + + var refused = await _controller.Prefill(new FieldRecordPrefillInput { OriginClient = (int)RmsOriginClient.Responder, DefinitionKey = "shift-log", Version = 3 }); + refused.Result.Should().BeOfType(); + + _field.Setup(f => f.PrefillAsync(Dept, Me, It.IsAny(), "run-sheet", 2)).ReturnsAsync(new FieldRecordPrefill + { + DefinitionKey = "run-sheet", Version = 2, CallId = 501, + Values = { new RecordValueInput { SectionKey = "main", FieldKey = "related_call", Value = "501", ReferenceType = "call", ReferenceId = "501" } }, + Provenance = { new FieldRecordPrefillProvenance { FieldKey = "related_call", Source = "call", SourceId = "501", CapturedOn = DateTime.UtcNow } } + }); + + var response = await _controller.Prefill(new FieldRecordPrefillInput { OriginClient = (int)RmsOriginClient.Responder, DefinitionKey = "run-sheet", Version = 2, Context = new FieldRecordContextInput { CallId = 501 } }); + var data = ((response.Result as OkObjectResult)?.Value as FieldRecordPrefillResult)?.Data; + data.Values.Should().ContainSingle(v => v.FieldKey == "related_call" && v.Value == "501"); + data.Provenance.Should().ContainSingle(p => p.Source == "call"); + } + + [Test] + public async Task Sync_maps_records_tombstones_drafts_and_assignments_and_rejects_a_bad_cursor() + { + (await _controller.Sync(new FieldRecordSyncInput { Since = -1 }, CancellationToken.None)).Result.Should().BeOfType(); + + _field.Setup(f => f.SyncAsync(Dept, Me, It.IsAny(), It.IsAny())).ReturnsAsync(new FieldRecordSyncBundle + { + Ok = true, ScopeStamp = "scope-1", ServerTimestampMs = 1234, + Changes = { new RmsRecordSearchProjection { RmsRecordSearchProjectionId = "r1", DepartmentId = Dept, State = (int)RmsRecordState.Finalized } }, + Tombstones = { "r2" }, + Drafts = { new RmsRecordSearchProjection { RmsRecordSearchProjectionId = "r3", DepartmentId = Dept, State = (int)RmsRecordState.Draft } }, + Assignments = { new RmsRecordWorkAssignment { RmsRecordWorkAssignmentId = "a1", RecordId = "r1", AssigneeKind = (int)RmsWorkAssigneeKind.Person, AssigneeUserId = Me, State = (int)RmsWorkAssignmentState.Open, Purpose = RmsWorkAssignmentPurposes.Complete } } + }); + + var response = await _controller.Sync(new FieldRecordSyncInput { OriginClient = (int)RmsOriginClient.Responder, Since = 0 }, CancellationToken.None); + + var data = ((response.Result as OkObjectResult)?.Value as FieldRecordSyncResult)?.Data; + data.Records.Select(r => r.RecordId).Should().Equal(new[] { "r1" }); + data.Tombstones.Should().Equal(new[] { "r2" }); + data.Drafts.Should().ContainSingle(); + data.Assignments.Single().State.Should().Be(RmsWorkAssignmentState.Open.ToString()); + data.Assignments.Single().AssigneeKind.Should().Be(RmsWorkAssigneeKind.Person.ToString()); + } + + [Test] + public async Task Assignment_commands_map_service_failures_onto_the_right_status_codes() + { + _assignments.Setup(a => a.AcknowledgeAsync(Dept, Me, "a1", It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new RecordConcurrencyException("r1", 1, 2)); + var conflict = await _controller.AcknowledgeAssignment(new FieldRecordAssignmentCommandInput { AssignmentId = "a1", RowVersion = 1 }, CancellationToken.None); + (conflict.Result as ObjectResult)?.StatusCode.Should().Be(StatusCodes.Status409Conflict); + + _assignments.Setup(a => a.CompleteAsync(Dept, Me, "a1", It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new UnauthorizedAccessException()); + (await _controller.CompleteAssignment(new FieldRecordAssignmentCommandInput { AssignmentId = "a1" }, CancellationToken.None)).Result.Should().BeOfType(); + + _assignments.Setup(a => a.AssignAsync(Dept, Me, It.IsAny(), It.IsAny())).ThrowsAsync(new ArgumentException("A reviewer is required.")); + var invalid = await _controller.Assign(new FieldRecordAssignInput { RecordId = "r1", AssigneeUserId = "x" }, CancellationToken.None); + (invalid.Result as ObjectResult)?.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + + (await _controller.Assign(null, CancellationToken.None)).Result.Should().BeOfType(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/RecordDefinitionsApiControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/RecordDefinitionsApiControllerTests.cs new file mode 100644 index 00000000..edb8b0ac --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/RecordDefinitionsApiControllerTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Web.Services +{ + /// v4 RecordDefinitions: flag gating, the aggregate/version envelopes with ETags, service exceptions mapped to HTTP codes. + [TestFixture, NonParallelizable] + public class RecordDefinitionsApiControllerTests + { + private const int Dept = 42; + private const string Me = "admin"; + private Mock _definitions; + private Mock _templates; + private Mock _cutover; + private RecordDefinitionsController _controller; + private DefaultHttpContext _http; + private System.Diagnostics.Activity _activity; + + [SetUp] + public void SetUp() + { + _definitions = new Mock(); + _templates = new Mock(); + _cutover = new Mock(); + _cutover.Setup(c => c.GetModuleStateAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsModuleState { DepartmentId = Dept, FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active, LegacyWritesBlocked = true }); + _http = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, Me), new Claim(ClaimTypes.PrimaryGroupSid, Dept.ToString()), + new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View) + }, "test")) + }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = _http }; + _activity = new System.Diagnostics.Activity(nameof(RecordDefinitionsApiControllerTests)).Start(); + _controller = new RecordDefinitionsController(_definitions.Object, _templates.Object, _cutover.Object, Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = _http } }; + } + + [TearDown] public void Cleanup() => _activity?.Stop(); + + private static RecordDefinitionAggregate Aggregate() => new RecordDefinitionAggregate + { + Definition = new RmsRecordDefinition { RmsRecordDefinitionId = "def-1", DepartmentId = Dept, DefinitionKey = "shift-log", Name = "Shift log", Owner = (int)RmsDefinitionOwner.Department, CurrentPublishedVersion = 1, LatestVersion = 2, RowVersion = 3 }, + Versions = new List + { + new RmsRecordDefinitionVersion { RmsRecordDefinitionVersionId = "v1", DepartmentId = Dept, DefinitionKey = "shift-log", Version = 1, State = (int)RmsDefinitionVersionState.Published, SchemaJson = RecordDefinitionSchema.Serialize(new RecordDefinitionSchema()), RowVersion = 2, MinimumClientCapability = RecordsClientCapabilities.Configurable }, + new RmsRecordDefinitionVersion { RmsRecordDefinitionVersionId = "v2", DepartmentId = Dept, DefinitionKey = "shift-log", Version = 2, State = (int)RmsDefinitionVersionState.Draft, SchemaJson = RecordDefinitionSchema.Serialize(new RecordDefinitionSchema()), RowVersion = 1 } + } + }; + + [Test] + public async Task Reads_are_gated_by_the_records_flag_and_carry_the_definition_etag() + { + _definitions.Setup(d => d.GetAsync(Dept, "shift-log")).ReturnsAsync(Aggregate()); + var ok = (await _controller.Get("shift-log")).Result.Should().BeOfType().Subject; + var result = ok.Value.Should().BeOfType().Subject; + result.Data.Key.Should().Be("shift-log"); + result.Data.CurrentPublishedVersion.Should().Be(1); + result.Data.Versions.Should().HaveCount(2); + result.Data.ETag.Should().Be(RecordsApiContract.ToETag(3)); + _http.Response.Headers[RecordsApiContract.ETagHeader].ToString().Should().Be(RecordsApiContract.ToETag(3)); + + (await _controller.Get("missing")).Result.Should().BeOfType(); + _cutover.Setup(c => c.GetModuleStateAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsModuleState { DepartmentId = Dept, FlagEnabled = false }); + (await _controller.List()).Result.Should().BeOfType(); + (await _controller.Get("shift-log")).Result.Should().BeOfType("the surface disappears when the flag is off"); + } + + [Test] + public async Task Create_returns_201_with_the_aggregate_and_maps_service_failures() + { + RecordDefinitionCreateInput captured = null; + _definitions.Setup(d => d.CreateAsync(Dept, Me, It.IsAny(), It.IsAny())) + .Callback((d, u, i, c) => captured = i).ReturnsAsync(Aggregate()); + var created = (await _controller.Create(new CreateRecordDefinitionInput { DefinitionKey = "shift-log", Name = "Shift log", TemplateKey = "template.shift-summary", JurisdictionProfileKey = "ca", Locale = "fr-CA" }, CancellationToken.None)).Result + .Should().BeOfType().Subject; + created.StatusCode.Should().Be(StatusCodes.Status201Created); + captured.TemplateKey.Should().Be("template.shift-summary"); captured.JurisdictionProfileKey.Should().Be("ca"); captured.Locale.Should().Be("fr-CA"); + created.Value.Should().BeOfType().Which.Data.Key.Should().Be("shift-log"); + + (await _controller.Create(null, CancellationToken.None)).Result.Should().BeOfType(); + _definitions.Setup(d => d.CreateAsync(Dept, Me, It.IsAny(), It.IsAny())).ThrowsAsync(new ArgumentException("A definition with key 'shift-log' already exists.")); + var conflict = (await _controller.Create(new CreateRecordDefinitionInput { DefinitionKey = "shift-log", Name = "x" }, CancellationToken.None)).Result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + _definitions.Setup(d => d.CreateAsync(Dept, Me, It.IsAny(), It.IsAny())).ThrowsAsync(new UnauthorizedAccessException("no")); + (await _controller.Create(new CreateRecordDefinitionInput { DefinitionKey = "shift-log", Name = "x" }, CancellationToken.None)).Result.Should().BeOfType(); + } + + [Test] + public async Task Publish_uses_the_if_match_header_and_reports_concurrency_as_409() + { + _http.Request.Headers[RecordsApiContract.IfMatchHeader] = RecordsApiContract.ToETag(1); + _definitions.Setup(d => d.PublishAsync(Dept, Me, "shift-log", 2, 1, It.IsAny())).ReturnsAsync(Aggregate().Versions[1]); + var ok = (await _controller.Publish("shift-log", 2, null, CancellationToken.None)).Result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType().Which.Data.Version.Should().Be(2); + + _definitions.Setup(d => d.PublishAsync(Dept, Me, "shift-log", 2, 1, It.IsAny())).ThrowsAsync(new RecordConcurrencyException("v2", 1, 4)); + var stale = (await _controller.Publish("shift-log", 2, null, CancellationToken.None)).Result.Should().BeOfType().Subject; + stale.StatusCode.Should().Be(StatusCodes.Status409Conflict); + _definitions.Setup(d => d.PublishAsync(Dept, Me, "shift-log", 2, 1, It.IsAny())).ThrowsAsync(new InvalidOperationException("Only a draft version can be published.")); + (await _controller.Publish("shift-log", 2, null, CancellationToken.None)).Result.Should().BeOfType().Which.StatusCode.Should().Be(StatusCodes.Status409Conflict); + } + + [Test] + public async Task Templates_and_profiles_come_from_the_catalog_service() + { + _templates.Setup(t => t.GetCatalogAsync()).ReturnsAsync(new List { new RecordTemplatePackSummary { PackKey = "pack.sar", Name = "SAR" } }); + var ok = (await _controller.Templates()).Result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType().Which.Data.Should().ContainSingle(p => p.PackKey == "pack.sar"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/RecordsApiControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/RecordsApiControllerTests.cs index 7ae0183f..664618af 100644 --- a/Tests/Resgrid.Tests/Web/Services/RecordsApiControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/RecordsApiControllerTests.cs @@ -93,7 +93,7 @@ public void SetUp() ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = _http }; _activity = new Activity("RecordsApiControllerTests").Start(); - _controller = new RecordsController(_records.Object, _cutover.Object, _authorization.Object, _flags.Object, _settings.Object, _adp.Object, _search.Object, _uploads.Object, _idempotency.Object, _dashboard.Object) + _controller = new RecordsController(_records.Object, _cutover.Object, _authorization.Object, _flags.Object, _settings.Object, _adp.Object, _search.Object, _uploads.Object, _idempotency.Object, _dashboard.Object, Mock.Of(), Mock.Of(), Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = _http } }; diff --git a/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs b/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs index 52d75e75..2b481c05 100644 --- a/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs +++ b/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs @@ -41,6 +41,18 @@ public async Task> Encrypt([FromBody] Br return StatusCode(MapStatusCode(result), result); } + /// + /// Purpose-bound workload decrypt (RMS plan section 5.9.4): no grant, the workload key plus an allow-listed + /// purpose for an actively protected department. Refusals answer 403 workload_purpose_denied. + /// + [HttpPost("workload/decrypt")] + public async Task> DecryptForWorkload([FromQuery] string purpose, + [FromBody] BrokerFieldOperationRequest request, CancellationToken cancellationToken) + { + var result = await _operationService.DecryptForWorkloadAsync(request, purpose, cancellationToken); + return StatusCode(MapStatusCode(result), result); + } + private static int MapStatusCode(ProtectedDataBrokerResult result) { if (result.Success) @@ -58,6 +70,7 @@ private static int MapStatusCode(ProtectedDataBrokerResult result) case "grant_invalid": return StatusCodes.Status401Unauthorized; case "grant_revoked": + case "workload_purpose_denied": return StatusCodes.Status403Forbidden; case "no_active_key": return StatusCodes.Status409Conflict; diff --git a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs index 58ebc497..181902f9 100644 --- a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs +++ b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs @@ -52,13 +52,41 @@ public Task DecryptAsync(BrokerFieldOperationRequest public Task EncryptAsync(BrokerFieldOperationRequest request, CancellationToken cancellationToken) => ProcessAsync(request, decrypt: false, cancellationToken); + /// + /// The purpose-bound workload decrypt lane (RMS plan section 5.9.4, ADP plan 3.4): no grant, the workload key + /// plus a purpose on the broker's allow-list (DataProtectionConfig.BrokerWorkloadPurposes) for a department + /// that is actively protected. The application enforces the department's per-purpose acknowledgement before it + /// calls; the broker records the purpose on every use and refuses anything it was not configured for. + /// + public Task DecryptForWorkloadAsync(BrokerFieldOperationRequest request, string purpose, + CancellationToken cancellationToken) => + ProcessAsync(request, decrypt: true, cancellationToken, workloadPurpose: NormalizePurpose(purpose)); + + private static string NormalizePurpose(string purpose) => + string.IsNullOrWhiteSpace(purpose) ? string.Empty : purpose.Trim().ToLowerInvariant(); + + /// True when the purpose is on the configured allow-list; an empty list or purpose allows nothing. + public static bool IsAllowedWorkloadPurpose(string purpose) + { + if (string.IsNullOrEmpty(purpose)) + return false; + return (Config.DataProtectionConfig.BrokerWorkloadPurposes ?? string.Empty) + .Split(',') + .Select(p => p.Trim().ToLowerInvariant()) + .Any(p => p.Length > 0 && p == purpose); + } + private async Task ProcessAsync(BrokerFieldOperationRequest request, bool decrypt, - CancellationToken cancellationToken) + CancellationToken cancellationToken, string workloadPurpose = null) { if (request == null || request.DepartmentId <= 0 || string.IsNullOrWhiteSpace(request.RequestId) || request.Items == null || request.Items.Count == 0) return Fail("invalid_request"); + // Workload lane: the purpose gate runs before anything is consumed (no request id burned, no key touched). + if (workloadPurpose != null && !IsAllowedWorkloadPurpose(workloadPurpose)) + return Fail("workload_purpose_denied"); + var maxItems = Math.Max(1, Config.DataProtectionConfig.BrokerMaxItemsPerRequest); if (request.Items.Count > maxItems) return Fail("too_many_items"); @@ -76,6 +104,12 @@ private async Task ProcessAsync(BrokerFieldOperationR var policy = await policyRepository.GetByDepartmentIdAsync(request.DepartmentId); var currentEpoch = policy?.PolicyEpoch ?? 0; + // A workload purpose only ever opens data of a department that is actively protected; an unenrolled, + // enrolling or offboarding department has no acknowledged egress to honor. + if (workloadPurpose != null && (policy == null || + policy.State != (int)DepartmentDataProtectionState.Enabled && policy.State != (int)DepartmentDataProtectionState.Rotating)) + return Fail("workload_purpose_denied"); + // DECRYPT always requires a valid attended grant. ENCRYPT has a workload lane (plan 3.4 // "required protected submissions"): a request WITHOUT a grant — already past the // workload-key middleware — may encrypt, because encryption discloses nothing; system @@ -83,7 +117,7 @@ private async Task ProcessAsync(BrokerFieldOperationR // grant that IS presented is still fully validated, so a stolen/stale token cannot be // laundered through the encrypt path either. ProtectedDataGrant grant = null; - if (decrypt || !string.IsNullOrWhiteSpace(request.GrantToken)) + if (decrypt && workloadPurpose == null || !string.IsNullOrWhiteSpace(request.GrantToken)) { var requiredScope = decrypt ? ProtectedDataGrantScopes.Read : ProtectedDataGrantScopes.Write; var outcome = _grantService.ValidateGrant(request.GrantToken, request.DepartmentId, currentEpoch, @@ -113,7 +147,7 @@ private async Task ProcessAsync(BrokerFieldOperationR CryptographicOperations.ZeroMemory(dek); } - Audit(decrypt ? "decrypt" : "encrypt", request, grant, result); + Audit(workloadPurpose != null ? "workload-decrypt" : decrypt ? "decrypt" : "encrypt", request, grant, result, workloadPurpose); return result; } @@ -348,11 +382,11 @@ private static ProtectedDataBrokerResult Fail(string errorCode) => /// Value-free audit line: identifiers and counts only, never field values. private static void Audit(string operation, BrokerFieldOperationRequest request, ProtectedDataGrant grant, - ProtectedDataBrokerResult result) + ProtectedDataBrokerResult result, string workloadPurpose = null) { var failed = result.Items.Count(i => i.ErrorCode != null); var fields = string.Join(",", request.Items.Where(i => i?.FieldId != null).Select(i => i.FieldId).Distinct()); - var identity = grant == null ? "workload" : $"user {grant.UserId}, grant {grant.GrantId}"; + var identity = grant == null ? (workloadPurpose == null ? "workload" : $"workload purpose {workloadPurpose}") : $"user {grant.UserId}, grant {grant.GrantId}"; Logging.LogInfo($"ADP broker {operation}: department {request.DepartmentId}, {identity}, request {request.RequestId}, items {result.Items.Count}, failed {failed}, fields [{fields}]"); } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs new file mode 100644 index 00000000..eed04a93 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Mime; +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.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Field Records for the Responder, Unit, Incident Command and Dispatch apps (RMS plan RMS-1D): minimum-version + /// preflight, the FieldRecordCatalogV1 manifest, server-calculated prefill with provenance, the bounded sync + /// bundle, and work assignments. Every filter is derived server-side from the authenticated principal, the + /// department, the app flag and the verified context; a forged origin, context or capability value can only + /// narrow the response. Authoring itself stays on the Records controller. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class FieldRecordsController : V4AuthenticatedApiControllerbase + { + private readonly IFieldRecordsService _field; + private readonly IRecordWorkAssignmentsService _assignments; + + public FieldRecordsController(IFieldRecordsService field, IRecordWorkAssignmentsService assignments) + { + _field = field; + _assignments = assignments; + } + + #region Preflight and catalog + + /// Whether this app, at this version, may show Records at all in this department. + [HttpGet("Preflight")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Preflight(int? originClient = null, string appVersion = null, string clientCapability = null) + { + var origin = Origin(originClient); + var preflight = await _field.PreflightAsync(DepartmentId, UserId, origin, AppVersion(appVersion), clientCapability); + var result = new FieldRecordPreflightResult + { + Data = new FieldRecordPreflightData + { + ContractVersion = preflight.ContractVersion, SyncContractVersion = preflight.SyncContractVersion, OriginClient = preflight.Origin.ToString(), Ok = preflight.Ok, + Reasons = preflight.Reasons, ModuleEnabled = preflight.ModuleEnabled, RecordsUsable = preflight.RecordsUsable, AppEnabled = preflight.AppEnabled, + MinimumAppVersion = preflight.MinimumAppVersion, AppVersion = preflight.AppVersion, ClientCapability = preflight.ClientCapability, + ProtectionState = preflight.ProtectionState, ServerTimestampMs = preflight.ServerTimestampMs + }, + Status = ResponseHelper.Success, PageSize = 1 + }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// The definitions this app may start right now, plus a coded reason for each one withheld. + [HttpPost("Catalog")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Catalog([FromBody] FieldRecordCatalogInput input) + { + var catalog = await _field.GetCatalogAsync(DepartmentId, UserId, ToRequest(input)); + var result = new FieldRecordCatalogResult { Data = ToData(catalog), Status = ResponseHelper.Success, PageSize = catalog.Definitions.Count }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// Server-calculated prefill for one catalog entry; refused when that entry is not in this client's catalog. + [HttpPost("Prefill")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Prefill([FromBody] FieldRecordPrefillInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.DefinitionKey)) return BadRequest(); + FieldRecordPrefill prefill; + try + { + prefill = await _field.PrefillAsync(DepartmentId, UserId, ToRequest(input), input.DefinitionKey, input.Version); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + + var result = new FieldRecordPrefillResult + { + Data = new FieldRecordPrefillData + { + ContractVersion = prefill.ContractVersion, DefinitionKey = prefill.DefinitionKey, Version = prefill.Version, PrefillVersion = prefill.PrefillVersion, + CallId = prefill.CallId, UnitId = prefill.UnitId, StationGroupId = prefill.StationGroupId, Provenance = prefill.Provenance, + SuggestedParticipantUserIds = prefill.SuggestedParticipantUserIds, SuggestedUnitIds = prefill.SuggestedUnitIds, CalculatedOn = prefill.CalculatedOn, + Values = prefill.Values.Select(v => new FieldRecordPrefillValueData { SectionKey = v.SectionKey, FieldKey = v.FieldKey, Value = v.Value, ReferenceType = v.ReferenceType, ReferenceId = v.ReferenceId }).ToList() + }, + Status = ResponseHelper.Success, PageSize = 1 + }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + #endregion + + #region Sync + + /// + /// The bounded working set: catalog, authorized change delta with tombstones, the caller's own drafts and + /// returned Records, and their open assignments. A scope change answers ResetRequired instead of a page. + /// + [HttpPost("Sync")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Sync([FromBody] FieldRecordSyncInput input, CancellationToken cancellationToken) + { + input ??= new FieldRecordSyncInput(); + if (input.Since < 0 || input.Since > DateTimeOffset.MaxValue.ToUnixTimeMilliseconds()) return BadRequest(); + + var request = new FieldRecordSyncRequest + { + Origin = Origin(input.OriginClient), AppVersion = AppVersion(input.AppVersion), ClientCapability = input.ClientCapability, + Context = input.Context?.ToContext() ?? new FieldRecordContext(), Since = input.Since, SinceId = input.SinceId, ScopeStamp = input.ScopeStamp, + Take = input.Take, IncludeCatalog = input.IncludeCatalog + }; + var bundle = await _field.SyncAsync(DepartmentId, UserId, request, cancellationToken); + var result = new FieldRecordSyncResult + { + Data = new FieldRecordSyncData + { + ContractVersion = bundle.ContractVersion, Ok = bundle.Ok, Reasons = bundle.Reasons, ScopeStamp = bundle.ScopeStamp, ResetRequired = bundle.ResetRequired, + Since = bundle.Since, ServerTimestampMs = bundle.ServerTimestampMs, ServerCursorId = bundle.ServerCursorId, HasMore = bundle.HasMore, + Catalog = bundle.Catalog == null ? null : ToData(bundle.Catalog), Tombstones = bundle.Tombstones, + Records = bundle.Changes.Select(RecordsApiMapper.ToSummary).ToList(), + Drafts = bundle.Drafts.Select(RecordsApiMapper.ToSummary).ToList(), + Assignments = bundle.Assignments.Select(ToAssignment).ToList() + }, + Status = ResponseHelper.Success, PageSize = bundle.Changes.Count + }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + #endregion + + #region Work assignments + + /// The caller's open work queue, narrowed by assignment and re-authorized per Record. + [HttpPost("Assignments")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Assignments([FromBody] FieldRecordCatalogInput input) + { + var rows = await _assignments.GetQueueAsync(DepartmentId, UserId, input?.Context?.ToContext(), 0); + var result = new FieldRecordAssignmentsResult { Data = rows.Select(ToAssignment).ToList(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// Assignments on one Record; empty when the caller cannot read that Record. + [HttpGet("RecordAssignments")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> RecordAssignments(string recordId) + { + if (string.IsNullOrWhiteSpace(recordId)) return BadRequest(); + var rows = await _assignments.GetForRecordAsync(DepartmentId, UserId, recordId); + var result = new FieldRecordAssignmentsResult { Data = rows.Select(ToAssignment).ToList(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + [HttpPost("Assign")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Review)] + public async Task> Assign([FromBody] FieldRecordAssignInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.RecordId)) return BadRequest(); + return await CommandAsync(() => _assignments.AssignAsync(DepartmentId, UserId, new RecordWorkAssignmentInput + { + RecordId = input.RecordId, AssigneeKind = (RmsWorkAssigneeKind)input.AssigneeKind, AssigneeUserId = input.AssigneeUserId, AssigneeUnitId = input.AssigneeUnitId, + AssigneeGroupId = input.AssigneeGroupId, AssigneeRole = input.AssigneeRole, Purpose = input.Purpose, Note = input.Note, DueOn = input.DueOn, + SourceContext = input.Context?.ToContext(), OriginClient = Origin(input.OriginClient) + }, cancellationToken)); + } + + [HttpPost("AcknowledgeAssignment")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> AcknowledgeAssignment([FromBody] FieldRecordAssignmentCommandInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.AssignmentId)) return BadRequest(); + return await CommandAsync(() => _assignments.AcknowledgeAsync(DepartmentId, UserId, input.AssignmentId, input.RowVersion, input.Context?.ToContext(), Origin(input.OriginClient), cancellationToken)); + } + + [HttpPost("CompleteAssignment")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> CompleteAssignment([FromBody] FieldRecordAssignmentCommandInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.AssignmentId)) return BadRequest(); + return await CommandAsync(() => _assignments.CompleteAsync(DepartmentId, UserId, input.AssignmentId, input.RowVersion, input.Context?.ToContext(), Origin(input.OriginClient), cancellationToken)); + } + + [HttpPost("CancelAssignment")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Review)] + public async Task> CancelAssignment([FromBody] FieldRecordAssignmentCommandInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.AssignmentId)) return BadRequest(); + return await CommandAsync(() => _assignments.CancelAsync(DepartmentId, UserId, input.AssignmentId, input.RowVersion, input.Reason, Origin(input.OriginClient), cancellationToken)); + } + + #endregion + + #region Helpers + + private async Task> CommandAsync(Func> action) + { + try + { + var row = await action(); + var result = new FieldRecordAssignmentResult { Data = ToAssignment(row), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (RecordConcurrencyException ex) { return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message, type: "assignment_conflict"); } + catch (RecordTransitionException ex) { return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message, type: "record_transition"); } + catch (InvalidOperationException ex) { return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message, type: "assignment_state"); } + catch (ArgumentException ex) { return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "assignment_validation"); } + } + + private FieldRecordCatalogRequest ToRequest(FieldRecordCatalogInput input) => new FieldRecordCatalogRequest + { + Origin = Origin(input?.OriginClient), + AppVersion = AppVersion(input?.AppVersion), + ClientCapability = input?.ClientCapability, + Context = input?.Context?.ToContext() ?? new FieldRecordContext() + }; + + private static FieldRecordCatalogData ToData(FieldRecordCatalog catalog) => new FieldRecordCatalogData + { + ContractVersion = catalog.ContractVersion, OriginClient = catalog.Origin.ToString(), Ok = catalog.Ok, Reasons = catalog.Reasons, ContextKind = catalog.ContextKind, + ContextVerified = catalog.ContextVerified, ProtectionState = catalog.ProtectionState, ScopeStamp = catalog.ScopeStamp, Definitions = catalog.Definitions, + Exclusions = catalog.Exclusions, ServerTimestampMs = catalog.ServerTimestampMs + }; + + private static FieldRecordAssignmentData ToAssignment(RmsRecordWorkAssignment a) => new FieldRecordAssignmentData + { + AssignmentId = a.RmsRecordWorkAssignmentId, RecordId = a.RecordId, AssigneeKind = ((RmsWorkAssigneeKind)a.AssigneeKind).ToString(), AssigneeUserId = a.AssigneeUserId, + AssigneeUnitId = a.AssigneeUnitId, AssigneeGroupId = a.AssigneeGroupId, AssigneeRole = a.AssigneeRole, Purpose = a.Purpose, Note = a.Note, DueOn = a.DueOn, + State = ((RmsWorkAssignmentState)a.State).ToString(), AcknowledgedOn = a.AcknowledgedOn, CompletedOn = a.CompletedOn, OriginClient = ((RmsOriginClient)a.OriginClient).ToString(), + CreatedOn = a.CreatedOn, CreatedByUserId = a.CreatedByUserId, RowVersion = a.RowVersion + }; + + /// The claimed origin, normalized: a non-field value is System, which every field gate refuses. + private static RmsOriginClient Origin(int? value) + { + var origin = RecordsApiHelper.ResolveOrigin(value); + return FieldRecordCatalogV1.IsFieldOrigin(origin) ? origin : RmsOriginClient.System; + } + + /// The body value when present, otherwise the standard app header. + private string AppVersion(string bodyValue) + { + if (!string.IsNullOrWhiteSpace(bodyValue)) return bodyValue.Trim(); + var header = Request?.Headers["X-Resgrid-App-Version"].ToString(); + return string.IsNullOrWhiteSpace(header) ? null : header.Trim(); + } + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs index e67b536b..1bc3973a 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs @@ -41,14 +41,16 @@ public class IncidentReportsController : V4AuthenticatedApiControllerbase, IActi private readonly IIncidentAnalysisService _analysis; private readonly IRecordsSubmissionService _submissionWorker; private readonly IRecordsNfirsLegacyService _nfirs; + private readonly IRecordsRevealService _reveal; private SystemPrincipalRecordGrant _systemGrant; private bool _systemGrantResolved; public IncidentReportsController(IIncidentReportsService incidentReports, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, INerisProfileService neris, IFeatureToggleService featureToggleService, IRecordsApiIdempotencyService idempotency, IIncidentAnalysisService analysis, IRecordsSubmissionService submissionWorker, - IRecordsNfirsLegacyService nfirs) + IRecordsNfirsLegacyService nfirs, IRecordsRevealService reveal) { + _reveal = reveal; _nfirs = nfirs; _incidentReports = incidentReports; _cutoverService = cutoverService; @@ -217,6 +219,24 @@ public async Task> GetIncidentReport(string i return Ok(await WrapAsync(aggregate)); } + /// Protected reveal of an incident report (RMS plan section 5.9.3): the grant travels in X-Resgrid-Protected-Grant. + [HttpPost("Reveal")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Reveal([FromBody] RecordRevealInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.Id)) return BadRequest(); + if (!await FlagOnAsync()) return NotFound(); + var aggregate = await LoadAuthorizedAsync(input.Id); + if (aggregate == null) return NotFound(); + var outcome = await _reveal.RevealIncidentAsync(DepartmentId, UserId, aggregate, await CanViewRestrictedAsync(), IpAddressHelper.GetRequestIP(Request, true)); + var result = new RecordRevealApiResult { Data = new RecordRevealData { Success = outcome.Success, Error = outcome.Error, Fields = outcome.Fields }, Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + /// The authoritative report for a Call, if one exists. [HttpGet("GetForCall")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -265,6 +285,12 @@ public async Task> GetNfirsLegacy(int callId) if (rendering == null) return NotFound(); + // The rendering reads the department's own report to report crosswalk coverage, so the read belongs in + // the access audit alongside every other report read on this controller. + if (!string.IsNullOrWhiteSpace(rendering.IncidentReportId)) + await _incidentReports.RecordAccessAsync(DepartmentId, UserId, rendering.IncidentReportId, null, RmsAccessAuditAction.Read, + AccessPurpose("NFIRS legacy rendering"), IpAddressHelper.GetRequestIP(Request, true)); + var result = new NfirsLegacyResult { Data = rendering, PageSize = 1, Status = ResponseHelper.Success }; ResponseHelper.PopulateV4ResponseData(result); return Ok(result); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordDefinitionsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordDefinitionsController.cs new file mode 100644 index 00000000..a752f98b --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordDefinitionsController.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Mime; +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.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Definition management and runtime catalog over v4 (RMS plan section 5.4, RMS-1B): template browse, clone/new + /// draft, ETag-guarded draft saves, validation, impact preview, publish, retire, history, safe diff and draft + /// migration. Reads need Record_View; authoring needs RecordDefinition_Update; publish/retire RecordDefinition_Publish. + /// Locked system definitions are listed but never editable here. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class RecordDefinitionsController : V4AuthenticatedApiControllerbase + { + private readonly IRecordDefinitionsService _definitions; + private readonly IRecordTemplatePacksService _templates; + private readonly IRecordsCutoverService _cutoverService; + private readonly IRecordsPrintLayoutService _printLayouts; + + public RecordDefinitionsController(IRecordDefinitionsService definitions, IRecordTemplatePacksService templates, IRecordsCutoverService cutoverService, IRecordsPrintLayoutService printLayouts) + { + _printLayouts = printLayouts; + _definitions = definitions; + _templates = templates; + _cutoverService = cutoverService; + } + + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> List(bool includeRetired = false) + { + if (!await FlagOnAsync()) return NotFound(); + var result = new RecordDefinitionsResult { Data = await _definitions.ListAsync(DepartmentId, includeRetired), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// Published department definitions with their schemas: what a client offers on New Record and renders against. + [HttpGet("Published")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Published() + { + if (!await FlagOnAsync()) return NotFound(); + var result = new RecordDefinitionVersionsResult { Data = (await _definitions.GetPublishedAsync(DepartmentId)).Select(RecordsRms1bApiMapper.ToVersion).ToList(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + [HttpGet("Get")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Get(string key) + { + if (!await FlagOnAsync()) return NotFound(); + var aggregate = await _definitions.GetAsync(DepartmentId, key); + if (aggregate == null) return NotFound(); + return Ok(Wrap(aggregate)); + } + + [HttpGet("Version")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Version(string key, int version) + { + if (!await FlagOnAsync()) return NotFound(); + var row = await _definitions.GetVersionAsync(DepartmentId, key, version); + if (row == null) return NotFound(); + return Ok(Wrap(row)); + } + + [HttpGet("Templates")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Templates() + { + if (!await FlagOnAsync()) return NotFound(); + var result = new RecordTemplatePacksResult { Data = await _templates.GetCatalogAsync(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + [HttpGet("Profiles")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Profiles() + { + if (!await FlagOnAsync()) return NotFound(); + var result = new RecordJurisdictionProfilesResult { Data = await _templates.GetProfilesAsync(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// A product template rendered for a profile and locale: what a clone would start from, with its provenance statement. + [HttpGet("Template")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Template(string key, string profile = "generic", string locale = null) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var rendering = await _templates.RenderAsync(key, profile, locale); + if (rendering == null) return NotFound(); + var result = new RecordTemplateRenderingResult { Data = RecordsRms1bApiMapper.ToRendering(rendering), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (ArgumentException ex) { return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_definition_validation"); } + } + + [HttpPost("Create")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.RecordDefinition_Update)] + public async Task> Create([FromBody] CreateRecordDefinitionInput input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + try + { + var aggregate = await _definitions.CreateAsync(DepartmentId, UserId, new RecordDefinitionCreateInput + { + DefinitionKey = input.DefinitionKey, Name = input.Name, Category = input.Category, TemplateKey = input.TemplateKey, CloneFromDefinitionKey = input.CloneFromDefinitionKey, JurisdictionProfileKey = input.JurisdictionProfileKey, Locale = input.Locale + }, cancellationToken); + return StatusCode(StatusCodes.Status201Created, Wrap(aggregate, ResponseHelper.Created)); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("OpenDraft")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.RecordDefinition_Update)] + public async Task> OpenDraft(string key, CancellationToken cancellationToken) + { + var usable = await UsableAsync(); + if (usable != null) return usable; + try { return Ok(Wrap(await _definitions.OpenDraftAsync(DepartmentId, UserId, key, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + /// ETag-guarded save of a draft version (send the version's RowVersion). A published version refuses edits. + [HttpPost("SaveDraft")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [Authorize(Policy = ResgridResources.RecordDefinition_Update)] + public async Task> SaveDraft(string key, int version, [FromBody] SaveRecordDefinitionDraftInput input, CancellationToken cancellationToken) + { + if (input?.Draft == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + var rowVersion = RecordsApiContract.ParseETag(Request.Headers[RecordsApiContract.IfMatchHeader]) ?? input.RowVersion; + try { return Ok(Wrap(await _definitions.SaveDraftAsync(DepartmentId, UserId, key, version, rowVersion, input.Draft, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Validate")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.RecordDefinition_Update)] + public async Task> Validate([FromBody] RecordDefinitionDraftInput input) + { + if (input == null) return BadRequest(); + if (!await FlagOnAsync()) return NotFound(); + var result = new RecordDefinitionValidationResult { Data = await _definitions.ValidateAsync(DepartmentId, input), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + [HttpGet("Impact")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.RecordDefinition_Update)] + public async Task> Impact(string key, int version) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var result = new RecordDefinitionImpactResult { Data = await _definitions.ImpactPreviewAsync(DepartmentId, key, version), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Publish")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [Authorize(Policy = ResgridResources.RecordDefinition_Publish)] + public async Task> Publish(string key, int version, [FromBody] PublishRecordDefinitionInput input, CancellationToken cancellationToken) + { + var usable = await UsableAsync(); + if (usable != null) return usable; + var rowVersion = RecordsApiContract.ParseETag(Request.Headers[RecordsApiContract.IfMatchHeader]) ?? input?.RowVersion ?? 0; + try { return Ok(Wrap(await _definitions.PublishAsync(DepartmentId, UserId, key, version, rowVersion, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Retire")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.RecordDefinition_Publish)] + public async Task> Retire(string key, [FromBody] RetireRecordDefinitionInput input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + var rowVersion = RecordsApiContract.ParseETag(Request.Headers[RecordsApiContract.IfMatchHeader]) ?? input.RowVersion; + try + { + await _definitions.RetireAsync(DepartmentId, UserId, key, rowVersion, input.Reason, cancellationToken); + return Ok(Wrap(await _definitions.GetAsync(DepartmentId, key))); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("DeleteDraft")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.RecordDefinition_Update)] + public async Task DeleteDraft(string key, int version, CancellationToken cancellationToken) + { + var usable = await UsableAsync(); + if (usable != null) return usable; + try { return await _definitions.DeleteDraftAsync(DepartmentId, UserId, key, version, cancellationToken) ? NoContent() : NotFound(); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("History")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> History(string key) + { + if (!await FlagOnAsync()) return NotFound(); + var result = new RecordDefinitionVersionsResult { Data = (await _definitions.HistoryAsync(DepartmentId, key)).Select(RecordsRms1bApiMapper.ToVersion).ToList(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + [HttpGet("Diff")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Diff(string key, int from, int to) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var result = new RecordDefinitionDiffResult { Data = await _definitions.DiffAsync(DepartmentId, key, from, to), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + /// Migrates compatible drafts to a newer version through an explicit mapping (Preview = true only counts). Finalized Records never move. + [HttpPost("MigrateDrafts")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.RecordDefinition_Update)] + public async Task> MigrateDrafts(string key, [FromBody] MigrateRecordDraftsInput input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + try + { + var result = new RecordDefinitionMigrationApiResult { Data = await _definitions.MigrateDraftsAsync(DepartmentId, UserId, key, input.FromVersion, input.ToVersion, input.Mapping, input.Preview, cancellationToken), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + /// + /// The print layout that applies to one definition version (RMS plan section 4.10.1): the definition-scope config + /// when one is saved and applies, plus the branding block it resolves to and the composite layout version that + /// the provenance footer stamps. Locked system definitions resolve to the department default only. + /// + [HttpGet("Layout")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Layout(string key, int? version = null) + { + if (!await FlagOnAsync()) return NotFound(); + var aggregate = await _definitions.GetAsync(DepartmentId, key); + if (aggregate == null) return NotFound(); + var target = version.HasValue ? aggregate.Versions.FirstOrDefault(v => v.Version == version.Value) : aggregate.Published ?? aggregate.Latest ?? aggregate.Draft; + if (target == null) return NotFound(); + var stored = await _printLayouts.GetDefinitionLayoutAsync(DepartmentId, aggregate.Definition.DefinitionKey); + return Ok(WrapLayout(aggregate.Definition.DefinitionKey, target.Version, stored, await _printLayouts.ResolveForDefinitionAsync(DepartmentId, aggregate.Definition.DefinitionKey, target.Version))); + } + + /// Saves the definition-scope print layout; every save is a new layout version the footer names. + [HttpPost("Layout")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.RecordDefinition_Update)] + public async Task> SaveLayout(string key, [FromBody] RecordsDefinitionLayoutConfig input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + var aggregate = await _definitions.GetAsync(DepartmentId, key); + if (aggregate == null || aggregate.Definition.Owner == (int)RmsDefinitionOwner.System) return NotFound(); + try + { + var stored = await _printLayouts.SaveDefinitionLayoutAsync(DepartmentId, UserId, aggregate.Definition.DefinitionKey, input, cancellationToken); + var target = aggregate.Published ?? aggregate.Latest ?? aggregate.Draft; + return Ok(WrapLayout(aggregate.Definition.DefinitionKey, target?.Version ?? 0, stored, await _printLayouts.ResolveForDefinitionAsync(DepartmentId, aggregate.Definition.DefinitionKey, target?.Version ?? 0))); + } + catch (Exception ex) { return Fail(ex); } + } + + private RecordDefinitionLayoutResult WrapLayout(string definitionKey, int definitionVersion, RmsRecordPrintLayout stored, RecordsResolvedPrintLayout resolved) + { + var result = new RecordDefinitionLayoutResult + { + Data = new RecordDefinitionLayoutData + { + DefinitionKey = definitionKey, DefinitionVersion = definitionVersion, + StoredLayoutVersion = stored?.Version > 0 ? stored.LayoutVersion : null, Config = stored?.DefinitionConfig, + AppliesToVersion = resolved.Definition != null, ResolvedLayoutVersion = resolved.LayoutVersion, Branding = resolved.Branding + }, + Status = ResponseHelper.Success, PageSize = 1 + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private RecordDefinitionResult Wrap(RecordDefinitionAggregate aggregate, string status = ResponseHelper.Success) + { + var result = new RecordDefinitionResult { Data = RecordsRms1bApiMapper.ToDefinition(aggregate), Status = status, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + Response.Headers[RecordsApiContract.ETagHeader] = result.Data.ETag; + return result; + } + + private RecordDefinitionVersionResult Wrap(RmsRecordDefinitionVersion version) + { + var result = new RecordDefinitionVersionResult { Data = RecordsRms1bApiMapper.ToVersion(version), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + Response.Headers[RecordsApiContract.ETagHeader] = result.Data.ETag; + return result; + } + + private ActionResult Fail(Exception ex) + { + switch (ex) + { + case UnauthorizedAccessException _: return Forbid(); + case RecordConcurrencyException conflict: return Problem(statusCode: StatusCodes.Status409Conflict, title: conflict.Message, type: "record_definition_conflict"); + case ArgumentException argument: return Problem(statusCode: StatusCodes.Status400BadRequest, title: argument.Message, type: "record_definition_validation"); + case InvalidOperationException invalid: return Problem(statusCode: StatusCodes.Status409Conflict, title: invalid.Message, type: "record_definition_state"); + default: throw ex; + } + } + + private async Task FlagOnAsync() => (await _cutoverService.GetModuleStateAsync(DepartmentId)).FlagEnabled; + + private async Task UsableAsync() + { + var state = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!state.FlagEnabled) return NotFound(); + return state.RecordsUsable ? null : Problem(statusCode: StatusCodes.Status409Conflict, title: "Records is not activated for this department.", type: "records_not_activated"); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentsController.cs new file mode 100644 index 00000000..8544971c --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentsController.cs @@ -0,0 +1,233 @@ +using System; +using System.Linq; +using System.Net.Mime; +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.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Create Deployment from External Order over v4 (RMS plan section 4.1 external-order fill contract, RMS-1C, + /// Preview). Manual entry and artifact snapshots only; no ordering-system connector and no write-back. Creating + /// or changing a deployment needs Record_Create; reading needs Record_View plus visibility of its Record. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class RecordDeploymentsController : V4AuthenticatedApiControllerbase + { + private readonly IRecordDeploymentsService _deployments; + private readonly IRmsExternalOrdersRepository _orders; + private readonly IRecordsCutoverService _cutoverService; + + public RecordDeploymentsController(IRecordDeploymentsService deployments, IRmsExternalOrdersRepository orders, IRecordsCutoverService cutoverService) + { + _deployments = deployments; + _orders = orders; + _cutoverService = cutoverService; + } + + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> List(bool includeClosed = false) + { + if (!await FlagOnAsync()) return NotFound(); + var orders = await _deployments.ListAsync(DepartmentId, UserId, includeClosed); + var result = new RecordDeploymentsResult { Status = ResponseHelper.Success }; + foreach (var order in orders) + { + var aggregate = await _deployments.GetAsync(DepartmentId, UserId, order.RmsExternalOrderId); + if (aggregate != null) result.Data.Add(RecordsRms1bApiMapper.ToDeployment(aggregate)); + } + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + [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 _deployments.GetAsync(DepartmentId, UserId, id); + if (aggregate == null) return NotFound(); + return Ok(Wrap(aggregate)); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("GetForRecord")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> GetForRecord(string recordId) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var aggregate = await _deployments.GetForRecordAsync(DepartmentId, UserId, recordId); + if (aggregate == null) return NotFound(); + return Ok(Wrap(aggregate)); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Create")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Create([FromBody] CreateRecordDeploymentInput input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + try + { + var origin = RecordsApiHelper.ResolveOrigin(null); + var created = RecordsRms1bApiMapper.ToCreateInput(input, origin); + created.IdempotencyKey = RecordsApiHelper.ResolveIdempotencyKey(input.IdempotencyKey, Request); + var aggregate = await _deployments.CreateFromExternalOrderAsync(DepartmentId, UserId, created, cancellationToken); + return StatusCode(StatusCodes.Status201Created, Wrap(aggregate, ResponseHelper.Created)); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("AddFill")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> AddFill(string id, [FromBody] RecordDeploymentFillInput input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + try + { + await _deployments.AddFillAsync(DepartmentId, UserId, id, input, cancellationToken); + return Ok(Wrap(await _deployments.GetAsync(DepartmentId, UserId, id))); + } + catch (Exception ex) { return Fail(ex); } + } + + /// Moves one fill through accept/decline, mobilize, check-in, assign, release, demobilize and return. + [HttpPost("TransitionFill")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> TransitionFill(string fillId, [FromBody] RecordDeploymentFillTransitionInput input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + try + { + var fill = await _deployments.TransitionFillAsync(DepartmentId, UserId, fillId, input, cancellationToken); + return Ok(Wrap(await _deployments.GetAsync(DepartmentId, UserId, fill.RmsExternalOrderId))); + } + catch (Exception ex) { return Fail(ex); } + } + + /// Records a later snapshot of the same external order; the previous artifact stays on record as a superseded reference. + [HttpPost("Snapshot")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Snapshot(string id, [FromBody] RecordDeploymentSnapshotInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.ArtifactBase64)) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + try + { + byte[] artifact; + try { artifact = Convert.FromBase64String(input.ArtifactBase64); } catch (FormatException) { return Problem(statusCode: StatusCodes.Status400BadRequest, title: "ArtifactBase64 is not valid base64.", type: "record_deployment_validation"); } + await _deployments.RecordSourceSnapshotAsync(DepartmentId, UserId, id, input.SourceVersion, artifact, input.ArtifactFileName, input.ArtifactContentType, cancellationToken); + return Ok(Wrap(await _deployments.GetAsync(DepartmentId, UserId, id))); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Closeout")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Closeout(string id, [FromBody] CloseoutRecordDeploymentInput input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + var rowVersion = RecordsApiContract.ParseETag(Request.Headers[RecordsApiContract.IfMatchHeader]) ?? input.RowVersion; + try + { + await _deployments.CloseoutAsync(DepartmentId, UserId, id, rowVersion, input.Notes, cancellationToken); + return Ok(Wrap(await _deployments.GetAsync(DepartmentId, UserId, id))); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Artifact")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task Artifact(string id) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var aggregate = await _deployments.GetAsync(DepartmentId, UserId, id, true); + if (aggregate?.Order?.ArtifactData == null) return NotFound(); + return File(aggregate.Order.ArtifactData, string.IsNullOrWhiteSpace(aggregate.Order.ArtifactContentType) ? "application/octet-stream" : aggregate.Order.ArtifactContentType, aggregate.Order.ArtifactFileName ?? "order-artifact"); + } + catch (Exception ex) { return Fail(ex); } + } + + private RecordDeploymentResult Wrap(RecordDeploymentAggregate aggregate, string status = ResponseHelper.Success) + { + var result = new RecordDeploymentResult { Data = RecordsRms1bApiMapper.ToDeployment(aggregate), Status = status, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + Response.Headers[RecordsApiContract.ETagHeader] = result.Data.ETag; + return result; + } + + private ActionResult Fail(Exception ex) + { + switch (ex) + { + case UnauthorizedAccessException _: return Forbid(); + case RecordConcurrencyException conflict: return Problem(statusCode: StatusCodes.Status409Conflict, title: conflict.Message, type: "record_deployment_conflict"); + case RecordIdempotencyException idempotency: return Problem(statusCode: StatusCodes.Status409Conflict, title: idempotency.Message, type: "record_idempotency_conflict"); + case ArgumentException argument: return Problem(statusCode: StatusCodes.Status400BadRequest, title: argument.Message, type: "record_deployment_validation"); + case InvalidOperationException invalid: return Problem(statusCode: StatusCodes.Status409Conflict, title: invalid.Message, type: "record_deployment_state"); + default: throw ex; + } + } + + private async Task FlagOnAsync() => (await _cutoverService.GetModuleStateAsync(DepartmentId)).FlagEnabled; + + private async Task UsableAsync() + { + var state = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!state.FlagEnabled) return NotFound(); + return state.RecordsUsable ? null : Problem(statusCode: StatusCodes.Status409Conflict, title: "Records is not activated for this department.", type: "records_not_activated"); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordExportTemplatesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordExportTemplatesController.cs new file mode 100644 index 00000000..67adcad3 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordExportTemplatesController.cs @@ -0,0 +1,187 @@ +using System; +using System.Linq; +using System.Net.Mime; +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.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Department report exports over v4 (RMS plan section 5.6, RMS-3e): the templates a Workflow step or the hourly + /// sweep (worker 45) renders for agencies without an API, their runs, and on-demand renders. Authoring needs + /// ManageRecordReports (checked by the service); every render is an Export audit against each record it contains. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Authorize(Policy = ResgridResources.Record_Export)] + public class RecordExportTemplatesController : V4AuthenticatedApiControllerbase + { + private readonly IRecordsExportService _exports; + private readonly IRecordsCutoverService _cutoverService; + private readonly IRecordsAuthorizationService _authorization; + + public RecordExportTemplatesController(IRecordsExportService exports, IRecordsCutoverService cutoverService, IRecordsAuthorizationService authorization) + { + _exports = exports; + _cutoverService = cutoverService; + _authorization = authorization; + } + + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> List() + { + if (!await FlagOnAsync()) return NotFound(); + if (!await CanManageAsync()) return Forbid(); + var result = new RecordExportTemplatesResult { Data = (await _exports.GetTemplatesAsync(DepartmentId)).Select(RecordsRms1bApiMapper.ToTemplate).ToList(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + [HttpGet("Get")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(string id) + { + if (!await FlagOnAsync()) return NotFound(); + if (!await CanManageAsync()) return Forbid(); + var template = await _exports.GetTemplateAsync(DepartmentId, id); + if (template == null) return NotFound(); + return Ok(Wrap(template)); + } + + /// Creates (no TemplateId) or updates (TemplateId + RowVersion / If-Match). Narrative or restricted columns need AcknowledgeEgress. + [HttpPost("Save")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Save([FromBody] SaveRecordExportTemplateInput input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + var template = RecordsRms1bApiMapper.ToTemplate(input); + template.RowVersion = RecordsApiContract.ParseETag(Request.Headers[RecordsApiContract.IfMatchHeader]) ?? input.RowVersion; + try + { + var validation = await _exports.ValidateAsync(DepartmentId, UserId, template); + if (!validation.IsValid) return Problem(statusCode: StatusCodes.Status400BadRequest, title: string.Join(" ", validation.Errors), type: "record_export_validation"); + var saved = await _exports.SaveAsync(DepartmentId, UserId, template, input.AcknowledgeEgress, cancellationToken); + var result = Wrap(saved); + result.Warnings = validation.Warnings.ToList(); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("Delete")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(string id, CancellationToken cancellationToken) + { + var usable = await UsableAsync(); + if (usable != null) return usable; + try { return await _exports.DeleteAsync(DepartmentId, UserId, id, cancellationToken) ? NoContent() : NotFound(); } + catch (Exception ex) { return Fail(ex); } + } + + /// Renders the template now and stores the run; the file comes from Download. TriggeringRecord scope needs RecordId. + [HttpPost("Run")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Run(string id, [FromBody] RunRecordExportInput input, CancellationToken cancellationToken) + { + var usable = await UsableAsync(); + if (usable != null) return usable; + var template = await _exports.GetTemplateAsync(DepartmentId, id); + if (template == null) return NotFound(); + try + { + var request = new RecordsExportRequest { Trigger = RmsExportTrigger.Manual, ActingUserId = UserId, Purpose = "API export " + template.Name, RecordId = input?.RecordId?.Trim(), RecordKind = input?.RecordKind.HasValue == true ? (RmsRecordKind?)input.RecordKind.Value : null, WindowStart = input?.WindowStart, WindowEnd = input?.WindowEnd }; + if ((RmsExportScope)template.Scope == RmsExportScope.TriggeringRecord && string.IsNullOrWhiteSpace(request.RecordId)) + return Problem(statusCode: StatusCodes.Status400BadRequest, title: "A TriggeringRecord template needs the RecordId to export.", type: "record_export_validation"); + var run = await _exports.RenderAsync(DepartmentId, template, request, cancellationToken); + var result = new RecordExportRunResult { Data = RecordsRms1bApiMapper.ToRun(run), Status = ResponseHelper.Created, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Runs")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Runs(string id, int take = 50) + { + if (!await FlagOnAsync()) return NotFound(); + if (!await CanManageAsync()) return Forbid(); + var result = new RecordExportRunsResult { Data = (await _exports.GetRunsAsync(DepartmentId, id, Math.Clamp(take, 1, 200))).Select(RecordsRms1bApiMapper.ToRun).ToList(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// The stored file of a run; under ADP the bytes are opened through the seam for this caller. + [HttpGet("Download")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Download(string runId) + { + if (!await FlagOnAsync()) return NotFound(); + if (!await CanManageAsync()) return Forbid(); + try + { + var run = await _exports.GetRunAsync(DepartmentId, runId, true); + if (run?.Data == null) return NotFound(); + return File(run.Data, string.IsNullOrWhiteSpace(run.ContentType) ? "application/octet-stream" : run.ContentType, run.FileName ?? "export"); + } + catch (Exception ex) { return Fail(ex); } + } + + private RecordExportTemplateResult Wrap(RmsExportTemplate template) + { + var result = new RecordExportTemplateResult { Data = RecordsRms1bApiMapper.ToTemplate(template), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + Response.Headers[RecordsApiContract.ETagHeader] = result.Data.ETag; + return result; + } + + private Task CanManageAsync() => _authorization.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ManageRecordReports); + + private ActionResult Fail(Exception ex) + { + switch (ex) + { + case UnauthorizedAccessException _: return Forbid(); + case RecordProtectedContentException protectedContent: return Problem(statusCode: StatusCodes.Status403Forbidden, title: protectedContent.Message, type: protectedContent.Reason); + case RecordConcurrencyException conflict: return Problem(statusCode: StatusCodes.Status409Conflict, title: conflict.Message, type: "record_export_conflict"); + case ArgumentException argument: return Problem(statusCode: StatusCodes.Status400BadRequest, title: argument.Message, type: "record_export_validation"); + case InvalidOperationException invalid: return Problem(statusCode: StatusCodes.Status409Conflict, title: invalid.Message, type: "record_export_state"); + default: throw ex; + } + } + + private async Task FlagOnAsync() => (await _cutoverService.GetModuleStateAsync(DepartmentId)).FlagEnabled; + + private async Task UsableAsync() + { + var state = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!state.FlagEnabled) return NotFound(); + if (!await CanManageAsync()) return Forbid(); + return state.RecordsUsable ? null : Problem(statusCode: StatusCodes.Status409Conflict, title: "Records is not activated for this department.", type: "records_not_activated"); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordSavedReportsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordSavedReportsController.cs new file mode 100644 index 00000000..9bfd68dd --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordSavedReportsController.cs @@ -0,0 +1,165 @@ +using System; +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.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Department saved reports over v4 (RMS plan section 4.1, RMS-1B): allowlisted typed columns, bounded filters, + /// one group-by and count/sum/avg/min/max. Managing needs RecordReport_Update; running needs Record_View and + /// honors the runner's group scope and restricted permission. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class RecordSavedReportsController : V4AuthenticatedApiControllerbase + { + private readonly IRecordSavedReportsService _reports; + private readonly IRecordsCutoverService _cutoverService; + + public RecordSavedReportsController(IRecordSavedReportsService reports, IRecordsCutoverService cutoverService) + { + _reports = reports; + _cutoverService = cutoverService; + } + + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> List() + { + if (!await FlagOnAsync()) return NotFound(); + var result = new RecordSavedReportsResult { Data = (await _reports.GetForDepartmentAsync(DepartmentId)).Select(RecordsRms1bApiMapper.ToReport).ToList(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + [HttpGet("Get")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Get(string id) + { + if (!await FlagOnAsync()) return NotFound(); + var report = await _reports.GetAsync(DepartmentId, id); + if (report == null) return NotFound(); + return Ok(Wrap(report)); + } + + [HttpPost("Validate")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.RecordReport_Update)] + public async Task> Validate([FromBody] SaveRecordSavedReportInput input) + { + if (input == null) return BadRequest(); + if (!await FlagOnAsync()) return NotFound(); + var result = new RecordReportValidationResult { Data = await _reports.ValidateAsync(DepartmentId, RecordsRms1bApiMapper.ToReport(input)), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// Creates (no ReportId) or updates (ReportId + RowVersion / If-Match) a saved report. + [HttpPost("Save")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [Authorize(Policy = ResgridResources.RecordReport_Update)] + public async Task> Save([FromBody] SaveRecordSavedReportInput input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + var report = RecordsRms1bApiMapper.ToReport(input); + report.RowVersion = RecordsApiContract.ParseETag(Request.Headers[RecordsApiContract.IfMatchHeader]) ?? input.RowVersion; + try { return Ok(Wrap(await _reports.SaveAsync(DepartmentId, UserId, report, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("Delete")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.RecordReport_Update)] + public async Task Delete(string id, CancellationToken cancellationToken) + { + var usable = await UsableAsync(); + if (usable != null) return usable; + try { return await _reports.DeleteAsync(DepartmentId, UserId, id, cancellationToken) ? NoContent() : NotFound(); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Run")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Run(string id, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var result = new RecordReportRunResult { Data = await _reports.RunAsync(DepartmentId, UserId, id, cancellationToken), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("RunCsv")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task RunCsv(string id, CancellationToken cancellationToken) + { + if (!await FlagOnAsync()) return NotFound(); + try + { + var run = await _reports.RunAsync(DepartmentId, UserId, id, cancellationToken); + return File(Encoding.UTF8.GetBytes(_reports.ToCsv(run)), "text/csv", (run.Name ?? "report").Replace(' ', '-') + ".csv"); + } + catch (Exception ex) { return Fail(ex); } + } + + private RecordSavedReportResult Wrap(RmsSavedReportDefinition report) + { + var result = new RecordSavedReportResult { Data = RecordsRms1bApiMapper.ToReport(report), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + Response.Headers[RecordsApiContract.ETagHeader] = result.Data.ETag; + return result; + } + + private ActionResult Fail(Exception ex) + { + switch (ex) + { + case UnauthorizedAccessException _: return Forbid(); + case RecordConcurrencyException conflict: return Problem(statusCode: StatusCodes.Status409Conflict, title: conflict.Message, type: "record_report_conflict"); + case ArgumentException argument: return Problem(statusCode: StatusCodes.Status400BadRequest, title: argument.Message, type: "record_report_validation"); + case InvalidOperationException invalid: return Problem(statusCode: StatusCodes.Status409Conflict, title: invalid.Message, type: "record_report_state"); + default: throw ex; + } + } + + private async Task FlagOnAsync() => (await _cutoverService.GetModuleStateAsync(DepartmentId)).FlagEnabled; + + private async Task UsableAsync() + { + var state = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!state.FlagEnabled) return NotFound(); + return state.RecordsUsable ? null : Problem(statusCode: StatusCodes.Status409Conflict, title: "Records is not activated for this department.", type: "records_not_activated"); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs index 83a6af88..1479f5bf 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs @@ -46,14 +46,21 @@ public class RecordsController : V4AuthenticatedApiControllerbase, IActionFilter private readonly IRecordAttachmentUploadService _uploads; private readonly IRecordsApiIdempotencyService _idempotency; private readonly IRecordsDashboardService _dashboard; + private readonly IRecordDefinitionsService _definitions; + private readonly IRecordsRevealService _reveal; + private readonly IRecordsBulkPacketService _bulk; private SystemPrincipalRecordGrant _systemGrant; private bool _systemGrantResolved; public RecordsController(IRecordsService recordsService, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, IFeatureToggleService featureToggleService, IDepartmentSettingsService departmentSettingsService, IDepartmentDataProtectionService dataProtectionService, - IRecordsSearchService recordsSearch, IRecordAttachmentUploadService uploads, IRecordsApiIdempotencyService idempotency, IRecordsDashboardService dashboard) + IRecordsSearchService recordsSearch, IRecordAttachmentUploadService uploads, IRecordsApiIdempotencyService idempotency, IRecordsDashboardService dashboard, + IRecordDefinitionsService definitions, IRecordsRevealService reveal, IRecordsBulkPacketService bulk) { + _bulk = bulk; + _definitions = definitions; + _reveal = reveal; _recordsService = recordsService; _cutoverService = cutoverService; _recordsAuthorizationService = recordsAuthorizationService; @@ -194,6 +201,18 @@ public async Task> Capabilities() Logging.LogException(ex); } + // Department definitions (RMS-1B) sit beside the locked ones with their own capability floor (plan 5.4). + try + { + var names = (await _definitions.ListAsync(DepartmentId)).Where(s => !s.Locked).ToDictionary(s => s.Key, s => s.Name, StringComparer.OrdinalIgnoreCase); + foreach (var version in await _definitions.GetPublishedAsync(DepartmentId)) + data.Definitions.Add(RecordsRms1bApiMapper.ToDefinitionData(version, new RmsRecordDefinition { DefinitionKey = version.DefinitionKey, Name = names.TryGetValue(version.DefinitionKey, out var name) ? name : version.DefinitionKey })); + } + catch (Exception ex) + { + Logging.LogException(ex, "Department definitions could not be listed for the capability manifest."); + } + var result = new RecordsCapabilitiesResult { Data = data, PageSize = 1, Status = ResponseHelper.Success }; ResponseHelper.PopulateV4ResponseData(result); return Ok(result); @@ -473,6 +492,28 @@ public async Task> GetRecord(string id) return Ok(await WrapAsync(aggregate)); } + /// + /// Protected reveal (RMS plan section 5.9.3): the grant travels in X-Resgrid-Protected-Grant; the aggregate is + /// hydrated through the seam with it and the cataloged columns come back keyed "{table}.{column}:{rowId}". + /// A refused reveal answers Success = false with the step-up reason; never ciphertext. + /// + [HttpPost("Reveal")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Reveal([FromBody] RecordRevealInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.Id)) return BadRequest(); + if (!await FlagOnAsync()) return NotFound(); + var aggregate = await LoadAuthorizedAsync(input.Id); + if (aggregate == null) return NotFound(); + var outcome = await _reveal.RevealRecordAsync(DepartmentId, UserId, aggregate, await CanViewRestrictedAsync(), IpAddressHelper.GetRequestIP(Request, true)); + var result = new RecordRevealApiResult { Data = new RecordRevealData { Success = outcome.Success, Error = outcome.Error, Fields = outcome.Fields }, Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + [HttpGet("GetRevisions")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Record_View)] @@ -1061,6 +1102,63 @@ public async Task> RemoveAttachment(RecordAttachmentI } } + /// + /// Compiles an authorized selection into one stored packet (compiled PDF or zip bundle; RMS plan section 4.7). + /// Every record renders from its pinned revision through the document service; unauthorized or unfinalized + /// selections are reported as skips, never silently dropped. Bulk void and bulk delete do not exist. + /// + [HttpPost("BulkPacket")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Export)] + public async Task> BulkPacket([FromBody] RecordsBulkPacketInput input, CancellationToken cancellationToken) + { + if (input == null || input.RecordIds == null || input.RecordIds.Count == 0) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + try + { + var result = await _bulk.BuildPacketAsync(DepartmentId, UserId, new RecordsBulkPacketRequest + { + RecordIds = input.RecordIds, Mode = input.Mode == (int)RecordsBulkPacketMode.Bundle ? RecordsBulkPacketMode.Bundle : RecordsBulkPacketMode.CompiledPdf, + Title = input.Title, Purpose = input.Purpose, DeliverToEmail = input.DeliverToEmail, OriginClient = RecordsApiHelper.ResolveOrigin(input.OriginClient) + }, cancellationToken); + return Ok(WrapBulk(result)); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (ArgumentException ex) { return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "bulk_validation"); } + catch (InvalidOperationException ex) { return Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: ex.Message, type: "bulk_empty"); } + } + + /// Assigns one reviewer to every selected Record awaiting review; other states are reported as skips. + [HttpPost("BulkAssignReview")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Review)] + public async Task> BulkAssignReview([FromBody] RecordsBulkAssignInput input, CancellationToken cancellationToken) + { + if (input == null || input.RecordIds == null || input.RecordIds.Count == 0 || string.IsNullOrWhiteSpace(input.ReviewerUserId)) return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) return usable; + try + { + return Ok(WrapBulk(await _bulk.AssignForReviewAsync(DepartmentId, UserId, new RecordsBulkAssignRequest { RecordIds = input.RecordIds, ReviewerUserId = input.ReviewerUserId, Reason = input.Reason }, cancellationToken))); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (ArgumentException ex) { return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "bulk_validation"); } + } + + private RecordsBulkResultApi WrapBulk(RecordsBulkResult bulk) + { + var result = new RecordsBulkResultApi + { + Data = new RecordsBulkData { Processed = bulk.Processed, Skipped = bulk.Skipped, Skips = bulk.Skips, Delivered = bulk.Delivered, Run = bulk.Run == null ? null : RecordsRms1bApiMapper.ToRun(bulk.Run) }, + Status = ResponseHelper.Success, PageSize = 1 + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + private ActionResult UploadProblem(RecordUploadSessionException ex) { int status; diff --git a/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs b/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs index 7ff46430..7c6ba210 100644 --- a/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs @@ -142,6 +142,7 @@ public static RecordData ToRecord(RecordAggregate aggregate, bool canViewRestric var restricted = RmsDefinitionKeys.RestrictedClass.Contains(r.DefinitionKey ?? string.Empty); var data = new RecordData { + Values = RecordsRms1bApiMapper.ToValues(aggregate.Values, aggregate.DefinitionVersionRow?.Schema, canViewRestricted), RecordId = r.RmsOperationalRecordId, RecordKind = RmsRecordKind.Operational.ToString(), DefinitionKey = r.DefinitionKey, @@ -309,6 +310,7 @@ public static RecordDraftInput ToDraftInput(SaveRecordDraftInput input, RmsOrigi { UnitId = u.UnitId, Dispatched = RecordsApiHelper.Utc(u.Dispatched), Enroute = RecordsApiHelper.Utc(u.Enroute), OnScene = RecordsApiHelper.Utc(u.OnScene), Released = RecordsApiHelper.Utc(u.Released), InQuarters = RecordsApiHelper.Utc(u.InQuarters) }).ToList(), + Values = RecordsRms1bApiMapper.ToValueInputs(input.Values), ClientRecordId = input.ClientRecordId, IdempotencyKey = input.IdempotencyKey, OriginClient = origin, @@ -570,10 +572,11 @@ public static IncidentReportDraftInput ToDraftInput(SaveIncidentReportDraftInput // Null stays null: the service reads absence as "leave this section alone". Modules = input.Modules?.Select(m => new IncidentModuleInput { + ModuleId = m.ModuleId, Kind = (RmsIncidentModuleKind)m.Kind, PrimaryCode = m.PrimaryCode, SecondaryCode = m.SecondaryCode, Quantity = m.Quantity, QuantityUnit = m.QuantityUnit, OccurredOn = RecordsApiHelper.Utc(m.OccurredOn), DetailJson = m.DetailJson }).ToList(), - Resources = input.Resources?.Select(r => new IncidentResourceInput { ResourceCode = r.ResourceCode, Quantity = r.Quantity, Detail = r.Detail }).ToList(), + Resources = input.Resources?.Select(r => new IncidentResourceInput { ResourceId = r.ResourceId, ResourceCode = r.ResourceCode, Quantity = r.Quantity, Detail = r.Detail }).ToList(), Casualties = input.Casualties?.Select(c => new IncidentCasualtyRescueInput { CasualtyId = c.CasualtyId, Kind = (RmsCasualtyRescueKind)c.Kind, PersonType = c.PersonType, PersonnelUserId = c.PersonnelUserId, Rank = c.Rank, @@ -587,6 +590,7 @@ public static IncidentReportDraftInput ToDraftInput(SaveIncidentReportDraftInput }).ToList(), Exposures = input.Exposures?.Select(e => new IncidentExposureInput { + ExposureId = e.ExposureId, LocationKind = e.LocationKind, ItemType = e.ItemType, DamageType = e.DamageType, LocationUse = e.LocationUse, PeoplePresent = e.PeoplePresent, DisplacementCount = e.DisplacementCount, DisplacementCauses = e.DisplacementCauses ?? new List(), AddressText = e.AddressText, Street = e.Street, Municipality = e.Municipality, State = e.State, PostalCode = e.PostalCode, diff --git a/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs b/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs new file mode 100644 index 00000000..7c62d364 --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using Resgrid.Web.Services.Models.v4.Records; + +namespace Resgrid.Web.Services.Helpers +{ + /// Entity to DTO mapping for the RMS-1B/1C v4 surfaces. Restricted typed values are withheld here, never in the client. + public static class RecordsRms1bApiMapper + { + public static List ToValueInputs(IEnumerable inputs) + => (inputs ?? Enumerable.Empty()).Where(i => i != null).Select(i => new RecordValueInput + { + SectionKey = i.SectionKey, FieldKey = i.FieldKey, RowKey = i.RowKey, Ordinal = i.Ordinal, Value = i.Value, Values = i.Values, ReferenceType = i.ReferenceType, ReferenceId = i.ReferenceId, + UnitCode = i.UnitCode, CurrencyCode = i.CurrencyCode, OffsetMinutes = i.OffsetMinutes + }).ToList(); + + public static RecordValuesData ToValues(RecordValueSet set, RecordDefinitionSchema schema, bool canViewRestricted, IRecordTypedValuesService typedValues = null) + { + if (set == null) return null; + var data = new RecordValuesData { DefinitionKey = set.DefinitionKey, DefinitionVersion = set.DefinitionVersion, WithheldFieldKeys = set.WithheldFieldKeys.ToList() }; + foreach (var section in set.Sections) + { + var sectionData = new RecordValueSectionData { SectionKey = section.SectionKey, Label = section.Label, Repeating = section.Repeating }; + foreach (var row in section.Rows) + { + var rowData = new RecordValueRowData { RowKey = row.RowKey, Ordinal = row.Ordinal }; + foreach (var cell in row.Cells) + { + var withheld = cell.Withheld || cell.Classification == RmsFieldClassification.Restricted && !canViewRestricted; + if (withheld && !data.WithheldFieldKeys.Contains(cell.FieldKey)) data.WithheldFieldKeys.Add(cell.FieldKey); + rowData.Cells.Add(new RecordValueCellData + { + FieldKey = cell.FieldKey, Label = cell.Label, Type = cell.Type.ToString(), Classification = cell.Classification.ToString(), Withheld = withheld, + Display = withheld ? (cell.Display == null ? null : RecordTypedValuesService.Redacted) : cell.Display, + Value = withheld ? null : cell.Value, Values = withheld ? null : cell.Values, ReferenceType = withheld ? null : cell.ReferenceType, ReferenceId = withheld ? null : cell.ReferenceId, + UnitCode = cell.UnitCode, CurrencyCode = cell.CurrencyCode, OffsetMinutes = cell.OffsetMinutes, Number = withheld ? null : cell.Number, CanonicalNumber = withheld ? null : cell.CanonicalNumber, CanonicalUnitCode = cell.CanonicalUnitCode + }); + } + sectionData.Rows.Add(rowData); + } + data.Sections.Add(sectionData); + } + if (schema != null && typedValues != null) + { + var evaluation = typedValues.EvaluateRules(schema, set); + data.HiddenSectionKeys = evaluation.HiddenSectionKeys.ToList(); + data.HiddenFieldKeys = evaluation.HiddenFieldKeys.ToList(); + data.RequiredFieldKeys = evaluation.RequiredFieldKeys.ToList(); + data.RowRules = evaluation.Rows.Values.Where(r => r.HiddenFieldKeys.Count > 0 || r.RequiredFieldKeys.Count > 0) + .Select(r => new RecordRowRulesData { SectionKey = r.SectionKey, RowKey = r.RowKey, HiddenFieldKeys = r.HiddenFieldKeys.ToList(), RequiredFieldKeys = r.RequiredFieldKeys.ToList() }).ToList(); + } + return data; + } + + public static RecordDefinitionVersionData ToVersion(RmsRecordDefinitionVersion v) + { + if (v == null) return null; + 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), + 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, + CreatedOn = v.CreatedOn, ModifiedOn = v.ModifiedOn, RowVersion = v.RowVersion, ETag = RecordsApiContract.ToETag(v.RowVersion) + }; + } + + public static RecordDefinitionDetailData ToDefinition(RecordDefinitionAggregate aggregate) + { + var d = aggregate.Definition; + return new RecordDefinitionDetailData + { + DefinitionId = d.RmsRecordDefinitionId, Key = d.DefinitionKey, Name = d.Name, Category = d.Category, Description = d.Description, Owner = ((RmsDefinitionOwner)d.Owner).ToString(), + TemplateKey = d.TemplateKey, JurisdictionProfileKey = d.JurisdictionProfileKey, PermittedSubjectTypes = d.PermittedSubjectTypes, CurrentPublishedVersion = d.CurrentPublishedVersion, LatestVersion = d.LatestVersion, + IsRetired = d.IsRetired, RetiredOn = d.RetiredOn, RetiredReason = d.RetiredReason, RowVersion = d.RowVersion, ETag = RecordsApiContract.ToETag(d.RowVersion), + Versions = aggregate.Versions.OrderBy(v => v.Version).Select(ToVersion).ToList() + }; + } + + /// Department definitions as the capability manifest lists them (plan 5.4), beside the locked ones. + public static RecordDefinitionData ToDefinitionData(RmsRecordDefinitionVersion version, RmsRecordDefinition definition) + { + var schema = version.Schema; + 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(), + 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, + Fields = schema.AllFields().Select(f => new RecordFieldData { Key = f.Key, Section = schema.SectionOf(f.Key)?.Key, Type = f.Type.ToString(), Required = f.Required, RequiredToFinalize = f.RequiredToFinalize, Restricted = f.Classification != RmsFieldClassification.Standard }).ToList() + }; + } + + public static RecordTemplateRenderingData ToRendering(RecordTemplateRendering rendering) + { + 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, + 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 + }; + } + + public static RecordSavedReportData ToReport(RmsSavedReportDefinition r) => new RecordSavedReportData + { + ReportId = r.RmsSavedReportDefinitionId, Name = r.Name, Description = r.Description, DefinitionKey = r.DefinitionKey, DefinitionVersion = r.DefinitionVersion, Spec = r.Spec, MaxRowsPerRun = r.MaxRowsPerRun, + IncludeRestricted = r.IncludeRestricted, LastRunOn = r.LastRunOn, LastRunByUserId = r.LastRunByUserId, CreatedOn = r.CreatedOn, ModifiedOn = r.ModifiedOn, RowVersion = r.RowVersion, ETag = RecordsApiContract.ToETag(r.RowVersion) + }; + + public static RmsSavedReportDefinition ToReport(SaveRecordSavedReportInput input) => new RmsSavedReportDefinition + { + RmsSavedReportDefinitionId = input.ReportId, RowVersion = input.RowVersion, Name = input.Name?.Trim(), Description = input.Description, DefinitionKey = RecordDefinitionKeys.NormalizeKey(input.DefinitionKey), + DefinitionVersion = input.DefinitionVersion, Spec = input.Spec ?? new RecordReportSpec(), MaxRowsPerRun = input.MaxRowsPerRun, IncludeRestricted = input.IncludeRestricted + }; + + public static RecordExportTemplateData ToTemplate(RmsExportTemplate t) => new RecordExportTemplateData + { + TemplateId = t.RmsExportTemplateId, TemplateKey = t.TemplateKey, Name = t.Name, Description = t.Description, Format = ((RmsExportFormat)t.Format).ToString(), Scope = ((RmsExportScope)t.Scope).ToString(), + DefinitionKeys = (t.DefinitionKeysCsv ?? string.Empty).Split(',').Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).ToList(), + Columns = string.IsNullOrWhiteSpace(t.ColumnsJson) ? new List() : Newtonsoft.Json.JsonConvert.DeserializeObject>(t.ColumnsJson) ?? new List(), + IncludeNarrative = t.IncludeNarrative, IncludeRestricted = t.IncludeRestricted, EgressAcknowledgedOn = t.EgressAcknowledgedOn, EgressAcknowledgedByUserId = t.EgressAcknowledgedByUserId, FileNameTemplate = t.FileNameTemplate, + IncludeHeader = t.IncludeHeader, Delimiter = t.Delimiter == "\t" ? "tab" : t.Delimiter, ScheduleKind = ((RmsExportScheduleKind)t.ScheduleKind).ToString(), ScheduleHourLocal = t.ScheduleHourLocal, ScheduleDayOfWeek = t.ScheduleDayOfWeek, + ScheduleDayOfMonth = t.ScheduleDayOfMonth, WindowDays = t.WindowDays, NextRunOn = t.NextRunOn, LastRunOn = t.LastRunOn, IsEnabled = t.IsEnabled, CreatedOn = t.CreatedOn, ModifiedOn = t.ModifiedOn, RowVersion = t.RowVersion, ETag = RecordsApiContract.ToETag(t.RowVersion) + }; + + public static RmsExportTemplate ToTemplate(SaveRecordExportTemplateInput input) => new RmsExportTemplate + { + RmsExportTemplateId = input.TemplateId, RowVersion = input.RowVersion, TemplateKey = input.TemplateKey, Name = input.Name, Description = input.Description, Format = input.Format, Scope = input.Scope, + DefinitionKeysCsv = string.Join(",", input.DefinitionKeys ?? new List()), ColumnsJson = Newtonsoft.Json.JsonConvert.SerializeObject(input.Columns ?? new List()), + IncludeNarrative = input.IncludeNarrative, IncludeRestricted = input.IncludeRestricted, FileNameTemplate = input.FileNameTemplate, IncludeHeader = input.IncludeHeader, + Delimiter = input.Delimiter == "tab" ? "\t" : input.Delimiter, ScheduleKind = input.ScheduleKind, ScheduleHourLocal = input.ScheduleHourLocal, ScheduleDayOfWeek = input.ScheduleDayOfWeek, ScheduleDayOfMonth = input.ScheduleDayOfMonth, + WindowDays = input.WindowDays, IsEnabled = input.IsEnabled + }; + + public static RecordExportRunData ToRun(RmsExportRun r) => new RecordExportRunData + { + RunId = r.RmsExportRunId, TemplateId = r.TemplateId, TemplateKey = r.TemplateKey, Trigger = ((RmsExportTrigger)r.Trigger).ToString(), RecordId = r.RecordId, WindowStart = r.WindowStart, WindowEnd = r.WindowEnd, + RecordCount = r.RecordCount, FileName = r.FileName, ContentType = r.ContentType, ByteSize = r.ByteSize, Checksum = r.Checksum, Redacted = r.Redacted, GeneratedOn = r.GeneratedOn, GeneratedByUserId = r.GeneratedByUserId, + WorkflowRunId = r.WorkflowRunId, ExpiresOn = r.ExpiresOn + }; + + public static RecordDeploymentFillData ToFill(RmsExternalOrderFill f) => new RecordDeploymentFillData + { + FillId = f.RmsExternalOrderFillId, RequestNumber = f.RequestNumber, ParentRequestNumber = f.ParentRequestNumber, RequestCategory = f.RequestCategory, FillNumber = f.FillNumber, ResourceKind = f.ResourceKind, ResourceType = f.ResourceType, + ResourceTypeScheme = f.ResourceTypeScheme, Position = f.Position, PositionScheme = f.PositionScheme, IsTrainee = f.IsTrainee, HomeUnit = f.HomeUnit, HostAgency = f.HostAgency, AgencyUnitId = f.AgencyUnitId, PointOfHire = f.PointOfHire, + CostCode = f.CostCode, AgreementReference = f.AgreementReference, AssignedUserId = f.AssignedUserId, AssignedUnitId = f.AssignedUnitId, Status = ((RmsDeploymentFillStatus)f.Status).ToString(), DeclineReason = f.DeclineReason, + RequestedOn = f.RequestedOn, NeededOn = f.NeededOn, FilledOn = f.FilledOn, MobilizedOn = f.MobilizedOn, CheckedInOn = f.CheckedInOn, AssignedOn = f.AssignedOn, ReleasedOn = f.ReleasedOn, DemobilizedOn = f.DemobilizedOn, ReturnedOn = f.ReturnedOn, + CapturedOffsetMinutes = f.CapturedOffsetMinutes, Notes = f.Notes, RowVersion = f.RowVersion + }; + + public static RecordDeploymentData ToDeployment(RecordDeploymentAggregate aggregate) + { + var o = aggregate.Order; + var pack = RecordTemplateCatalog.PackOf(RecordDeploymentsService.DeploymentTemplateKey); + return new RecordDeploymentData + { + OrderId = o.RmsExternalOrderId, RecordId = o.RecordId, RecordNumber = aggregate.Record?.Record?.RecordNumber ?? aggregate.Record?.Record?.DraftReference, RecordState = aggregate.Record == null ? null : ((RmsRecordState)aggregate.Record.Record.State).ToString(), + ProfileKey = o.ProfileKey, ProfileVersion = o.ProfileVersion, HomeProfileKey = o.HomeProfileKey, HostProfileKey = o.HostProfileKey, SourceScheme = o.SourceScheme, SourceSystem = o.SourceSystem, OrderNumber = o.OrderNumber, + IncidentName = o.IncidentName, IncidentNumber = o.IncidentNumber, IncidentCountry = o.IncidentCountry, IncidentSubdivision = o.IncidentSubdivision, OrderingOffice = o.OrderingOffice, DispatchOffice = o.DispatchOffice, + RequestingAgency = o.RequestingAgency, ReceivingAgency = o.ReceivingAgency, SendingAgency = o.SendingAgency, DepartmentRole = o.DepartmentRole, CostCode = o.CostCode, AgreementReference = o.AgreementReference, + CurrencyCode = o.CurrencyCode, MeasurementSystem = o.MeasurementSystem, TimeZoneId = o.TimeZoneId, CapturedOffsetMinutes = o.CapturedOffsetMinutes, SourceCapturedOn = o.SourceCapturedOn, SourceVersion = o.SourceVersion, + ArtifactFileName = o.ArtifactFileName, ArtifactContentType = o.ArtifactContentType, ArtifactChecksum = o.ArtifactChecksum, HasArtifact = o.ArtifactChecksum != null, ArtifactSafeUrl = o.ArtifactSafeUrl, + Status = ((RmsExternalOrderStatus)o.Status).ToString(), MobilizedOn = o.MobilizedOn, ReleasedOn = o.ReleasedOn, ClosedOutOn = o.ClosedOutOn, CloseoutNotes = o.CloseoutNotes, AllReturned = aggregate.AllReturned, + IsPreview = pack?.IsPreview ?? true, ProvenanceStatement = "Preview: created from an external order snapshot; no claim that NWCG, CIFFC or a member agency accepts this output until a real order has been filled and reconciled.", + CreatedOn = o.CreatedOn, ModifiedOn = o.ModifiedOn, RowVersion = o.RowVersion, ETag = RecordsApiContract.ToETag(o.RowVersion), Fills = aggregate.Fills.Select(ToFill).ToList() + }; + } + + public static RecordDeploymentCreateInput ToCreateInput(CreateRecordDeploymentInput input, RmsOriginClient origin) + { + byte[] artifact = null; + if (!string.IsNullOrWhiteSpace(input.ArtifactBase64)) + { + try { artifact = Convert.FromBase64String(input.ArtifactBase64); } + catch (FormatException) { throw new ArgumentException("ArtifactBase64 is not valid base64.", nameof(input)); } + } + return new RecordDeploymentCreateInput + { + ProfileKey = input.ProfileKey, HomeProfileKey = input.HomeProfileKey, HostProfileKey = input.HostProfileKey, SourceScheme = input.SourceScheme, SourceSystem = input.SourceSystem, OrderNumber = input.OrderNumber, + IncidentName = input.IncidentName, IncidentNumber = input.IncidentNumber, IncidentCountry = input.IncidentCountry, IncidentSubdivision = input.IncidentSubdivision, OrderingOffice = input.OrderingOffice, DispatchOffice = input.DispatchOffice, + RequestingAgency = input.RequestingAgency, ReceivingAgency = input.ReceivingAgency, SendingAgency = input.SendingAgency, DepartmentRole = input.DepartmentRole, CostCode = input.CostCode, AgreementReference = input.AgreementReference, + CurrencyCode = input.CurrencyCode, MeasurementSystem = input.MeasurementSystem, TimeZoneId = input.TimeZoneId, CapturedOffsetMinutes = input.CapturedOffsetMinutes, SourceCapturedOn = input.SourceCapturedOn, SourceVersion = input.SourceVersion, + ArtifactFileName = input.ArtifactFileName, ArtifactContentType = input.ArtifactContentType, ArtifactData = artifact, ArtifactSafeUrl = input.ArtifactSafeUrl, StationGroupId = input.StationGroupId, IdempotencyKey = input.IdempotencyKey, + OriginClient = origin, Fills = input.Fills ?? new List() + }; + } + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/RecordsRms3ApiHelper.cs b/Web/Resgrid.Web.Services/Helpers/RecordsRms3ApiHelper.cs index 112931c7..0c8f0879 100644 --- a/Web/Resgrid.Web.Services/Helpers/RecordsRms3ApiHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/RecordsRms3ApiHelper.cs @@ -101,6 +101,7 @@ public static IncidentAnalysisDraftInput ToDraftInput(SaveIncidentAnalysisDraftI // Null stays null: the service reads absence as "leave this section alone". Modules = input.Modules?.Select(m => new IncidentModuleInput { + ModuleId = m.ModuleId, Kind = (RmsIncidentModuleKind)m.Kind, PrimaryCode = m.PrimaryCode, SecondaryCode = m.SecondaryCode, Quantity = m.Quantity, QuantityUnit = m.QuantityUnit, OccurredOn = RecordsApiHelper.Utc(m.OccurredOn), DetailJson = m.DetailJson }).ToList(), diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/FieldRecordsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/FieldRecordsApiModels.cs new file mode 100644 index 00000000..18120f83 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Records/FieldRecordsApiModels.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Services.Models.v4.Records +{ + // Field Records for the four operational apps (RMS plan RMS-1D). Every filter is applied server-side from the + // authenticated principal; these inputs narrow what comes back, they never widen it. + + public class FieldRecordContextInput + { + public int? CallId { get; set; } + public int? UnitId { get; set; } + public int? GroupId { get; set; } + public string CommandRole { get; set; } + public int? ContactId { get; set; } + + public FieldRecordContext ToContext() => new FieldRecordContext { CallId = CallId, UnitId = UnitId, GroupId = GroupId, CommandRole = CommandRole, ContactId = ContactId }; + } + + public class FieldRecordCatalogInput + { + /// : 2 Responder, 3 Unit, 4 IncidentCommand, 5 Dispatch. + public int? OriginClient { get; set; } + public string AppVersion { get; set; } + /// records.v1 / records.v1b / records.v1c; an unknown value is treated as the oldest. + public string ClientCapability { get; set; } + public FieldRecordContextInput Context { get; set; } + } + + public class FieldRecordPrefillInput : FieldRecordCatalogInput + { + public string DefinitionKey { get; set; } + public int Version { get; set; } + } + + public class FieldRecordSyncInput : FieldRecordCatalogInput + { + public long Since { get; set; } + public string SinceId { get; set; } + public string ScopeStamp { get; set; } + public int Take { get; set; } = 200; + public bool IncludeCatalog { get; set; } = true; + } + + public class FieldRecordPreflightResult : StandardApiResponseV4Base + { + public FieldRecordPreflightData Data { get; set; } + } + + public class FieldRecordPreflightData + { + public string ContractVersion { get; set; } + public string SyncContractVersion { get; set; } + public string OriginClient { get; set; } + public bool Ok { get; set; } + public List Reasons { get; set; } = new List(); + public bool ModuleEnabled { get; set; } + public bool RecordsUsable { get; set; } + public bool AppEnabled { get; set; } + public string MinimumAppVersion { get; set; } + public string AppVersion { get; set; } + public string ClientCapability { get; set; } + public string ProtectionState { get; set; } + public long ServerTimestampMs { get; set; } + } + + public class FieldRecordCatalogResult : StandardApiResponseV4Base + { + public FieldRecordCatalogData Data { get; set; } + } + + public class FieldRecordCatalogData + { + public string ContractVersion { get; set; } + public string OriginClient { get; set; } + public bool Ok { get; set; } + public List Reasons { get; set; } = new List(); + public string ContextKind { get; set; } + public bool ContextVerified { get; set; } + public string ProtectionState { get; set; } + public string ScopeStamp { get; set; } + public List Definitions { get; set; } = new List(); + public List Exclusions { get; set; } = new List(); + public long ServerTimestampMs { get; set; } + } + + public class FieldRecordPrefillResult : StandardApiResponseV4Base + { + public FieldRecordPrefillData Data { get; set; } + } + + public class FieldRecordPrefillData + { + public string ContractVersion { get; set; } + public string DefinitionKey { get; set; } + public int Version { get; set; } + public int PrefillVersion { get; set; } + public int? CallId { get; set; } + public int? UnitId { get; set; } + public int? StationGroupId { get; set; } + public List Values { get; set; } = new List(); + public List Provenance { get; set; } = new List(); + public List SuggestedParticipantUserIds { get; set; } = new List(); + public List SuggestedUnitIds { get; set; } = new List(); + public DateTime CalculatedOn { get; set; } + } + + public class FieldRecordPrefillValueData + { + public string SectionKey { get; set; } + public string FieldKey { get; set; } + public string Value { get; set; } + public string ReferenceType { get; set; } + public string ReferenceId { get; set; } + } + + public class FieldRecordSyncResult : StandardApiResponseV4Base + { + public FieldRecordSyncData Data { get; set; } + } + + public class FieldRecordSyncData + { + public string ContractVersion { get; set; } + public bool Ok { get; set; } + public List Reasons { get; set; } = new List(); + public string ScopeStamp { get; set; } + public bool ResetRequired { get; set; } + public long Since { get; set; } + public long ServerTimestampMs { get; set; } + public string ServerCursorId { get; set; } + public bool HasMore { get; set; } + public FieldRecordCatalogData Catalog { get; set; } + public List Records { get; set; } = new List(); + public List Tombstones { get; set; } = new List(); + public List Drafts { get; set; } = new List(); + public List Assignments { get; set; } = new List(); + } + + public class FieldRecordAssignmentsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class FieldRecordAssignmentResult : StandardApiResponseV4Base + { + public FieldRecordAssignmentData Data { get; set; } + } + + public class FieldRecordAssignmentData + { + public string AssignmentId { get; set; } + public string RecordId { get; set; } + public string AssigneeKind { get; set; } + public string AssigneeUserId { get; set; } + public int? AssigneeUnitId { get; set; } + public int? AssigneeGroupId { get; set; } + public string AssigneeRole { get; set; } + public string Purpose { get; set; } + public string Note { get; set; } + public DateTime? DueOn { get; set; } + public string State { get; set; } + public DateTime? AcknowledgedOn { get; set; } + public DateTime? CompletedOn { get; set; } + public string OriginClient { get; set; } + public DateTime CreatedOn { get; set; } + public string CreatedByUserId { get; set; } + public long RowVersion { get; set; } + } + + public class FieldRecordAssignInput + { + public string RecordId { get; set; } + /// : 1 Person, 2 Unit, 3 Group, 4 CommandRole, 5 DispatchRole. + public int AssigneeKind { get; set; } = 1; + public string AssigneeUserId { get; set; } + public int? AssigneeUnitId { get; set; } + public int? AssigneeGroupId { get; set; } + public string AssigneeRole { get; set; } + public string Purpose { get; set; } + public string Note { get; set; } + public DateTime? DueOn { get; set; } + public FieldRecordContextInput Context { get; set; } + public int? OriginClient { get; set; } + } + + public class FieldRecordAssignmentCommandInput + { + public string AssignmentId { get; set; } + public long? RowVersion { get; set; } + public string Reason { get; set; } + public FieldRecordContextInput Context { get; set; } + public int? OriginClient { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs index 7691319d..baaf713e 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs @@ -441,6 +441,9 @@ public class IncidentExposureData public class IncidentModuleInputData { + /// The stored section this entry replaces; omit for a new one. Sending it is what lets a client + /// reorder or remove sections without the save matching rows by list position. + public string ModuleId { get; set; } public int Kind { get; set; } public string PrimaryCode { get; set; } public string SecondaryCode { get; set; } @@ -452,6 +455,8 @@ public class IncidentModuleInputData public class IncidentResourceInputData { + /// The stored resource this entry replaces; omit for a new one. + public string ResourceId { get; set; } public string ResourceCode { get; set; } public int? Quantity { get; set; } public string Detail { get; set; } @@ -490,6 +495,8 @@ public class IncidentCasualtyInputData public class IncidentExposureInputData { + /// The stored exposure this entry replaces; omit for a new one. + public string ExposureId { get; set; } public string LocationKind { get; set; } public string ItemType { get; set; } public string DamageType { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs index 950bf882..f123243e 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs @@ -232,6 +232,8 @@ public class RecordData public List Attachments { get; set; } = new List(); public List Revisions { get; set; } = new List(); public List GroupScopeIds { get; set; } = new List(); + /// Department-definition typed values rendered against the pinned version (RMS-1B); null for locked system definitions. + public RecordValuesData Values { get; set; } } public class RecordDetailsData @@ -342,6 +344,8 @@ public class SaveRecordDraftInput public RecordDetailsInput Details { get; set; } = new RecordDetailsInput(); public List Participants { get; set; } = new List(); public List Units { get; set; } = new List(); + /// Typed values for a department definition (RMS-1B); ignored for locked system definitions. + public List Values { get; set; } = new List(); public string DuplicateContinueReason { get; set; } /// RmsOriginClient: 2 Responder, 3 Unit, 4 IncidentCommand, 5 Dispatch, 6 Api (default). Field clients are gated by their Records.Field.* flag. public int? OriginClient { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs new file mode 100644 index 00000000..8bb48535 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs @@ -0,0 +1,646 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Services.Models.v4.Records +{ + // RMS-1B/1C v4 surfaces (plan section 5.4): definitions, typed values, saved reports, export templates, deployments, + // and the protected reveal. Schema/rule/report/diff contracts are the Model types themselves; only envelopes, + // inputs and withholding-aware value shapes live here. + + #region Typed values + + public class RecordValueInputData + { + public string SectionKey { get; set; } + public string FieldKey { get; set; } + public string RowKey { get; set; } + public int Ordinal { get; set; } + public string Value { get; set; } + public List Values { get; set; } + public string ReferenceType { get; set; } + public string ReferenceId { get; set; } + public string UnitCode { get; set; } + public string CurrencyCode { get; set; } + public int? OffsetMinutes { get; set; } + } + + public class RecordValueCellData + { + public string FieldKey { get; set; } + public string Label { get; set; } + public string Type { get; set; } + public string Classification { get; set; } + public string Display { get; set; } + public string Value { get; set; } + public List Values { get; set; } + public string ReferenceType { get; set; } + public string ReferenceId { get; set; } + public string UnitCode { get; set; } + public string CurrencyCode { get; set; } + public int? OffsetMinutes { get; set; } + public decimal? Number { get; set; } + public decimal? CanonicalNumber { get; set; } + public string CanonicalUnitCode { get; set; } + public bool Withheld { get; set; } + } + + public class RecordValueRowData + { + public string RowKey { get; set; } + public int Ordinal { get; set; } + public List Cells { get; set; } = new List(); + } + + public class RecordValueSectionData + { + public string SectionKey { get; set; } + public string Label { get; set; } + public bool Repeating { get; set; } + public List Rows { get; set; } = new List(); + } + + public class RecordRowRulesData + { + public string SectionKey { get; set; } + public string RowKey { get; set; } + public List HiddenFieldKeys { get; set; } = new List(); + public List RequiredFieldKeys { get; set; } = new List(); + } + + public class RecordValuesData + { + public string DefinitionKey { get; set; } + public int DefinitionVersion { get; set; } + public List Sections { get; set; } = new List(); + public List WithheldFieldKeys { get; set; } = new List(); + public List HiddenSectionKeys { get; set; } = new List(); + public List HiddenFieldKeys { get; set; } = new List(); + public List RequiredFieldKeys { get; set; } = new List(); + /// Per-row rule outcomes for repeating sections (a field rule may look at its own row). + public List RowRules { get; set; } = new List(); + } + + #endregion + + #region Definitions + + public class RecordDefinitionsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordDefinitionVersionData + { + public string VersionId { get; set; } + public string DefinitionKey { get; set; } + public int Version { get; set; } + public string State { get; set; } + public string LifecyclePreset { get; set; } + public List ReviewerRoleIds { get; set; } = new List(); + public List ApproverRoleIds { get; set; } = new List(); + public int? ReviewDueHours { get; set; } + public int? ApproveDueHours { get; set; } + public bool RequireAuthorAttestation { get; set; } + public RecordDefinitionNumbering Numbering { get; set; } + public int? RetentionYears { get; set; } + public string Classification { get; set; } + public RecordDefinitionSchema Schema { get; set; } + public string SchemaChecksum { get; set; } + public string MinimumClientCapability { get; set; } + public RecordDefinitionClientSurface ClientSurface { get; set; } + public List MigrationMap { get; set; } = new List(); + public string ChangeNotes { get; set; } + public DateTime? PublishedOn { get; set; } + public string PublishedByUserId { get; set; } + public DateTime? RetiredOn { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public string ETag { get; set; } + } + + public class RecordDefinitionDetailData + { + public string DefinitionId { get; set; } + public string Key { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string Description { get; set; } + public string Owner { get; set; } + public string TemplateKey { get; set; } + public string JurisdictionProfileKey { get; set; } + public string PermittedSubjectTypes { get; set; } + public int? CurrentPublishedVersion { get; set; } + public int LatestVersion { get; set; } + public bool IsRetired { get; set; } + public DateTime? RetiredOn { get; set; } + public string RetiredReason { get; set; } + public long RowVersion { get; set; } + public string ETag { get; set; } + public List Versions { get; set; } = new List(); + } + + public class RecordDefinitionResult : StandardApiResponseV4Base + { + public RecordDefinitionDetailData Data { get; set; } + } + + public class RecordDefinitionVersionResult : StandardApiResponseV4Base + { + public RecordDefinitionVersionData Data { get; set; } + } + + public class RecordDefinitionVersionsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordDefinitionValidationResult : StandardApiResponseV4Base + { + public RecordDefinitionValidation Data { get; set; } + } + + public class RecordDefinitionImpactResult : StandardApiResponseV4Base + { + public RecordDefinitionImpactPreview Data { get; set; } + } + + public class RecordDefinitionDiffResult : StandardApiResponseV4Base + { + public RecordDefinitionDiff Data { get; set; } + } + + /// The print layout for one definition version (RMS plan section 4.10.1). + public class RecordDefinitionLayoutResult : StandardApiResponseV4Base + { + public RecordDefinitionLayoutData Data { get; set; } + } + + public class RecordDefinitionLayoutData + { + public string DefinitionKey { get; set; } + public int DefinitionVersion { get; set; } + /// "{definition-key}/{n}" for the saved definition layout; null when none has been saved. + public string StoredLayoutVersion { get; set; } + /// The saved definition-scope configuration, or null. + public RecordsDefinitionLayoutConfig Config { get; set; } + /// True when the saved layout applies to the requested definition version. + public bool AppliesToVersion { get; set; } + /// The composite layout version the provenance footer stamps for this version. + public string ResolvedLayoutVersion { get; set; } + /// The branding block print resolves to (definition override or department default). + public RecordsPrintLayoutConfig Branding { get; set; } + } + + /// Bulk packet / bulk assign-for-review over an authorized selection (RMS plan section 4.7). + public class RecordsBulkPacketInput + { + public List RecordIds { get; set; } = new List(); + /// 1 = compiled PDF, 2 = zip bundle of per-record PDFs plus manifest.json. + public int Mode { get; set; } = 1; + public string Title { get; set; } + public string Purpose { get; set; } + public string DeliverToEmail { get; set; } + public int? OriginClient { get; set; } + } + + public class RecordsBulkAssignInput + { + public List RecordIds { get; set; } = new List(); + public string ReviewerUserId { get; set; } + public string Reason { get; set; } + } + + public class RecordsBulkResultApi : StandardApiResponseV4Base + { + public RecordsBulkData Data { get; set; } + } + + public class RecordsBulkData + { + public int Processed { get; set; } + public int Skipped { get; set; } + public List Skips { get; set; } = new List(); + /// The stored packet run (download through RecordExportTemplates/DownloadRun); null for an assignment. + public RecordExportRunData Run { get; set; } + public bool Delivered { get; set; } + } + + public class RecordDefinitionMigrationApiResult : StandardApiResponseV4Base + { + public RecordDefinitionMigrationResult Data { get; set; } + } + + public class CreateRecordDefinitionInput + { + public string DefinitionKey { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string TemplateKey { get; set; } + public string CloneFromDefinitionKey { get; set; } + public string JurisdictionProfileKey { get; set; } + public string Locale { get; set; } + } + + public class SaveRecordDefinitionDraftInput + { + public long RowVersion { get; set; } + public RecordDefinitionDraftInput Draft { get; set; } + } + + public class PublishRecordDefinitionInput + { + public long RowVersion { get; set; } + } + + public class RetireRecordDefinitionInput + { + public long RowVersion { get; set; } + public string Reason { get; set; } + } + + public class MigrateRecordDraftsInput + { + public int FromVersion { get; set; } + public int ToVersion { get; set; } + public List Mapping { get; set; } = new List(); + public bool Preview { get; set; } = true; + } + + public class RecordTemplatePacksResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordJurisdictionProfilesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordTemplateRenderingData + { + public string TemplateKey { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string Description { get; set; } + public string PackKey { get; set; } + public string LifecyclePreset { get; set; } + public string NumberPrefix { get; set; } + public string PermittedSubjectTypes { get; set; } + public string ProfileKey { get; set; } + public string Locale { get; set; } + public string MeasurementSystem { get; set; } + public string CurrencyCode { get; set; } + public string ArtifactStatus { get; set; } + public string ProvenanceStatement { get; set; } + public List Sources { get; set; } = new List(); + public string MinimumClientCapability { get; set; } + public RecordDefinitionSchema Schema { get; set; } + } + + public class RecordTemplateRenderingResult : StandardApiResponseV4Base + { + public RecordTemplateRenderingData Data { get; set; } + } + + #endregion + + #region Saved reports + + public class RecordSavedReportData + { + public string ReportId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string DefinitionKey { get; set; } + public int? DefinitionVersion { get; set; } + public RecordReportSpec Spec { get; set; } + public int MaxRowsPerRun { get; set; } + public bool IncludeRestricted { get; set; } + public DateTime? LastRunOn { get; set; } + public string LastRunByUserId { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public string ETag { get; set; } + } + + public class RecordSavedReportsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordSavedReportResult : StandardApiResponseV4Base + { + public RecordSavedReportData Data { get; set; } + } + + public class SaveRecordSavedReportInput + { + public string ReportId { get; set; } + public long RowVersion { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string DefinitionKey { get; set; } + public int? DefinitionVersion { get; set; } + public RecordReportSpec Spec { get; set; } = new RecordReportSpec(); + public int MaxRowsPerRun { get; set; } = RmsSavedReportDefinition.MaxRows; + public bool IncludeRestricted { get; set; } + } + + public class RecordReportValidationResult : StandardApiResponseV4Base + { + public RecordReportValidation Data { get; set; } + } + + public class RecordReportRunResult : StandardApiResponseV4Base + { + public RecordReportResult Data { get; set; } + } + + #endregion + + #region Export templates + + public class RecordExportTemplateData + { + public string TemplateId { get; set; } + public string TemplateKey { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Format { get; set; } + public string Scope { get; set; } + public List DefinitionKeys { get; set; } = new List(); + public List Columns { get; set; } = new List(); + public bool IncludeNarrative { get; set; } + public bool IncludeRestricted { get; set; } + public DateTime? EgressAcknowledgedOn { get; set; } + public string EgressAcknowledgedByUserId { get; set; } + public string FileNameTemplate { get; set; } + public bool IncludeHeader { get; set; } + public string Delimiter { get; set; } + public string ScheduleKind { get; set; } + public int ScheduleHourLocal { get; set; } + public int ScheduleDayOfWeek { get; set; } + public int ScheduleDayOfMonth { get; set; } + public int WindowDays { get; set; } + public DateTime? NextRunOn { get; set; } + public DateTime? LastRunOn { get; set; } + public bool IsEnabled { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public string ETag { get; set; } + } + + public class RecordExportTemplatesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordExportTemplateResult : StandardApiResponseV4Base + { + public RecordExportTemplateData Data { get; set; } + public List Warnings { get; set; } = new List(); + } + + public class SaveRecordExportTemplateInput + { + public string TemplateId { get; set; } + public long RowVersion { get; set; } + public string TemplateKey { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public int Format { get; set; } = (int)RmsExportFormat.Csv; + public int Scope { get; set; } = (int)RmsExportScope.TriggeringRecord; + public List DefinitionKeys { get; set; } = new List(); + public List Columns { get; set; } = new List(); + public bool IncludeNarrative { get; set; } + public bool IncludeRestricted { get; set; } + public bool AcknowledgeEgress { get; set; } + public string FileNameTemplate { get; set; } + public bool IncludeHeader { get; set; } = true; + public string Delimiter { get; set; } = ","; + public int ScheduleKind { get; set; } = (int)RmsExportScheduleKind.None; + public int ScheduleHourLocal { get; set; } = 6; + public int ScheduleDayOfWeek { get; set; } = 1; + public int ScheduleDayOfMonth { get; set; } = 1; + public int WindowDays { get; set; } + public bool IsEnabled { get; set; } = true; + } + + public class RunRecordExportInput + { + public string RecordId { get; set; } + public int? RecordKind { get; set; } + public DateTime? WindowStart { get; set; } + public DateTime? WindowEnd { get; set; } + } + + public class RecordExportRunData + { + public string RunId { get; set; } + public string TemplateId { get; set; } + public string TemplateKey { get; set; } + public string Trigger { get; set; } + public string RecordId { get; set; } + public DateTime? WindowStart { get; set; } + public DateTime? WindowEnd { get; set; } + public int RecordCount { get; set; } + public string FileName { get; set; } + public string ContentType { get; set; } + public long ByteSize { get; set; } + public string Checksum { get; set; } + public bool Redacted { get; set; } + public DateTime GeneratedOn { get; set; } + public string GeneratedByUserId { get; set; } + public string WorkflowRunId { get; set; } + public DateTime ExpiresOn { get; set; } + } + + public class RecordExportRunsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordExportRunResult : StandardApiResponseV4Base + { + public RecordExportRunData Data { get; set; } + } + + #endregion + + #region Deployments + + public class RecordDeploymentFillData + { + public string FillId { get; set; } + public string RequestNumber { get; set; } + public string ParentRequestNumber { get; set; } + public string RequestCategory { get; set; } + public string FillNumber { get; set; } + public string ResourceKind { get; set; } + public string ResourceType { get; set; } + public string ResourceTypeScheme { get; set; } + public string Position { get; set; } + public string PositionScheme { get; set; } + public bool IsTrainee { get; set; } + public string HomeUnit { get; set; } + public string HostAgency { get; set; } + public string AgencyUnitId { get; set; } + public string PointOfHire { get; set; } + public string CostCode { get; set; } + public string AgreementReference { get; set; } + public string AssignedUserId { get; set; } + public int? AssignedUnitId { get; set; } + public string Status { get; set; } + public string DeclineReason { get; set; } + public DateTime? RequestedOn { get; set; } + public DateTime? NeededOn { get; set; } + public DateTime? FilledOn { get; set; } + public DateTime? MobilizedOn { get; set; } + public DateTime? CheckedInOn { get; set; } + public DateTime? AssignedOn { get; set; } + public DateTime? ReleasedOn { get; set; } + public DateTime? DemobilizedOn { get; set; } + public DateTime? ReturnedOn { get; set; } + public int? CapturedOffsetMinutes { get; set; } + public string Notes { get; set; } + public long RowVersion { get; set; } + } + + public class RecordDeploymentData + { + public string OrderId { get; set; } + public string RecordId { get; set; } + public string RecordNumber { get; set; } + public string RecordState { get; set; } + public string ProfileKey { get; set; } + public int ProfileVersion { get; set; } + public string HomeProfileKey { get; set; } + public string HostProfileKey { get; set; } + public string SourceScheme { get; set; } + public string SourceSystem { get; set; } + public string OrderNumber { get; set; } + public string IncidentName { get; set; } + public string IncidentNumber { get; set; } + public string IncidentCountry { get; set; } + public string IncidentSubdivision { get; set; } + public string OrderingOffice { get; set; } + public string DispatchOffice { get; set; } + public string RequestingAgency { get; set; } + public string ReceivingAgency { get; set; } + public string SendingAgency { get; set; } + public string DepartmentRole { get; set; } + public string CostCode { get; set; } + public string AgreementReference { get; set; } + public string CurrencyCode { get; set; } + public string MeasurementSystem { get; set; } + public string TimeZoneId { get; set; } + public int? CapturedOffsetMinutes { get; set; } + public DateTime? SourceCapturedOn { get; set; } + public string SourceVersion { get; set; } + public string ArtifactFileName { get; set; } + public string ArtifactContentType { get; set; } + public string ArtifactChecksum { get; set; } + public bool HasArtifact { get; set; } + public string ArtifactSafeUrl { get; set; } + public string Status { get; set; } + public DateTime? MobilizedOn { get; set; } + public DateTime? ReleasedOn { get; set; } + public DateTime? ClosedOutOn { get; set; } + public string CloseoutNotes { get; set; } + public bool AllReturned { get; set; } + public bool IsPreview { get; set; } = true; + public string ProvenanceStatement { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public string ETag { get; set; } + public List Fills { get; set; } = new List(); + } + + public class RecordDeploymentsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordDeploymentResult : StandardApiResponseV4Base + { + public RecordDeploymentData Data { get; set; } + } + + public class CreateRecordDeploymentInput + { + public string ProfileKey { get; set; } + public string HomeProfileKey { get; set; } + public string HostProfileKey { get; set; } + public string SourceScheme { get; set; } + public string SourceSystem { get; set; } + public string OrderNumber { get; set; } + public string IncidentName { get; set; } + public string IncidentNumber { get; set; } + public string IncidentCountry { get; set; } + public string IncidentSubdivision { get; set; } + public string OrderingOffice { get; set; } + public string DispatchOffice { get; set; } + public string RequestingAgency { get; set; } + public string ReceivingAgency { get; set; } + public string SendingAgency { get; set; } + public string DepartmentRole { get; set; } = "filling"; + public string CostCode { get; set; } + public string AgreementReference { get; set; } + public string CurrencyCode { get; set; } + public string MeasurementSystem { get; set; } + public string TimeZoneId { get; set; } + public int? CapturedOffsetMinutes { get; set; } + public DateTime? SourceCapturedOn { get; set; } + public string SourceVersion { get; set; } + public string ArtifactFileName { get; set; } + public string ArtifactContentType { get; set; } + /// Base64 of the order artifact (PDF, image, export); at most 25 MB decoded. + public string ArtifactBase64 { get; set; } + public string ArtifactSafeUrl { get; set; } + public int? StationGroupId { get; set; } + public string IdempotencyKey { get; set; } + public List Fills { get; set; } = new List(); + } + + public class RecordDeploymentSnapshotInput + { + public string SourceVersion { get; set; } + public string ArtifactFileName { get; set; } + public string ArtifactContentType { get; set; } + public string ArtifactBase64 { get; set; } + } + + public class CloseoutRecordDeploymentInput + { + public long RowVersion { get; set; } + public string Notes { get; set; } + } + + #endregion + + #region Reveal + + public class RecordRevealInput + { + public string Id { get; set; } + } + + public class RecordRevealData + { + public bool Success { get; set; } + public string Error { get; set; } + public Dictionary Fields { get; set; } = new Dictionary(); + } + + public class RecordRevealApiResult : StandardApiResponseV4Base + { + public RecordRevealData Data { get; set; } + } + + #endregion +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index b0944d41..f7fda8e8 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1638,6 +1638,42 @@ Gets calls and other data formatted for different feed formats, like RSS. + + + Field Records for the Responder, Unit, Incident Command and Dispatch apps (RMS plan RMS-1D): minimum-version + preflight, the FieldRecordCatalogV1 manifest, server-calculated prefill with provenance, the bounded sync + bundle, and work assignments. Every filter is derived server-side from the authenticated principal, the + department, the app flag and the verified context; a forged origin, context or capability value can only + narrow the response. Authoring itself stays on the Records controller. + + + + Whether this app, at this version, may show Records at all in this department. + + + The definitions this app may start right now, plus a coded reason for each one withheld. + + + Server-calculated prefill for one catalog entry; refused when that entry is not in this client's catalog. + + + + The bounded working set: catalog, authorized change delta with tombstones, the caller's own drafts and + returned Records, and their open assignments. A scope change answers ResetRequired instead of a page. + + + + The caller's open work queue, narrowed by assignment and re-authorized per Record. + + + Assignments on one Record; empty when the caller cannot read that Record. + + + The claimed origin, normalized: a non-field value is System, which every field gate refuses. + + + The body value when present, otherwise the standard app header. + User generated forms that are displayed to get custom information for New Calls, Unit Checks, etc @@ -2036,6 +2072,9 @@ One report with all sections, provenance facts, validation issues and sanitized submission history. Sets a weak ETag. + + Protected reveal of an incident report (RMS plan section 5.9.3): the grant travels in X-Resgrid-Protected-Grant. + The authoritative report for a Call, if one exists. @@ -2582,6 +2621,49 @@ while the Incident Commander/PIO has public sharing enabled. Disabling sharing revokes the token immediately. + + + Definition management and runtime catalog over v4 (RMS plan section 5.4, RMS-1B): template browse, clone/new + draft, ETag-guarded draft saves, validation, impact preview, publish, retire, history, safe diff and draft + migration. Reads need Record_View; authoring needs RecordDefinition_Update; publish/retire RecordDefinition_Publish. + Locked system definitions are listed but never editable here. + + + + Published department definitions with their schemas: what a client offers on New Record and renders against. + + + A product template rendered for a profile and locale: what a clone would start from, with its provenance statement. + + + ETag-guarded save of a draft version (send the version's RowVersion). A published version refuses edits. + + + Migrates compatible drafts to a newer version through an explicit mapping (Preview = true only counts). Finalized Records never move. + + + + The print layout that applies to one definition version (RMS plan section 4.10.1): the definition-scope config + when one is saved and applies, plus the branding block it resolves to and the composite layout version that + the provenance footer stamps. Locked system definitions resolve to the department default only. + + + + Saves the definition-scope print layout; every save is a new layout version the footer names. + + + + Create Deployment from External Order over v4 (RMS plan section 4.1 external-order fill contract, RMS-1C, + Preview). Manual entry and artifact snapshots only; no ordering-system connector and no write-back. Creating + or changing a deployment needs Record_Create; reading needs Record_View plus visibility of its Record. + + + + Moves one fill through accept/decline, mobilize, check-in, assign, release, demobilize and return. + + + Records a later snapshot of the same external order; the previous artifact stays on record as a superseded reference. + Evidence capture over the v4 Records contract (RMS plan section 4.5, RMS-3): readiness, run-card activation, @@ -2621,6 +2703,32 @@ An artifact is only as visible as the Record it supports. + + + Department report exports over v4 (RMS plan section 5.6, RMS-3e): the templates a Workflow step or the hourly + sweep (worker 45) renders for agencies without an API, their runs, and on-demand renders. Authoring needs + ManageRecordReports (checked by the service); every render is an Export audit against each record it contains. + + + + Creates (no TemplateId) or updates (TemplateId + RowVersion / If-Match). Narrative or restricted columns need AcknowledgeEgress. + + + Renders the template now and stores the run; the file comes from Download. TriggeringRecord scope needs RecordId. + + + The stored file of a run; under ADP the bytes are opened through the seam for this caller. + + + + Department saved reports over v4 (RMS plan section 4.1, RMS-1B): allowlisted typed columns, bounded filters, + one group-by and count/sum/avg/min/max. Managing needs RecordReport_Update; running needs Record_View and + honors the runner's group scope and restricted permission. + + + + Creates (no ReportId) or updates (ReportId + RowVersion / If-Match) a saved report. + Records (RMS) client contract, RMS-1B (plan sections 5.3, 5.4, 5.9.1): capability manifest with the locked @@ -2683,6 +2791,13 @@ One record with its working details, participants, units, attachment metadata and revision summaries. Sets a weak ETag. + + + Protected reveal (RMS plan section 5.9.3): the grant travels in X-Resgrid-Protected-Grant; the aggregate is + hydrated through the seam with it and the cataloged columns come back keyed "{table}.{column}:{rowId}". + A refused reveal answers Success = false with the step-up reason; never ciphertext. + + A revision rendered from its own snapshot; restricted fields withheld without RecordRestricted_View. @@ -2728,6 +2843,16 @@ Verifies size and SHA-256, then stores the attachment through hygiene and the scanner. 422 on checksum mismatch or rejection. + + + Compiles an authorized selection into one stored packet (compiled PDF or zip bundle; RMS plan section 4.7). + Every record renders from its pinned revision through the document service; unauthorized or unfinalized + selections are reported as skips, never silently dropped. Bulk void and bulk delete do not exist. + + + + Assigns one reviewer to every selected Record awaiting review; other states are reported as skips. + True when the caller is the relay key or a client_credentials service account, not a member. @@ -6056,6 +6181,12 @@ rather than the whole row disappearing, which would misrepresent the incident. + + Entity to DTO mapping for the RMS-1B/1C v4 surfaces. Restricted typed values are withheld here, never in the client. + + + Department definitions as the capability manifest lists them (plan 5.4), beside the locked ones. + v4 mapping for the RMS-3 surfaces: the incident-analysis filing, evidence artifacts, public-records @@ -12258,6 +12389,15 @@ Response Data + + : 2 Responder, 3 Unit, 4 IncidentCommand, 5 Dispatch. + + + records.v1 / records.v1b / records.v1c; an unknown value is treated as the oldest. + + + : 1 Person, 2 Unit, 3 Group, 4 CommandRole, 5 DispatchRole. + The NERIS incident-analysis filing (RMS-3): the fire/hazmat investigation posted separately from the @@ -12331,6 +12471,16 @@ without RecordRestricted_View gets the entry with those fields absent and named in WithheldFields. + + The stored section this entry replaces; omit for a new one. Sending it is what lets a client + reorder or remove sections without the save matching rows by list position. + + + The stored resource this entry replaces; omit for a new one. + + + The stored exposure this entry replaces; omit for a new one. + 409 on a stale incident report save. @@ -12383,6 +12533,9 @@ Weak ETag of the header row (W/"{RowVersion}"); send it back as If-Match or RowVersion on saves and commands. + + Department-definition typed values rendered against the pinned version (RMS-1B); null for locked system definitions. + Create or save a draft. Dates are UTC. Every list replaces the draft rows wholesale. @@ -12398,6 +12551,9 @@ Client-generated GUID for an offline-created draft; server-assigned when null. + + Typed values for a department definition (RMS-1B); ignored for locked system definitions. + RmsOriginClient: 2 Responder, 3 Unit, 4 IncidentCommand, 5 Dispatch, 6 Api (default). Field clients are gated by their Records.Field.* flag. @@ -12435,6 +12591,39 @@ Base64 chunk, at most ChunkSize bytes. + + Per-row rule outcomes for repeating sections (a field rule may look at its own row). + + + The print layout for one definition version (RMS plan section 4.10.1). + + + "{definition-key}/{n}" for the saved definition layout; null when none has been saved. + + + The saved definition-scope configuration, or null. + + + True when the saved layout applies to the requested definition version. + + + The composite layout version the provenance footer stamps for this version. + + + The branding block print resolves to (definition override or department default). + + + Bulk packet / bulk assign-for-review over an authorized selection (RMS plan section 4.7). + + + 1 = compiled PDF, 2 = zip bundle of per-record PDFs plus manifest.json. + + + The stored packet run (download through RecordExportTemplates/DownloadRun); null for an assignment. + + + Base64 of the order artifact (PDF, image, export); at most 25 MB decoded. + Whether one of the six evidence sources can produce anything for this department right now. "Unavailable diff --git a/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs b/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs index 71359605..79716693 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs @@ -357,6 +357,7 @@ private async Task BuildEditAsync(IncidentAnalysisAggr { model.Modules.Add(new IncidentModuleRow { + ModuleId = row.RmsIncidentModuleId, Kind = (int)kind, Included = true, PrimaryCode = row.PrimaryCode, SecondaryCode = row.SecondaryCode, Quantity = row.Quantity, QuantityUnit = row.QuantityUnit, OccurredOn = row.OccurredOn?.TimeConverter(department), DetailJson = row.DetailJson }); @@ -404,6 +405,7 @@ private IncidentAnalysisDraftInput BuildInput(IncidentAnalysisEditView model, De CurrencyCode = model.CurrencyCode, Modules = (model.Modules ?? new List()).Where(m => m.Included && m.Kind > 0).Select(m => new IncidentModuleInput { + ModuleId = m.ModuleId, Kind = (RmsIncidentModuleKind)m.Kind, PrimaryCode = m.PrimaryCode, SecondaryCode = m.SecondaryCode, Quantity = m.Quantity, QuantityUnit = m.QuantityUnit, OccurredOn = ToUtc(m.OccurredOn, department), DetailJson = m.DetailJson }).ToList(), diff --git a/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs index 3949ce03..93073c9c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs @@ -52,15 +52,17 @@ public class IncidentReportsController : SecureBaseController private readonly IRecordsProtectionService _protection; private readonly IProtectedGrantContext _grantContext; + private readonly IRecordsRevealService _reveal; public IncidentReportsController(IIncidentReportsService incidentReports, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService, ICallsService callsService, INerisProfileService neris, IRmsSubmissionsRepository submissions, IIncidentAnalysisService analysis, IRecordsEvidenceService evidence, IStringLocalizer localizer, IRecordsSubmissionService submissionWorker, IIncidentAttachmentsService attachments, IRecordsUdfService udf, - IRecordsNfirsLegacyService nfirs, IRecordsProtectionService protection, IProtectedGrantContext grantContext) + IRecordsNfirsLegacyService nfirs, IRecordsProtectionService protection, IProtectedGrantContext grantContext, IRecordsRevealService reveal) { _protection = protection; _grantContext = grantContext; + _reveal = reveal; _nfirs = nfirs; _incidentReports = incidentReports; _cutoverService = cutoverService; @@ -281,6 +283,9 @@ public async Task NfirsLegacy(int callId) if (rendering == null) return NotFound(); + if (!string.IsNullOrWhiteSpace(rendering.IncidentReportId)) + await _incidentReports.RecordAccessAsync(DepartmentId, UserId, rendering.IncidentReportId, null, RmsAccessAuditAction.Read, "NFIRS legacy rendering"); + return View(new NfirsLegacyView { Rendering = rendering, @@ -316,34 +321,11 @@ public async Task RevealIncident([FromForm] string id) if (aggregate == null) return NotFound(); - var protection = aggregate.Protection ?? new ProtectedReadResult(); - if (protection.IsProtected && protection.ProtectedReason != null) - return Json(new { success = false, error = protection.ProtectedReason }); - - var fields = new Dictionary(); - void Add(IEnumerable rows, Func key, IReadOnlyDictionary Get, Action Set)> accessors) - { - foreach (var row in rows ?? Enumerable.Empty()) - foreach (var accessor in accessors) - fields[$"{accessor.Key}:{key(row)}"] = accessor.Value.Get(row); - } - if (aggregate.Narrative != null) Add(new[] { aggregate.Narrative }, n => n.RmsNarrativeId, RmsProtectedFields.Narratives); - if (aggregate.Location != null) - { - Add(new[] { aggregate.Location }, l => l.RmsLocationId, RmsProtectedFields.Locations); - fields[$"rmslocations.coordinates:{aggregate.Location.RmsLocationId}"] = aggregate.Location.Latitude.HasValue ? aggregate.Location.Latitude + ", " + aggregate.Location.Longitude : "-"; - } - Add(aggregate.Facts, f => f.RmsSourceFactId, RmsProtectedFields.SourceFacts); - Add(aggregate.Exposures, e => e.RmsExposureId, RmsProtectedFields.Exposures); - Add(aggregate.Resources, r => r.RmsIncidentResourceId, RmsProtectedFields.Resources); - Add(aggregate.Modules, m => m.RmsIncidentModuleId, RmsProtectedFields.Modules); - if (await CanViewRestrictedAsync()) - Add(aggregate.Casualties, c => c.RmsCasualtyRescueId, RmsProtectedFields.Casualties); - foreach (var attachment in aggregate.Attachments ?? new List()) - fields[$"rmsrecordattachments.filename:{attachment.RmsRecordAttachmentId}"] = attachment.FileName; - - await _incidentReports.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Read, "Protected reveal", IpAddressHelper.GetRequestIP(Request, true)); - return Json(new { success = true, fields }); + // Shared with the v4 Reveal endpoint (IRecordsRevealService): same keys, same withholding, same audit. + var outcome = await _reveal.RevealIncidentAsync(DepartmentId, UserId, aggregate, await CanViewRestrictedAsync(), IpAddressHelper.GetRequestIP(Request, true)); + if (!outcome.Success) + return Json(new { success = false, error = outcome.Error }); + return Json(new { success = true, fields = outcome.Fields }); } /// The immutable submission artifact (the exact payload sent), for administrators auditing a delivery. @@ -997,6 +979,7 @@ private async Task BuildSectionsAsync(IncidentReportEditView model, IncidentRepo { model.Modules.Add(new IncidentModuleRow { + ModuleId = row.RmsIncidentModuleId, Kind = (int)kind, Included = true, PrimaryCode = row.PrimaryCode, SecondaryCode = row.SecondaryCode, Quantity = row.Quantity, QuantityUnit = row.QuantityUnit, OccurredOn = row.OccurredOn?.TimeConverter(model.Department), DetailJson = row.DetailJson }); @@ -1008,7 +991,7 @@ private async Task BuildSectionsAsync(IncidentReportEditView model, IncidentRepo } model.Resources = aggregate.Resources.OrderBy(r => r.Ordinal) - .Select(r => new IncidentResourceRow { ResourceCode = r.ResourceCode, Quantity = r.Quantity, Detail = r.Detail }).ToList(); + .Select(r => new IncidentResourceRow { ResourceId = r.RmsIncidentResourceId, ResourceCode = r.ResourceCode, Quantity = r.Quantity, Detail = r.Detail }).ToList(); model.Casualties = aggregate.Casualties.OrderBy(c => c.Ordinal).Select(c => new IncidentCasualtyRow { @@ -1022,6 +1005,7 @@ private async Task BuildSectionsAsync(IncidentReportEditView model, IncidentRepo model.Exposures = aggregate.Exposures.OrderBy(e => e.Ordinal).Select(e => new IncidentExposureRow { + ExposureId = e.RmsExposureId, Included = true, LocationKind = e.LocationKind, ItemType = e.ItemType, DamageType = e.DamageType, LocationUse = e.LocationUse, PeoplePresent = e.PeoplePresent, DisplacementCount = e.DisplacementCount, DisplacementCauses = Split(e.DisplacementCausesCsv), AddressText = e.AddressText, Street = e.Street, Municipality = e.Municipality, State = e.State, PostalCode = e.PostalCode, Latitude = e.Latitude, Longitude = e.Longitude, @@ -1072,11 +1056,12 @@ private IncidentReportDraftInput BuildInput(IncidentReportEditView model, Depart // means the author removed the rows, not that the client could not show them. Modules = (model.Modules ?? new List()).Where(m => m.Included && m.Kind > 0).Select(m => new IncidentModuleInput { + ModuleId = m.ModuleId, Kind = (RmsIncidentModuleKind)m.Kind, PrimaryCode = m.PrimaryCode, SecondaryCode = m.SecondaryCode, Quantity = m.Quantity, QuantityUnit = m.QuantityUnit, OccurredOn = ToUtc(m.OccurredOn, department), DetailJson = m.DetailJson }).ToList(), Resources = (model.Resources ?? new List()).Where(r => !string.IsNullOrWhiteSpace(r.ResourceCode)) - .Select(r => new IncidentResourceInput { ResourceCode = r.ResourceCode, Quantity = r.Quantity, Detail = r.Detail }).ToList(), + .Select(r => new IncidentResourceInput { ResourceId = r.ResourceId, ResourceCode = r.ResourceCode, Quantity = r.Quantity, Detail = r.Detail }).ToList(), Casualties = (model.Casualties ?? new List()).Where(c => c.Included).Select(c => c.Guided ? IncidentGuidedFormMapper.Casualty(c, ToUtc(c.OccurredOn, department)) : new IncidentCasualtyRescueInput { CasualtyId = c.CasualtyId, Kind = (RmsCasualtyRescueKind)c.Kind, PersonType = c.PersonType, PersonnelUserId = c.PersonnelUserId, Rank = c.Rank, YearsOfService = c.YearsOfService, diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordDefinitionsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordDefinitionsController.cs new file mode 100644 index 00000000..2859ac7c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordDefinitionsController.cs @@ -0,0 +1,338 @@ +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.Services.Records; +using Resgrid.Web.Areas.User.Models.Records; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// The controlled definition designer (RMS plan sections 4.1 and 5.4, RMS-1B): template browse, clone or blank + /// draft, policy and schema editing with server validation, impact preview, publish, retire, history, diff and + /// draft migration. Managing needs ManageRecordDefinitions; publishing and retiring need PublishRecordDefinitions. + /// + [Area("User")] + [Authorize(Policy = ResgridResources.RecordDefinition_Update)] + public class RecordDefinitionsController : SecureBaseController + { + private readonly IRecordDefinitionsService _definitions; + private readonly IRecordTemplatePacksService _templates; + private readonly IRecordsCutoverService _cutover; + private readonly IRecordsAuthorizationService _authorization; + private readonly IDepartmentsService _departments; + private readonly IPersonnelRolesService _roles; + private readonly IStringLocalizer _localizer; + private readonly IRecordsPrintLayoutService _printLayouts; + + public RecordDefinitionsController(IRecordDefinitionsService definitions, IRecordTemplatePacksService templates, IRecordsCutoverService cutover, IRecordsAuthorizationService authorization, + IDepartmentsService departments, IPersonnelRolesService roles, IStringLocalizer localizer, IRecordsPrintLayoutService printLayouts) + { + _printLayouts = printLayouts; + _definitions = definitions; + _templates = templates; + _cutover = cutover; + _authorization = authorization; + _departments = departments; + _roles = roles; + _localizer = localizer; + } + + [HttpGet] + public async Task Index(bool includeRetired = false) + { + if (!await RequireManageAsync()) return Forbid(); + var moduleState = await _cutover.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) return NotFound(); + var model = new RecordDefinitionsIndexView + { + ModuleState = moduleState, Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false), IncludeRetired = includeRetired, + Definitions = await _definitions.ListAsync(DepartmentId, includeRetired), CanPublish = await CanPublishAsync() + }; + ReadTempData(model); + return View(model); + } + + [HttpGet] + public async Task Templates() + { + if (!await RequireManageAsync()) return Forbid(); + var model = new RecordTemplatesView { Packs = await _templates.GetCatalogAsync(), Profiles = await _templates.GetProfilesAsync() }; + ReadTempData(model); + return View(model); + } + + [HttpGet] + public async Task Create(string templateKey, string cloneFrom, string profile = "generic", string locale = null) + { + if (!await RequireManageAsync()) return Forbid(); + var model = new RecordDefinitionCreateView { TemplateKey = templateKey, CloneFromDefinitionKey = cloneFrom, JurisdictionProfileKey = string.IsNullOrWhiteSpace(profile) ? "generic" : profile, Locale = locale }; + await PopulateCreateAsync(model); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(RecordDefinitionCreateView model, CancellationToken cancellationToken) + { + if (!await RequireManageAsync()) return Forbid(); + try + { + var aggregate = await _definitions.CreateAsync(DepartmentId, UserId, new RecordDefinitionCreateInput + { + DefinitionKey = model.DefinitionKey, Name = model.Name, Category = model.Category, TemplateKey = model.TemplateKey, CloneFromDefinitionKey = model.CloneFromDefinitionKey, JurisdictionProfileKey = model.JurisdictionProfileKey, Locale = model.Locale + }, cancellationToken); + TempData["RecordsMessage"] = _localizer["DefinitionCreated"].Value; + return RedirectToAction("Edit", new { key = aggregate.Definition.DefinitionKey, version = aggregate.Latest.Version }); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + model.ErrorMessage = ex.Message; + await PopulateCreateAsync(model); + return View(model); + } + } + + [HttpGet] + public async Task Edit(string key, int? version) + { + if (!await RequireManageAsync()) return Forbid(); + var aggregate = await _definitions.GetAsync(DepartmentId, key); + if (aggregate == null) return NotFound(); + var row = version.HasValue ? aggregate.Versions.FirstOrDefault(v => v.Version == version) : aggregate.Draft ?? aggregate.Published ?? aggregate.Latest; + if (row == null) return NotFound(); + var model = RecordDefinitionEditView.From(aggregate, row); + await PopulateEditAsync(model); + var validation = await _definitions.ValidateAsync(DepartmentId, model.ToDraftInput()); + model.Issues = validation.Issues; + ReadTempData(model); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(RecordDefinitionEditView model, string action, CancellationToken cancellationToken) + { + if (!await RequireManageAsync()) return Forbid(); + var aggregate = await _definitions.GetAsync(DepartmentId, model.DefinitionKey); + if (aggregate == null) return NotFound(); + model.Aggregate = aggregate; + model.VersionRow = aggregate.Versions.FirstOrDefault(v => v.Version == model.Version); + try + { + var input = model.ToDraftInput(); + if (string.Equals(action, "validate", StringComparison.OrdinalIgnoreCase)) + { + var validation = await _definitions.ValidateAsync(DepartmentId, input); + model.Issues = validation.Issues; + model.MinimumClientCapability = validation.MinimumClientCapability; + model.Message = validation.IsValid ? _localizer["DefinitionValid"].Value : _localizer["DefinitionInvalid"].Value; + await PopulateEditAsync(model); + return View(model); + } + var saved = await _definitions.SaveDraftAsync(DepartmentId, UserId, model.DefinitionKey, model.Version, model.RowVersion, input, cancellationToken); + TempData["RecordsMessage"] = _localizer["DefinitionSaved"].Value; + return RedirectToAction("Edit", new { key = model.DefinitionKey, version = saved.Version }); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (RecordConcurrencyException) { model.ErrorMessage = _localizer["ConcurrencyError"].Value; } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException || ex is Newtonsoft.Json.JsonException) { model.ErrorMessage = ex.Message; } + model.Schema = SafeParse(model.SchemaJson); + await PopulateEditAsync(model); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task OpenDraft(string key, CancellationToken cancellationToken) + { + if (!await RequireManageAsync()) return Forbid(); + try + { + var draft = await _definitions.OpenDraftAsync(DepartmentId, UserId, key, cancellationToken); + return RedirectToAction("Edit", new { key, version = draft.Version }); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; return RedirectToAction("Index"); } + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DeleteDraft(string key, int version, CancellationToken cancellationToken) + { + if (!await RequireManageAsync()) return Forbid(); + try + { + await _definitions.DeleteDraftAsync(DepartmentId, UserId, key, version, cancellationToken); + TempData["RecordsMessage"] = _localizer["DefinitionDraftDeleted"].Value; + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Index"); + } + + [HttpGet] + public async Task Impact(string key, int version) + { + if (!await RequireManageAsync()) return Forbid(); + var aggregate = await _definitions.GetAsync(DepartmentId, key); + var row = aggregate?.Versions.FirstOrDefault(v => v.Version == version); + if (row == null) return NotFound(); + var model = new RecordDefinitionImpactView { Aggregate = aggregate, VersionRow = row, Preview = await _definitions.ImpactPreviewAsync(DepartmentId, key, version), CanPublish = await CanPublishAsync() }; + if (aggregate.Published != null && aggregate.Published.Version != version) + model.Diff = await _definitions.DiffAsync(DepartmentId, key, aggregate.Published.Version, version); + ReadTempData(model); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.RecordDefinition_Publish)] + public async Task Publish(string key, int version, long rowVersion, CancellationToken cancellationToken) + { + if (!await CanPublishAsync()) return Forbid(); + try + { + await _definitions.PublishAsync(DepartmentId, UserId, key, version, rowVersion, cancellationToken); + TempData["RecordsMessage"] = string.Format(_localizer["DefinitionPublished"].Value, version); + return RedirectToAction("Edit", new { key, version }); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (RecordConcurrencyException) { TempData["RecordsError"] = _localizer["ConcurrencyError"].Value; } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Impact", new { key, version }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.RecordDefinition_Publish)] + public async Task Retire(string key, long rowVersion, string reason, CancellationToken cancellationToken) + { + if (!await CanPublishAsync()) return Forbid(); + try + { + await _definitions.RetireAsync(DepartmentId, UserId, key, rowVersion, reason, cancellationToken); + TempData["RecordsMessage"] = _localizer["DefinitionRetired"].Value; + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (RecordConcurrencyException) { TempData["RecordsError"] = _localizer["ConcurrencyError"].Value; } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Index"); + } + + [HttpGet] + public async Task History(string key, int? from, int? to) + { + if (!await RequireManageAsync()) return Forbid(); + var aggregate = await _definitions.GetAsync(DepartmentId, key); + if (aggregate == null) return NotFound(); + var model = new RecordDefinitionHistoryView + { + Aggregate = aggregate, Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false), Versions = await _definitions.HistoryAsync(DepartmentId, key), From = from, To = to, CanManage = true + }; + if (from.HasValue && to.HasValue) + { + try { model.Diff = await _definitions.DiffAsync(DepartmentId, key, from.Value, to.Value); } + catch (ArgumentException ex) { model.ErrorMessage = ex.Message; } + } + ReadTempData(model); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Migrate(string key, int from, int to, bool preview, string mappingJson, CancellationToken cancellationToken) + { + if (!await RequireManageAsync()) return Forbid(); + try + { + var mapping = string.IsNullOrWhiteSpace(mappingJson) ? new List() : Newtonsoft.Json.JsonConvert.DeserializeObject>(mappingJson) ?? new List(); + var result = await _definitions.MigrateDraftsAsync(DepartmentId, UserId, key, from, to, mapping, preview, cancellationToken); + TempData["RecordsMessage"] = string.Format(_localizer[preview ? "DefinitionMigrationPreview" : "DefinitionMigrationDone"].Value, result.Migrated, result.Skipped, result.UnmappedFieldKeys.Count == 0 ? "-" : string.Join(", ", result.UnmappedFieldKeys)); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException || ex is Newtonsoft.Json.JsonException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("History", new { key, from, to }); + } + + private async Task PopulateCreateAsync(RecordDefinitionCreateView model) + { + var profiles = await _templates.GetProfilesAsync(); + model.Profiles = profiles.Select(p => new SelectListItem { Value = p.ProfileKey, Text = p.Name }).ToList(); + model.Locales = new[] { "en-US", "en-CA", "fr-CA" }.Select(l => new SelectListItem { Value = l, Text = l }).ToList(); + model.Templates = (await _templates.GetCatalogAsync()).SelectMany(p => p.Definitions.Select(d => new SelectListItem { Value = d.Key, Text = p.Name + " — " + d.Name + (d.IsPreview ? " (Preview)" : string.Empty) })).ToList(); + model.DepartmentDefinitions = (await _definitions.ListAsync(DepartmentId)).Where(d => !d.Locked).Select(d => new SelectListItem { Value = d.Key, Text = d.Name }).ToList(); + if (!string.IsNullOrWhiteSpace(model.TemplateKey)) + { + try { model.Rendering = await _templates.RenderAsync(model.TemplateKey, model.JurisdictionProfileKey, model.Locale); } + catch (ArgumentException ex) { model.ErrorMessage = ex.Message; } + if (model.Rendering != null && string.IsNullOrWhiteSpace(model.Name)) model.Name = model.Rendering.Template.Name; + if (model.Rendering != null && string.IsNullOrWhiteSpace(model.DefinitionKey)) model.DefinitionKey = model.Rendering.Template.Key.Replace("template.", string.Empty).Replace("pack.", string.Empty); + } + } + + [HttpGet] + public async Task Layout(string key) + { + if (!await RequireManageAsync()) return Forbid(); + var aggregate = await _definitions.GetAsync(DepartmentId, key); + if (aggregate == null || aggregate.Definition.Owner == (int)RmsDefinitionOwner.System) return NotFound(); + var version = aggregate.Published ?? aggregate.Latest ?? aggregate.Draft; + if (version == null) return NotFound(); + var stored = await _printLayouts.GetDefinitionLayoutAsync(DepartmentId, aggregate.Definition.DefinitionKey); + var department = await _printLayouts.GetDepartmentDefaultAsync(DepartmentId); + var model = RecordDefinitionLayoutView.From(aggregate, version, stored, department.LayoutVersion); + ReadTempData(model); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Layout(RecordDefinitionLayoutView model, CancellationToken cancellationToken) + { + if (!await RequireManageAsync()) return Forbid(); + var aggregate = await _definitions.GetAsync(DepartmentId, model.DefinitionKey); + if (aggregate == null || aggregate.Definition.Owner == (int)RmsDefinitionOwner.System) return NotFound(); + try + { + await _printLayouts.SaveDefinitionLayoutAsync(DepartmentId, UserId, aggregate.Definition.DefinitionKey, model.ToConfig(), cancellationToken); + TempData["RecordsMessage"] = _localizer["LayoutSaved"].Value; + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Layout", new { key = aggregate.Definition.DefinitionKey }); + } + + private async Task PopulateEditAsync(RecordDefinitionEditView model) + { + model.Roles = ((await _roles.GetRolesForDepartmentAsync(DepartmentId)) ?? new List()).OrderBy(r => r.Name).Select(r => new SelectListItem { Value = r.PersonnelRoleId.ToString(), Text = r.Name }).ToList(); + model.CanPublish = await CanPublishAsync(); + model.Schema ??= SafeParse(model.SchemaJson); + if (model.Aggregate?.Definition.TemplateKey != null) + model.TemplateDiff = _templates.DiffAgainstTemplate(model.Aggregate.Definition.TemplateKey, model.Aggregate.Definition.JurisdictionProfileKey, null, model.Schema, model.DefinitionKey, model.Version); + } + + private static RecordDefinitionSchema SafeParse(string json) + { + try { return RecordDefinitionSchema.Parse(json); } catch (Newtonsoft.Json.JsonException) { return new RecordDefinitionSchema(); } + } + + private void ReadTempData(RecordsBaseView model) + { + if (TempData["RecordsMessage"] is string message) model.Message = message; + if (TempData["RecordsError"] is string error) model.ErrorMessage = error; + } + + private Task RequireManageAsync() => _authorization.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ManageRecordDefinitions); + private async Task CanPublishAsync() => ClaimsAuthorizationHelper.CanPublishRecordDefinitions() && await _authorization.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.PublishRecordDefinitions); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs new file mode 100644 index 00000000..8a901e30 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +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 +{ + /// + /// Create Deployment from External Order (RMS plan section 4.1, RMS-1C, Preview): the coordinator records the + /// external order and the requests the department fills, then walks each fill through mobilization, release and + /// the actual return home. Manual entry and artifact snapshots only; no ordering-system connector. + /// + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordDeploymentsController : SecureBaseController + { + private readonly IRecordDeploymentsService _deployments; + private readonly IRecordsCutoverService _cutover; + private readonly IDepartmentsService _departments; + private readonly IDepartmentGroupsService _groups; + private readonly IUnitsService _units; + private readonly IStringLocalizer _localizer; + + public RecordDeploymentsController(IRecordDeploymentsService deployments, IRecordsCutoverService cutover, IDepartmentsService departments, IDepartmentGroupsService groups, IUnitsService units, + IStringLocalizer localizer) + { + _deployments = deployments; + _cutover = cutover; + _departments = departments; + _groups = groups; + _units = units; + _localizer = localizer; + } + + [HttpGet] + public async Task Index(bool includeClosed = false) + { + if (!(await _cutover.GetModuleStateAsync(DepartmentId)).FlagEnabled) return NotFound(); + var model = new RecordDeploymentsIndexView { Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false), Orders = await _deployments.ListAsync(DepartmentId, UserId, includeClosed), IncludeClosed = includeClosed, CanCreate = ClaimsAuthorizationHelper.CanCreateRecord() }; + if (TempData["RecordsMessage"] is string message) model.Message = message; + if (TempData["RecordsError"] is string error) model.ErrorMessage = error; + return View(model); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task New() + { + if (!(await _cutover.GetModuleStateAsync(DepartmentId)).RecordsUsable) return NotFound(); + var model = new RecordDeploymentNewView(); + await PopulateAsync(model); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task New(RecordDeploymentNewView model, IFormFile artifact, CancellationToken cancellationToken) + { + if (!(await _cutover.GetModuleStateAsync(DepartmentId)).RecordsUsable) return NotFound(); + try + { + var input = new RecordDeploymentCreateInput + { + ProfileKey = model.ProfileKey, SourceScheme = model.SourceScheme, SourceSystem = model.SourceSystem, OrderNumber = model.OrderNumber, IncidentName = model.IncidentName, IncidentNumber = model.IncidentNumber, + IncidentCountry = model.IncidentCountry, IncidentSubdivision = model.IncidentSubdivision, OrderingOffice = model.OrderingOffice, DispatchOffice = model.DispatchOffice, RequestingAgency = model.RequestingAgency, + ReceivingAgency = model.ReceivingAgency, SendingAgency = model.SendingAgency, DepartmentRole = model.DepartmentRole, CostCode = model.CostCode, AgreementReference = model.AgreementReference, + CurrencyCode = string.IsNullOrWhiteSpace(model.CurrencyCode) ? null : model.CurrencyCode, MeasurementSystem = string.IsNullOrWhiteSpace(model.MeasurementSystem) ? null : model.MeasurementSystem, TimeZoneId = model.TimeZoneId, + SourceCapturedOn = model.SourceCapturedOn, SourceVersion = model.SourceVersion, ArtifactSafeUrl = model.ArtifactSafeUrl, StationGroupId = model.StationGroupId, OriginClient = RmsOriginClient.Web, + Fills = (model.Fills ?? new List()).Where(f => f != null && !string.IsNullOrWhiteSpace(f.RequestNumber)).ToList() + }; + if (artifact != null && artifact.Length > 0) + { + using var stream = new MemoryStream(); + await artifact.CopyToAsync(stream, cancellationToken); + input.ArtifactData = stream.ToArray(); input.ArtifactFileName = Path.GetFileName(artifact.FileName); input.ArtifactContentType = artifact.ContentType; + } + var created = await _deployments.CreateFromExternalOrderAsync(DepartmentId, UserId, input, cancellationToken); + TempData["RecordsMessage"] = _localizer["DeploymentCreated"].Value; + return RedirectToAction("Details", new { id = created.Order.RmsExternalOrderId }); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + model.ErrorMessage = ex.Message; + await PopulateAsync(model); + return View(model); + } + } + + [HttpGet] + public async Task Details(string id) + { + var model = await BuildDetailsAsync(id); + if (model == null) return NotFound(); + if (TempData["RecordsMessage"] is string message) model.Message = message; + if (TempData["RecordsError"] is string error) model.ErrorMessage = error; + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task AddFill(string id, RecordDeploymentFillInput newFill, CancellationToken cancellationToken) + { + try + { + await _deployments.AddFillAsync(DepartmentId, UserId, id, newFill, cancellationToken); + TempData["RecordsMessage"] = _localizer["DeploymentFillAdded"].Value; + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Details", new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task Transition(string id, string fillId, int status, DateTime? occurredOn, string reason, string notes, CancellationToken cancellationToken) + { + try + { + await _deployments.TransitionFillAsync(DepartmentId, UserId, fillId, new RecordDeploymentFillTransitionInput { Status = (RmsDeploymentFillStatus)status, OccurredOn = occurredOn?.ToUniversalTime(), Reason = reason, Notes = notes }, cancellationToken); + TempData["RecordsMessage"] = _localizer["DeploymentFillUpdated"].Value; + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Details", new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task Snapshot(string id, string sourceVersion, IFormFile artifact, CancellationToken cancellationToken) + { + try + { + if (artifact == null || artifact.Length == 0) throw new ArgumentException(_localizer["DeploymentSnapshotNeedsFile"].Value); + using var stream = new MemoryStream(); + await artifact.CopyToAsync(stream, cancellationToken); + await _deployments.RecordSourceSnapshotAsync(DepartmentId, UserId, id, sourceVersion, stream.ToArray(), Path.GetFileName(artifact.FileName), artifact.ContentType, cancellationToken); + TempData["RecordsMessage"] = _localizer["DeploymentSnapshotRecorded"].Value; + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Details", new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task Closeout(string id, long rowVersion, string notes, CancellationToken cancellationToken) + { + try + { + await _deployments.CloseoutAsync(DepartmentId, UserId, id, rowVersion, notes, cancellationToken); + TempData["RecordsMessage"] = _localizer["DeploymentClosedOut"].Value; + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (RecordConcurrencyException) { TempData["RecordsError"] = _localizer["ConcurrencyError"].Value; } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Details", new { id }); + } + + [HttpGet] + public async Task Artifact(string id) + { + try + { + var aggregate = await _deployments.GetAsync(DepartmentId, UserId, id, true); + if (aggregate?.Order?.ArtifactData == null) return NotFound(); + return File(aggregate.Order.ArtifactData, string.IsNullOrWhiteSpace(aggregate.Order.ArtifactContentType) ? "application/octet-stream" : aggregate.Order.ArtifactContentType, aggregate.Order.ArtifactFileName ?? "order-artifact"); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + private async Task BuildDetailsAsync(string id) + { + RecordDeploymentAggregate aggregate; + try { aggregate = await _deployments.GetAsync(DepartmentId, UserId, id); } + catch (UnauthorizedAccessException) { return null; } + if (aggregate == null) return null; + var names = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); + var units = await _units.GetUnitsForDepartmentAsync(DepartmentId) ?? new List(); + return new RecordDeploymentDetailsView + { + Deployment = aggregate, Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false), + PersonnelNames = names.GroupBy(n => n.UserId).ToDictionary(g => g.Key, g => g.First().Name), CanEdit = ClaimsAuthorizationHelper.CanCreateRecord() && aggregate.Order.Status != (int)RmsExternalOrderStatus.ClosedOut, + ProvenanceStatement = _localizer["DeploymentPreviewStatement"].Value, + Personnel = names.OrderBy(n => n.Name).Select(n => new SelectListItem { Value = n.UserId, Text = n.Name }).ToList(), + AvailableUnits = units.OrderBy(u => u.Name).Select(u => new SelectListItem { Value = u.UnitId.ToString(), Text = u.Name }).ToList() + }; + } + + private async Task PopulateAsync(RecordDeploymentNewView model) + { + model.Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false); + model.Profiles = RmsDeploymentProfiles.All.Select(p => new SelectListItem { Value = p, Text = p }).ToList(); + var groups = await _groups.GetAllGroupsForDepartmentAsync(DepartmentId) ?? new List(); + model.Stations = groups.OrderBy(g => g.Name).Select(g => new SelectListItem { Value = g.DepartmentGroupId.ToString(), Text = g.Name }).ToList(); + var names = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); + model.Personnel = names.OrderBy(n => n.Name).Select(n => new SelectListItem { Value = n.UserId, Text = n.Name }).ToList(); + var units = await _units.GetUnitsForDepartmentAsync(DepartmentId) ?? new List(); + model.AvailableUnits = units.OrderBy(u => u.Name).Select(u => new SelectListItem { Value = u.UnitId.ToString(), Text = u.Name }).ToList(); + while (model.Fills.Count < 3) model.Fills.Add(new RecordDeploymentFillInput()); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordEvidenceController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordEvidenceController.cs index 49a5514b..712b258d 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordEvidenceController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordEvidenceController.cs @@ -82,7 +82,8 @@ public async Task Capture(RecordEvidenceForm input, CancellationT if (context.RowVersion != input.RowVersion.Value) throw new RecordConcurrencyException(input.RecordId, input.RowVersion.Value, context.RowVersion); if (input.SourceKind == RmsEvidenceKind.TrackingFix && input.UnitIds?.Count is not > 0 || input.SourceKind == RmsEvidenceKind.CertificationSnapshot && input.UserIds?.Count is not > 0 - || input.SourceKind == RmsEvidenceKind.ChatPromotion && input.SourceIds?.Count is not > 0) return BadRequest("Select at least one source item."); + || input.SourceKind == RmsEvidenceKind.ChatPromotion && input.SourceIds?.Count is not > 0 + || input.SourceKind == RmsEvidenceKind.ModuleProjection && input.SourceIds?.Count != 1) return BadRequest("Select at least one source item."); if (input.SourceKind == RmsEvidenceKind.TrackingFix && (!input.StartUtc.HasValue || !input.EndUtc.HasValue)) return BadRequest("Enter both UTC tracking times."); if (input.SourceKind == RmsEvidenceKind.CertificationSnapshot && !input.EndUtc.HasValue) return BadRequest("Enter the UTC incident time for certification validity."); await _evidence.CaptureAsync(new RecordEvidenceCaptureRequest { DepartmentId = DepartmentId, CapturedByUserId = UserId, diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs new file mode 100644 index 00000000..579ac736 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +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 +{ + /// + /// Department saved reports (RMS plan section 4.1, RMS-1B): allowlisted typed columns, bounded filters, one + /// group-by, count/sum/avg/min/max. Managing needs ManageRecordReports; running honors the runner's group scope. + /// + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordSavedReportsController : SecureBaseController + { + private readonly IRecordSavedReportsService _reports; + private readonly IRecordDefinitionsService _definitions; + private readonly IRecordsCutoverService _cutover; + private readonly IRecordsAuthorizationService _authorization; + private readonly IDepartmentsService _departments; + private readonly IStringLocalizer _localizer; + + public RecordSavedReportsController(IRecordSavedReportsService reports, IRecordDefinitionsService definitions, IRecordsCutoverService cutover, IRecordsAuthorizationService authorization, + IDepartmentsService departments, IStringLocalizer localizer) + { + _reports = reports; + _definitions = definitions; + _cutover = cutover; + _authorization = authorization; + _departments = departments; + _localizer = localizer; + } + + [HttpGet] + public async Task Index() + { + if (!(await _cutover.GetModuleStateAsync(DepartmentId)).FlagEnabled) return NotFound(); + var model = new RecordSavedReportsIndexView { Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false), Reports = await _reports.GetForDepartmentAsync(DepartmentId), CanManage = await CanManageAsync() }; + if (TempData["RecordsMessage"] is string message) model.Message = message; + if (TempData["RecordsError"] is string error) model.ErrorMessage = error; + return View(model); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.RecordReport_Update)] + public async Task Edit(string id, string definitionKey) + { + if (!await CanManageAsync()) return Forbid(); + RecordSavedReportEditView model; + if (string.IsNullOrWhiteSpace(id)) + model = new RecordSavedReportEditView { DefinitionKey = definitionKey }; + else + { + var report = await _reports.GetAsync(DepartmentId, id); + if (report == null) return NotFound(); + model = RecordSavedReportEditView.From(report); + } + await PopulateAsync(model); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.RecordReport_Update)] + public async Task Edit(RecordSavedReportEditView model, string action, CancellationToken cancellationToken) + { + if (!await CanManageAsync()) return Forbid(); + try + { + var report = model.ToReport(); + if (string.Equals(action, "validate", StringComparison.OrdinalIgnoreCase)) + { + var validation = await _reports.ValidateAsync(DepartmentId, report); + model.Issues = validation.Issues; + model.Message = validation.IsValid ? _localizer["ReportValid"].Value : _localizer["ReportInvalid"].Value; + await PopulateAsync(model); + return View(model); + } + var saved = await _reports.SaveAsync(DepartmentId, UserId, report, cancellationToken); + TempData["RecordsMessage"] = _localizer["ReportSaved"].Value; + return RedirectToAction("Edit", new { id = saved.RmsSavedReportDefinitionId }); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (RecordConcurrencyException) { model.ErrorMessage = _localizer["ConcurrencyError"].Value; } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException || ex is Newtonsoft.Json.JsonException) { model.ErrorMessage = ex.Message; } + await PopulateAsync(model); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.RecordReport_Update)] + public async Task Delete(string id, CancellationToken cancellationToken) + { + if (!await CanManageAsync()) return Forbid(); + try + { + await _reports.DeleteAsync(DepartmentId, UserId, id, cancellationToken); + TempData["RecordsMessage"] = _localizer["ReportDeleted"].Value; + } + catch (UnauthorizedAccessException) { return Forbid(); } + return RedirectToAction("Index"); + } + + [HttpGet] + public async Task Run(string id, CancellationToken cancellationToken) + { + var report = await _reports.GetAsync(DepartmentId, id); + if (report == null) return NotFound(); + var model = new RecordSavedReportRunView { Report = report, Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false) }; + try { model.Result = await _reports.RunAsync(DepartmentId, UserId, id, cancellationToken); } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { model.ErrorMessage = ex.Message; } + return View(model); + } + + [HttpGet] + public async Task RunCsv(string id, CancellationToken cancellationToken) + { + try + { + var result = await _reports.RunAsync(DepartmentId, UserId, id, cancellationToken); + return File(Encoding.UTF8.GetBytes(_reports.ToCsv(result)), "text/csv", (result.Name ?? "report").Replace(' ', '-') + ".csv"); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; return RedirectToAction("Index"); } + } + + private async Task PopulateAsync(RecordSavedReportEditView model) + { + model.Definitions = (await _definitions.ListAsync(DepartmentId)).Where(d => !d.Locked && d.PublishedVersion.HasValue).Select(d => new SelectListItem { Value = d.Key, Text = d.Name }).ToList(); + model.CanIncludeRestricted = await _authorization.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ViewRestrictedRecords); + if (!string.IsNullOrWhiteSpace(model.DefinitionKey)) + { + var aggregate = await _definitions.GetAsync(DepartmentId, model.DefinitionKey); + var version = model.DefinitionVersion.HasValue ? aggregate?.Versions.FirstOrDefault(v => v.Version == model.DefinitionVersion) : aggregate?.Published; + model.Schema = version?.Schema; + } + } + + private Task CanManageAsync() => _authorization.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ManageRecordReports); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index 7a60e546..af6477a3 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -37,6 +37,7 @@ namespace Resgrid.Web.Areas.User.Controllers public class RecordsController : SecureBaseController { private readonly IRecordsService _recordsService; + private readonly IRecordsBulkPacketService _bulk; private readonly IRecordsCutoverService _cutoverService; private readonly IRecordsAuthorizationService _recordsAuthorizationService; private readonly IRecordsUdfService _udf; @@ -57,6 +58,10 @@ public class RecordsController : SecureBaseController private readonly IRecordsDashboardService _dashboard; private readonly IRecordsProtectionService _protection; private readonly IProtectedGrantContext _grantContext; + private readonly IRecordsRevealService _reveal; + private readonly IRecordDefinitionsService _definitions; + private readonly IRecordTypedValuesService _typedValues; + private readonly IContactsService _contacts; public RecordsController(IRecordsService recordsService, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService, ICallsService callsService, @@ -64,8 +69,14 @@ public RecordsController(IRecordsService recordsService, IRecordsCutoverService IStringLocalizer localizer, ICompositeViewEngine viewEngine, IPdfProvider pdfProvider, IRecordsSearchService recordsSearch, IDepartmentDataProtectionService dataProtection, IDepartmentProfileMediaService branding, IRecordsPrintLayoutService printLayouts, IRecordsAccountabilityService accountability, IRecordsDashboardService dashboard, IRecordsUdfService udf, - IRecordsProtectionService protection, IProtectedGrantContext grantContext) + IRecordsProtectionService protection, IProtectedGrantContext grantContext, IRecordsRevealService reveal, IRecordDefinitionsService definitions, IRecordTypedValuesService typedValues, IContactsService contacts, + IRecordsBulkPacketService bulk) { + _bulk = bulk; + _contacts = contacts; + _reveal = reveal; + _definitions = definitions; + _typedValues = typedValues; _accountability = accountability; _protection = protection; _grantContext = grantContext; @@ -206,7 +217,7 @@ public async Task Index(int? year, string definitionKey, string s if (TempData["RecordsError"] is string recordsError) model.ErrorMessage = recordsError; - model.Definitions = DefinitionList(); + model.Definitions = await DefinitionListAsync(); model.States = Enum.GetValues(typeof(RmsRecordState)).Cast() .Select(s => new SelectListItem { Value = ((int)s).ToString(), Text = s.ToString() }).ToList(); @@ -214,6 +225,7 @@ public async Task Index(int? year, string definitionKey, string s return View(model); model.Years = (await _recordsService.GetYearsAsync(DepartmentId)).Select(y => new SelectListItem { Value = y.ToString(), Text = y.ToString() }).ToList(); + await PopulateBulkAsync(model); var visibleGroups = await _recordsAuthorizationService.GetVisibleGroupIdsAsync(UserId, DepartmentId); var states = int.TryParse(state, out var stateValue) ? new List { stateValue } : null; @@ -325,6 +337,18 @@ public async Task New(string definitionKey, int? callId) if (!moduleState.RecordsUsable) return moduleState.FlagEnabled ? RedirectToAction("Index") : NotFound(); + if (!string.IsNullOrWhiteSpace(definitionKey) && !RmsDefinitionKeys.LockedTypes.ContainsKey(definitionKey)) + { + // Department definitions (RMS-1B) render through the definition-driven form, pinned to the published version. + var published = await _definitions.GetCurrentPublishedAsync(DepartmentId, definitionKey); + if (published != null) + { + var form = await BuildDefinitionFormAsync(null, published, callId, null); + form.ProtectionEnforced = await _protection.IsEnforcedAsync(DepartmentId); + ApplyTempDataError(form); + return View("EditDefinition", form); + } + } if (string.IsNullOrWhiteSpace(definitionKey) || !RmsDefinitionKeys.LockedTypes.ContainsKey(definitionKey)) definitionKey = callId.HasValue ? RmsDefinitionKeys.Run : RmsDefinitionKeys.Training; @@ -350,7 +374,7 @@ public async Task New(string definitionKey, int? callId) public async Task NewRevealed(string definitionKey, int? callId) { var result = await New(definitionKey, callId); - if (result is ViewResult view && view.Model is RecordEditView model) + if (result is ViewResult view && view.Model is RecordsBaseView model) CarryGrant(model); return result; } @@ -366,8 +390,13 @@ public async Task Create(RecordEditView model, ICollection Create(RecordEditView model, ICollection Create(RecordEditView model, ICollection Edit(string id) if (!CanEditRecord(record)) return Unauthorized(); + if (record.RecordType == null) + { + var version = aggregate.DefinitionVersionRow ?? await _definitions.GetVersionAsync(DepartmentId, record.DefinitionKey, record.DefinitionVersion); + if (version == null) + return NotFound(); + var form = await BuildDefinitionFormAsync(aggregate, version, record.CallId, null); + ApplyTempDataError(form); + return View("EditDefinition", form); + } + var model = await BuildEditAsync(aggregate); ApplyTempDataError(model); return View(model); @@ -436,7 +472,7 @@ public async Task Edit(string id) public async Task EditRevealed(string id) { var result = await Edit(id); - if (result is ViewResult view && view.Model is RecordEditView model) + if (result is ViewResult view && view.Model is RecordsBaseView model) CarryGrant(model); return result; } @@ -517,6 +553,7 @@ public async Task Edit(RecordEditView model, ICollection Edit(RecordEditView model, ICollection Edit(RecordEditView model, ICollection RevealRecord([FromForm] string id) if (aggregate == null) return NotFound(); - var protection = aggregate.Protection ?? new ProtectedReadResult(); - if (protection.IsProtected && protection.ProtectedReason != null) - return Json(new { success = false, error = protection.ProtectedReason }); - - var fields = new Dictionary(); - var details = aggregate.Details; - if (details != null) - { - var restricted = await CanViewRestrictedAsync(); - foreach (var accessor in RmsProtectedFields.Details) - { - // The reveal hides exactly what the page hides: restricted detail columns stay withheld without the grant. - var column = accessor.Key.Substring(accessor.Key.IndexOf('.') + 1); - if (!restricted && RecordSnapshotSerializer.RestrictedDetailFields.Any(f => string.Equals(f, column, StringComparison.OrdinalIgnoreCase))) - continue; - fields[$"{accessor.Key}:{details.RmsOperationalRecordDetailId}"] = accessor.Value.Get(details); - } - } - foreach (var attachment in aggregate.Attachments ?? new List()) - fields[$"rmsrecordattachments.filename:{attachment.RmsRecordAttachmentId}"] = attachment.FileName; - - await _recordsService.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Read, "Protected reveal", IpAddressHelper.GetRequestIP(Request, true)); - return Json(new { success = true, fields }); + // Shared with the v4 Reveal endpoint (IRecordsRevealService): same keys, same withholding, same audit. + var outcome = await _reveal.RevealRecordAsync(DepartmentId, UserId, aggregate, await CanViewRestrictedAsync(), IpAddressHelper.GetRequestIP(Request, true)); + if (!outcome.Success) + return Json(new { success = false, error = outcome.Error }); + return Json(new { success = true, fields = outcome.Fields }); } [HttpGet] @@ -1302,7 +1317,9 @@ private async Task BuildDetailAsync(string id) CanVoid = ClaimsAuthorizationHelper.CanVoidRecords(), CanExport = ClaimsAuthorizationHelper.CanExportRecords(), CanViewRestricted = await CanViewRestrictedAsync(), - CanReassign = ClaimsAuthorizationHelper.CanReassignRecordDrafts() + CanReassign = ClaimsAuthorizationHelper.CanReassignRecordDrafts(), + DefinitionName = await DefinitionNameAsync(aggregate.Record), + DefinitionLayout = aggregate.Record.RecordType == null ? (await _printLayouts.ResolveForDefinitionAsync(DepartmentId, aggregate.Record.DefinitionKey, aggregate.Record.DefinitionVersion)).Definition : null }; } @@ -1353,6 +1370,7 @@ private RecordDraftInput BuildInput(RecordEditView model) return new RecordDraftInput { CustomFields = model.CustomFields, + Values = model.Values ?? new List(), DefinitionKey = model.DefinitionKey, CallId = model.CallId, StationGroupId = model.StationGroupId, @@ -1450,7 +1468,7 @@ private async Task PopulateListsAsync(RecordEditView model) model.CanViewRestricted = await CanViewRestrictedAsync() && await _recordsAuthorizationService.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ViewRestrictedRecords); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); - model.Definitions = DefinitionList(); + model.Definitions = await DefinitionListAsync(); model.CanFinalize = ClaimsAuthorizationHelper.CanFinalizeRecords(); var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId) ?? new List(); @@ -1477,9 +1495,153 @@ private async Task PopulateListsAsync(RecordEditView model) } } - private static List DefinitionList() + /// Locked Logs-parity definitions plus the department's published definitions (RMS-1B). + private async Task> DefinitionListAsync() + { + var list = RmsDefinitionKeys.LockedTypes.Select(kv => new SelectListItem { Value = kv.Key, Text = kv.Value.ToString() }).ToList(); + try + { + foreach (var definition in (await _definitions.ListAsync(DepartmentId)).Where(d => !d.Locked && d.PublishedVersion.HasValue && !d.Retired).OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)) + list.Add(new SelectListItem { Value = definition.Key, Text = definition.Name }); + } + catch (Exception ex) + { + Logging.LogException(ex, "Department definitions could not be listed for the Records chooser."); + } + return list; + } + + private async Task DefinitionNameAsync(RmsOperationalRecord record) + { + if (record?.RecordType != null) return null; + return (await _definitions.ListAsync(DepartmentId, true)).FirstOrDefault(d => string.Equals(d.Key, record?.DefinitionKey, StringComparison.OrdinalIgnoreCase))?.Name ?? record?.DefinitionKey; + } + + /// The definition-driven authoring form (RMS-1B): pinned schema, stored or posted values, rule evaluation, reference lists. + private async Task BuildDefinitionFormAsync(RecordAggregate aggregate, RmsRecordDefinitionVersion version, int? callId, RecordEditView posted) + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var record = aggregate?.Record; + var definition = await _definitions.GetAsync(DepartmentId, version.DefinitionKey); + var form = new RecordDefinitionFormView + { + RecordId = record?.RmsOperationalRecordId, RowVersion = posted?.RowVersion ?? record?.RowVersion ?? 0, DefinitionKey = version.DefinitionKey, DefinitionName = definition?.Definition.Name ?? version.DefinitionKey, DefinitionVersion = version.Version, + DraftReference = record?.DraftReference, RecordNumber = record?.RecordNumber, IsAmendment = record?.AmendsRevisionId != null, + CallId = posted?.CallId ?? record?.CallId ?? callId, StationGroupId = posted?.StationGroupId ?? record?.StationGroupId, ExternalId = posted?.ExternalId ?? record?.ExternalId, + StartedOn = posted?.StartedOn ?? record?.StartedOn?.TimeConverter(department) ?? (record == null ? DateTime.UtcNow.TimeConverter(department) : (DateTime?)null), EndedOn = posted?.EndedOn ?? record?.EndedOn?.TimeConverter(department), + Schema = version.Schema, Values = aggregate?.Values, PostedValues = posted?.Values, LifecyclePreset = (RmsLifecyclePreset)version.LifecyclePreset, MinimumClientCapability = version.MinimumClientCapability, + CanViewRestricted = await CanViewRestrictedAsync(), CanFinalize = ClaimsAuthorizationHelper.CanFinalizeRecords(), Department = department, + FinalizeAfterSave = posted?.FinalizeAfterSave ?? false, Attested = posted?.Attested ?? false, ReasonCode = posted?.ReasonCode, ReasonText = posted?.ReasonText, AttachmentClassification = posted?.AttachmentClassification ?? 1 + }; + form.ApplyProtection(aggregate?.Protection); + form.Evaluation = _typedValues.EvaluateRules(version.Schema, aggregate?.Values ?? new RecordValueSet()); + if (definition?.Definition.TemplateKey != null) + { + var template = RecordTemplateCatalog.Find(definition.Definition.TemplateKey); + var profile = RecordTemplateCatalog.FindProfile(definition.Definition.JurisdictionProfileKey ?? "generic") ?? RecordTemplateCatalog.FindProfile("generic"); + if (template != null && profile != null) + { + var rendering = RecordTemplatePacksService.Render(template, profile, profile.ProfileKey, profile.DefaultLocale); + form.ProvenanceStatement = rendering.ProvenanceStatement; + form.IsPreview = RecordTemplateCatalog.PackOf(template.Key)?.IsPreview ?? false; + } + } + var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId) ?? new List(); + form.Stations = groups.OrderBy(g => g.Name).Select(g => new SelectListItem { Value = g.DepartmentGroupId.ToString(), Text = g.Name }).ToList(); + var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); + form.Personnel = names.OrderBy(n => n.Name).Select(n => new SelectListItem { Value = n.UserId, Text = n.Name }).ToList(); + var units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId) ?? new List(); + form.AvailableUnits = units.OrderBy(u => u.Name).Select(u => new SelectListItem { Value = u.UnitId.ToString(), Text = u.Name }).ToList(); + var calls = ClaimsAuthorizationHelper.CanViewCalls() ? await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId) ?? new List() : new List(); + form.Calls = calls.OrderByDescending(c => c.LoggedOn).Select(c => new SelectListItem { Value = c.CallId.ToString(), Text = $"{c.Number} - {c.Name}" }).ToList(); + try + { + var contacts = await _contacts.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List(); + form.Contacts = contacts.Select(c => new SelectListItem { Value = c.ContactId, Text = string.Join(" ", new[] { c.FirstName, c.LastName }.Where(s => !string.IsNullOrWhiteSpace(s))) is var n && !string.IsNullOrWhiteSpace(n) ? n : c.CompanyName ?? c.ContactId }).OrderBy(i => i.Text).ToList(); + } + catch (Exception ex) + { + Logging.LogException(ex, "Contacts could not be listed for the definition form."); + } + form.Attachments = (aggregate?.Attachments ?? new List()).Select(a => new SelectListItem { Value = a.RmsRecordAttachmentId, Text = a.FileName }).ToList(); + if (record == null && callId.HasValue) + form.DuplicateCandidates = await _recordsService.GetDuplicateCandidatesAsync(DepartmentId, version.DefinitionKey, callId.Value); + return form; + } + + /// Re-renders the right editor after a failed post: the definition form (with the posted values) or the locked-type Edit view. + private async Task EditErrorAsync(RecordEditView model, RecordAggregate aggregate, RmsRecordDefinitionVersion definitionVersion, string error) { - return RmsDefinitionKeys.LockedTypes.Select(kv => new SelectListItem { Value = kv.Key, Text = kv.Value.ToString() }).ToList(); + if (definitionVersion == null) + { + model.ErrorMessage = error; + return View("Edit", model); + } + var form = await BuildDefinitionFormAsync(aggregate, definitionVersion, model.CallId, model); + form.ErrorMessage = error; + CarryGrant(form); + form.ProtectionEnforced = aggregate?.Protection?.IsProtected ?? await _protection.IsEnforcedAsync(DepartmentId); + return View("EditDefinition", form); + } + + private async Task PopulateBulkAsync(RecordsIndexView model) + { + model.CanBulkAssign = await _recordsAuthorizationService.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ReviewRecords); + model.CanBulkPacket = await _recordsAuthorizationService.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ExportRecords); + if (model.CanBulkAssign) + { + var names = await PersonnelNamesAsync(); + model.Reviewers = names.OrderBy(n => n.Value, StringComparer.CurrentCultureIgnoreCase).Select(n => new SelectListItem { Value = n.Key, Text = n.Value }).ToList(); + } + } + + /// Bulk assign-for-review and bulk packets over the checked rows (RMS plan section 4.7). No bulk void, no bulk delete. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Bulk(string bulkAction, List ids, string reviewerUserId, string reason, string title, string purpose, string deliverTo, CancellationToken cancellationToken) + { + if (!await _recordsAuthorizationService.IsActiveMemberAsync(UserId, DepartmentId)) return Forbid(); + if (!(await _cutoverService.GetModuleStateAsync(DepartmentId)).RecordsUsable) return NotFound(); + ids = (ids ?? new List()).Where(i => !string.IsNullOrWhiteSpace(i)).ToList(); + if (ids.Count == 0) { TempData["RecordsError"] = _localizer["BulkNothingSelected"].Value; return RedirectToAction("Index"); } + try + { + switch ((bulkAction ?? string.Empty).ToLowerInvariant()) + { + case "assign": + { + var result = await _bulk.AssignForReviewAsync(DepartmentId, UserId, new RecordsBulkAssignRequest { RecordIds = ids, ReviewerUserId = reviewerUserId, Reason = reason }, cancellationToken); + TempData["RecordsMessage"] = string.Format(_localizer["BulkAssigned"].Value, result.Processed, result.Skipped); + return RedirectToAction("Index"); + } + case "packet": + case "bundle": + { + var result = await _bulk.BuildPacketAsync(DepartmentId, UserId, new RecordsBulkPacketRequest + { + RecordIds = ids, Mode = bulkAction.ToLowerInvariant() == "bundle" ? RecordsBulkPacketMode.Bundle : RecordsBulkPacketMode.CompiledPdf, + Title = title, Purpose = purpose, DeliverToEmail = deliverTo, OriginClient = RmsOriginClient.Web + }, cancellationToken); + TempData["RecordsMessage"] = string.Format(_localizer["BulkPacketCreated"].Value, result.Processed, result.Skipped) + (result.Delivered ? " " + _localizer["BulkDelivered"].Value : string.Empty); + return RedirectToAction("BulkDownload", new { id = result.Run.RmsExportRunId }); + } + default: + return BadRequest(); + } + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; return RedirectToAction("Index"); } + } + + [HttpGet] + public async Task BulkDownload(string id) + { + if (!await _recordsAuthorizationService.IsActiveMemberAsync(UserId, DepartmentId)) return Forbid(); + RmsExportRun run; + try { run = await _bulk.GetPacketAsync(DepartmentId, UserId, id); } + catch (UnauthorizedAccessException) { return Forbid(); } + if (run?.Data == null) return NotFound(); + return File(run.Data, run.ContentType ?? "application/octet-stream", run.FileName ?? "packet"); } private async Task> PersonnelNamesAsync() diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/IncidentSectionViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/IncidentSectionViewModels.cs index 05a91aa4..8c65aa04 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/IncidentSectionViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/IncidentSectionViewModels.cs @@ -12,6 +12,8 @@ namespace Resgrid.Web.Areas.User.Models.Records /// public class IncidentModuleRow { + /// The stored row this form row edits; posted back so the save matches by identity, not position. + public string ModuleId { get; set; } public int Kind { get; set; } public string PrimaryCode { get; set; } public string SecondaryCode { get; set; } @@ -26,6 +28,8 @@ public class IncidentModuleRow public class IncidentResourceRow { + /// The stored row this form row edits; posted back so the save matches by identity, not position. + public string ResourceId { get; set; } public string ResourceCode { get; set; } public int? Quantity { get; set; } public string Detail { get; set; } @@ -71,6 +75,8 @@ public class IncidentCasualtyRow public class IncidentExposureRow { + /// The stored row this form row edits; posted back so the save matches by identity, not position. + public string ExposureId { get; set; } public string LocationKind { get; set; } public string ItemType { get; set; } public string DamageType { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs new file mode 100644 index 00000000..f5a8c134 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Records +{ + // RMS-1B definition designer, saved reports, definition-driven record form; RMS-1C template packs and deployments. + + public class RecordDefinitionsIndexView : RecordsBaseView + { + public RecordsModuleState ModuleState { get; set; } + public Department Department { get; set; } + public List Definitions { get; set; } = new List(); + public bool CanPublish { get; set; } + public bool IncludeRetired { get; set; } + } + + public class RecordTemplatesView : RecordsBaseView + { + public List Packs { get; set; } = new List(); + public List Profiles { get; set; } = new List(); + } + + public class RecordDefinitionCreateView : RecordsBaseView + { + public string DefinitionKey { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string TemplateKey { get; set; } + public string CloneFromDefinitionKey { get; set; } + public string JurisdictionProfileKey { get; set; } = "generic"; + public string Locale { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public RecordTemplateRendering Rendering { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Profiles { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Locales { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Templates { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List DepartmentDefinitions { get; set; } = new List(); + } + + /// The controlled designer: policies as form fields, the schema as a validated JSON document, and a read-only field table rendered from it. + public class RecordDefinitionEditView : RecordsBaseView + { + public string DefinitionKey { get; set; } + public int Version { get; set; } + public long RowVersion { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public string Description { get; set; } + public string PermittedSubjectTypes { get; set; } + public int LifecyclePreset { get; set; } = (int)RmsLifecyclePreset.QuickEntry; + public List ReviewerRoleIds { get; set; } = new List(); + public List ApproverRoleIds { get; set; } = new List(); + public int? ReviewDueHours { get; set; } + public int? ApproveDueHours { get; set; } + public bool RequireAuthorAttestation { get; set; } + public string NumberPrefix { get; set; } + public int NumberAssignment { get; set; } = (int)RmsNumberAssignment.OnFinalize; + public bool PerGroupSequence { get; set; } + public bool ResetYearly { get; set; } = true; + public int SequenceWidth { get; set; } = 4; + public int? RetentionYears { get; set; } + public int Classification { get; set; } + public bool SurfaceResponder { get; set; } + public bool SurfaceUnit { get; set; } + public bool SurfaceIncidentCommand { get; set; } + public bool SurfaceDispatch { get; set; } + public bool AllowOffline { get; set; } + public bool AllowAttachments { get; set; } = true; + public string SchemaJson { get; set; } + public string MigrationMapJson { get; set; } + public string ChangeNotes { get; set; } + + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public RecordDefinitionAggregate Aggregate { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public RmsRecordDefinitionVersion VersionRow { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public RecordDefinitionSchema Schema { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Issues { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Roles { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public string MinimumClientCapability { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public bool CanPublish { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public bool IsPublished { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public RecordDefinitionDiff TemplateDiff { get; set; } + public bool IsDraft => VersionRow == null || VersionRow.IsDraft; + + 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(), + ReviewDueHours = ReviewDueHours, ApproveDueHours = ApproveDueHours, RequireAuthorAttestation = RequireAuthorAttestation, + Numbering = new RecordDefinitionNumbering { Prefix = NumberPrefix?.Trim().ToUpperInvariant(), Assignment = (RmsNumberAssignment)NumberAssignment, PerGroupSequence = PerGroupSequence, ResetYearly = ResetYearly, SequenceWidth = SequenceWidth }, + RetentionYears = RetentionYears, Classification = (RmsFieldClassification)Classification, + Schema = RecordDefinitionSchema.Parse(SchemaJson), + ClientSurface = new RecordDefinitionClientSurface { Responder = SurfaceResponder, Unit = SurfaceUnit, IncidentCommand = SurfaceIncidentCommand, Dispatch = SurfaceDispatch, AllowOffline = AllowOffline, AllowAttachments = AllowAttachments }, + MigrationMap = string.IsNullOrWhiteSpace(MigrationMapJson) ? new List() : Newtonsoft.Json.JsonConvert.DeserializeObject>(MigrationMapJson) ?? new List(), + ChangeNotes = ChangeNotes + }; + } + + public static RecordDefinitionEditView From(RecordDefinitionAggregate aggregate, RmsRecordDefinitionVersion version) + { + var numbering = version.Numbering; var surface = version.ClientSurface; + return new RecordDefinitionEditView + { + 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), + ReviewDueHours = version.ReviewDueHours, ApproveDueHours = version.ApproveDueHours, RequireAuthorAttestation = version.RequireAuthorAttestation, + NumberPrefix = numbering.Prefix, NumberAssignment = (int)numbering.Assignment, PerGroupSequence = numbering.PerGroupSequence, ResetYearly = numbering.ResetYearly, SequenceWidth = numbering.SequenceWidth, + RetentionYears = version.RetentionYears, Classification = version.Classification, + SurfaceResponder = surface.Responder, SurfaceUnit = surface.Unit, SurfaceIncidentCommand = surface.IncidentCommand, SurfaceDispatch = surface.Dispatch, AllowOffline = surface.AllowOffline, AllowAttachments = surface.AllowAttachments, + SchemaJson = Newtonsoft.Json.JsonConvert.SerializeObject(version.Schema, Newtonsoft.Json.Formatting.Indented, new Newtonsoft.Json.JsonSerializerSettings { NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore, DefaultValueHandling = Newtonsoft.Json.DefaultValueHandling.Ignore }), + MigrationMapJson = version.MigrationMapJson, ChangeNotes = version.ChangeNotes, Schema = version.Schema, IsPublished = version.IsPublished, + MinimumClientCapability = version.MinimumClientCapability ?? RecordsClientCapabilities.Derive(version.Schema) + }; + } + } + + /// + /// Definition-scope print layout (RMS plan section 4.10.1): section order, headings, hidden sections/fields, page breaks, + /// signature and attachment placement, optional branding overrides, and the definition version the layout applies to. + /// Dictionary members bind from Name[section.key] form fields. + /// + public class RecordDefinitionLayoutView : RecordsBaseView + { + public string DefinitionKey { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public string DefinitionName { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public RecordDefinitionSchema Schema { get; set; } = new RecordDefinitionSchema(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public string LayoutVersion { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public string DepartmentLayoutVersion { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Versions { get; set; } = new List(); + public int? AppliesToVersion { get; set; } + public Dictionary Order { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public Dictionary Visible { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public Dictionary Heading { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public Dictionary PageBreak { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public Dictionary FieldVisible { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public string SignatureBlockPlacement { get; set; } = RecordsDefinitionLayoutConfig.SignatureAtEnd; + public string AttachmentListStyle { get; set; } = RecordsDefinitionLayoutConfig.AttachmentsTable; + public bool OverrideBranding { get; set; } + public RecordsPrintLayoutConfig Branding { get; set; } = RecordsPrintLayoutConfig.Default(); + + /// Schema sections in the posted or stored order; hidden sections stay listed so they can be shown again. + public IEnumerable OrderedSectionKeys() + { + var keys = (Schema?.Sections ?? new List()).Select(s => s.Key).ToList(); + return keys.Select((k, i) => (Key: k, Sort: Order.TryGetValue(k, out var o) ? o : (i + 1) * 10 + 100000, Index: i)).OrderBy(t => t.Sort).ThenBy(t => t.Index).Select(t => t.Key); + } + + public RecordsDefinitionLayoutConfig ToConfig() + { + var keys = OrderedSectionKeys().ToList(); + return new RecordsDefinitionLayoutConfig + { + AppliesToVersion = AppliesToVersion, + SectionOrder = keys, + HiddenSectionKeys = Visible.Where(v => !v.Value).Select(v => v.Key).ToList(), + HiddenFieldKeys = FieldVisible.Where(v => !v.Value).Select(v => v.Key).ToList(), + SectionHeadings = Heading.Where(h => !string.IsNullOrWhiteSpace(h.Value)).ToDictionary(h => h.Key, h => h.Value.Trim(), StringComparer.OrdinalIgnoreCase), + PageBreakBeforeSectionKeys = PageBreak.Where(p => p.Value).Select(p => p.Key).ToList(), + SignatureBlockPlacement = SignatureBlockPlacement, + AttachmentListStyle = AttachmentListStyle, + BrandingOverrides = OverrideBranding ? Branding : null + }; + } + + public static RecordDefinitionLayoutView From(RecordDefinitionAggregate aggregate, RmsRecordDefinitionVersion version, RmsRecordPrintLayout stored, string departmentLayoutVersion) + { + var config = stored?.DefinitionConfig ?? RecordsDefinitionLayoutConfig.Default(); + var model = new RecordDefinitionLayoutView + { + DefinitionKey = aggregate.Definition.DefinitionKey, + DefinitionName = aggregate.Definition.Name, + Schema = version.Schema ?? new RecordDefinitionSchema(), + LayoutVersion = stored?.LayoutVersion ?? RmsRecordPrintLayout.GeneratedLayoutVersion, + DepartmentLayoutVersion = departmentLayoutVersion, + Versions = aggregate.Versions.OrderByDescending(v => v.Version).Select(v => new SelectListItem { Value = v.Version.ToString(), Text = "v" + v.Version + " (" + ((RmsDefinitionVersionState)v.State) + ")" }).ToList(), + AppliesToVersion = config.AppliesToVersion, + SignatureBlockPlacement = config.SignatureBlockPlacement, + AttachmentListStyle = config.AttachmentListStyle, + OverrideBranding = config.BrandingOverrides != null, + Branding = config.BrandingOverrides ?? RecordsPrintLayoutConfig.Default() + }; + for (var i = 0; i < config.SectionOrder.Count; i++) model.Order[config.SectionOrder[i]] = (i + 1) * 10; + foreach (var key in config.HiddenSectionKeys) model.Visible[key] = false; + foreach (var key in config.HiddenFieldKeys) model.FieldVisible[key] = false; + foreach (var pair in config.SectionHeadings) model.Heading[pair.Key] = pair.Value; + foreach (var key in config.PageBreakBeforeSectionKeys) model.PageBreak[key] = true; + return model; + } + } + + public class RecordDefinitionImpactView : RecordsBaseView + { + public RecordDefinitionAggregate Aggregate { get; set; } + public RmsRecordDefinitionVersion VersionRow { get; set; } + public RecordDefinitionImpactPreview Preview { get; set; } + public RecordDefinitionDiff Diff { get; set; } + public bool CanPublish { get; set; } + } + + public class RecordDefinitionHistoryView : RecordsBaseView + { + public RecordDefinitionAggregate Aggregate { get; set; } + public Department Department { get; set; } + public List Versions { get; set; } = new List(); + public RecordDefinitionDiff Diff { get; set; } + public int? From { get; set; } + public int? To { get; set; } + public RecordDefinitionMigrationResult Migration { get; set; } + public bool CanManage { get; set; } + } + + // ---- saved reports ----------------------------------------------------------------------------- + + public class RecordSavedReportsIndexView : RecordsBaseView + { + public Department Department { get; set; } + public List Reports { get; set; } = new List(); + public bool CanManage { get; set; } + } + + public class RecordSavedReportEditView : RecordsBaseView + { + public string ReportId { get; set; } + public long RowVersion { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string DefinitionKey { get; set; } + public int? DefinitionVersion { get; set; } + public List Columns { get; set; } = new List(); + public string FiltersJson { get; set; } + public string GroupByFieldKey { get; set; } + public string AggregatesJson { get; set; } + public string SortFieldKey { get; set; } + public bool SortDescending { get; set; } + public bool IncludeDrafts { get; set; } + public int? WindowDays { get; set; } + public string VersionMappingsJson { get; set; } + public int MaxRowsPerRun { get; set; } = RmsSavedReportDefinition.MaxRows; + public bool IncludeRestricted { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Definitions { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public RecordDefinitionSchema Schema { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Issues { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public bool CanIncludeRestricted { get; set; } + public bool IsNew => string.IsNullOrWhiteSpace(ReportId); + + public RmsSavedReportDefinition ToReport() + { + var spec = new RecordReportSpec + { + Columns = (Columns ?? new List()).Where(c => !string.IsNullOrWhiteSpace(c)).ToList(), GroupByFieldKey = string.IsNullOrWhiteSpace(GroupByFieldKey) ? null : GroupByFieldKey, SortFieldKey = string.IsNullOrWhiteSpace(SortFieldKey) ? null : SortFieldKey, + SortDescending = SortDescending, IncludeDrafts = IncludeDrafts, WindowDays = WindowDays, + Filters = string.IsNullOrWhiteSpace(FiltersJson) ? new List() : Newtonsoft.Json.JsonConvert.DeserializeObject>(FiltersJson) ?? new List(), + Aggregates = string.IsNullOrWhiteSpace(AggregatesJson) ? new List() : Newtonsoft.Json.JsonConvert.DeserializeObject>(AggregatesJson) ?? new List(), + VersionMappings = string.IsNullOrWhiteSpace(VersionMappingsJson) ? new Dictionary>() : Newtonsoft.Json.JsonConvert.DeserializeObject>>(VersionMappingsJson) ?? new Dictionary>() + }; + return new RmsSavedReportDefinition { RmsSavedReportDefinitionId = ReportId, RowVersion = RowVersion, Name = Name, Description = Description, DefinitionKey = DefinitionKey, DefinitionVersion = DefinitionVersion, Spec = spec, MaxRowsPerRun = MaxRowsPerRun, IncludeRestricted = IncludeRestricted }; + } + + public static RecordSavedReportEditView From(RmsSavedReportDefinition report) + { + var spec = report.Spec; + return new RecordSavedReportEditView + { + ReportId = report.RmsSavedReportDefinitionId, RowVersion = report.RowVersion, Name = report.Name, Description = report.Description, DefinitionKey = report.DefinitionKey, DefinitionVersion = report.DefinitionVersion, + Columns = spec.Columns.ToList(), FiltersJson = Newtonsoft.Json.JsonConvert.SerializeObject(spec.Filters, Newtonsoft.Json.Formatting.Indented), GroupByFieldKey = spec.GroupByFieldKey, + AggregatesJson = Newtonsoft.Json.JsonConvert.SerializeObject(spec.Aggregates, Newtonsoft.Json.Formatting.Indented), SortFieldKey = spec.SortFieldKey, SortDescending = spec.SortDescending, IncludeDrafts = spec.IncludeDrafts, + WindowDays = spec.WindowDays, VersionMappingsJson = Newtonsoft.Json.JsonConvert.SerializeObject(spec.VersionMappings, Newtonsoft.Json.Formatting.Indented), MaxRowsPerRun = report.MaxRowsPerRun, IncludeRestricted = report.IncludeRestricted + }; + } + } + + public class RecordSavedReportRunView : RecordsBaseView + { + public RmsSavedReportDefinition Report { get; set; } + public RecordReportResult Result { get; set; } + public Department Department { get; set; } + } + + // ---- deployments (RMS-1C, Preview) ------------------------------------------------------------- + + public class RecordDeploymentsIndexView : RecordsBaseView + { + public Department Department { get; set; } + public List Orders { get; set; } = new List(); + public bool IncludeClosed { get; set; } + public bool CanCreate { get; set; } + } + + public class RecordDeploymentNewView : RecordsBaseView + { + public string ProfileKey { get; set; } = RmsDeploymentProfiles.Generic; + public string SourceScheme { get; set; } + public string SourceSystem { get; set; } + public string OrderNumber { get; set; } + public string IncidentName { get; set; } + public string IncidentNumber { get; set; } + public string IncidentCountry { get; set; } + public string IncidentSubdivision { get; set; } + public string OrderingOffice { get; set; } + public string DispatchOffice { get; set; } + public string RequestingAgency { get; set; } + public string ReceivingAgency { get; set; } + public string SendingAgency { get; set; } + public string DepartmentRole { get; set; } = "filling"; + public string CostCode { get; set; } + public string AgreementReference { get; set; } + public string CurrencyCode { get; set; } + public string MeasurementSystem { get; set; } + public string TimeZoneId { get; set; } + public DateTime? SourceCapturedOn { get; set; } + public string SourceVersion { get; set; } + public string ArtifactSafeUrl { get; set; } + public int? StationGroupId { get; set; } + public List Fills { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Profiles { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Stations { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List Personnel { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public List AvailableUnits { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public Department Department { get; set; } + } + + public class RecordDeploymentDetailsView : RecordsBaseView + { + public RecordDeploymentAggregate Deployment { get; set; } + public Department Department { get; set; } + public Dictionary PersonnelNames { get; set; } = new Dictionary(); + public bool CanEdit { get; set; } + public string ProvenanceStatement { get; set; } + public RecordDeploymentFillInput NewFill { get; set; } = new RecordDeploymentFillInput(); + public List Personnel { get; set; } = new List(); + public List AvailableUnits { get; set; } = new List(); + } + + // ---- definition-driven record form (RMS-1B) ---------------------------------------------------- + + /// + /// A Record on a department definition: the pinned schema, the stored or posted values, the rule evaluation and the + /// reference lists the renderer needs. Posts back as RecordEditView with Values[i].* fields. + /// + public class RecordDefinitionFormView : RecordsBaseView + { + public string RecordId { get; set; } + public long RowVersion { get; set; } + public string DefinitionKey { get; set; } + public string DefinitionName { get; set; } + public int DefinitionVersion { get; set; } + public string DraftReference { get; set; } + public string RecordNumber { get; set; } + public bool IsAmendment { get; set; } + public bool IsNew => string.IsNullOrWhiteSpace(RecordId); + public int? CallId { get; set; } + public int? StationGroupId { get; set; } + public string ExternalId { get; set; } + public DateTime? StartedOn { get; set; } + public DateTime? EndedOn { get; set; } + public RecordDefinitionSchema Schema { get; set; } = new RecordDefinitionSchema(); + public RecordValueSet Values { get; set; } + public RecordRuleEvaluation Evaluation { get; set; } = new RecordRuleEvaluation(); + /// Raw inputs from a failed post; they win over the stored values when re-rendering. + public List PostedValues { get; set; } + public RmsLifecyclePreset LifecyclePreset { get; set; } + public string MinimumClientCapability { get; set; } + public string ProvenanceStatement { get; set; } + public bool IsPreview { get; set; } + public bool CanViewRestricted { get; set; } + public bool CanFinalize { get; set; } + public bool FinalizeAfterSave { get; set; } + public bool Attested { get; set; } + public string ReasonCode { get; set; } + public string ReasonText { get; set; } + public int AttachmentClassification { get; set; } = 1; + public Department Department { get; set; } + public List Stations { get; set; } = new List(); + public List Personnel { get; set; } = new List(); + public List AvailableUnits { get; set; } = new List(); + public List Calls { get; set; } = new List(); + public List Contacts { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + public List DuplicateCandidates { get; set; } = new List(); + + private List _inputs; + /// Posted inputs when present, else the stored values as inputs. + public List Inputs => _inputs ??= (PostedValues != null && PostedValues.Count > 0 ? PostedValues : Values?.ToInputs()) ?? new List(); + + public RecordValueInput Input(string sectionKey, string rowKey, string fieldKey) + => Inputs.FirstOrDefault(i => string.Equals(i.FieldKey, fieldKey, StringComparison.OrdinalIgnoreCase) && (rowKey == null ? string.IsNullOrEmpty(i.RowKey) || !IsRepeating(sectionKey) : string.Equals(i.RowKey, rowKey, StringComparison.OrdinalIgnoreCase))); + + private bool IsRepeating(string sectionKey) => Schema.FindSection(sectionKey)?.Repeating == true; + + /// Row keys of a repeating section in ordinal order (at least one blank row is rendered by the view). + public List RowKeys(string sectionKey) + => Inputs.Where(i => string.Equals(i.SectionKey, sectionKey, StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(i.SectionKey) && Schema.SectionOf(i.FieldKey)?.Key.Equals(sectionKey, StringComparison.OrdinalIgnoreCase) == true) + .Where(i => !string.IsNullOrEmpty(i.RowKey)).GroupBy(i => i.RowKey, StringComparer.OrdinalIgnoreCase).OrderBy(g => g.Min(i => i.Ordinal)).Select(g => g.Key).ToList(); + + /// Whether a stored restricted cell is withheld for this viewer (the input renders disabled and blank). + public bool IsWithheld(string fieldKey) => !CanViewRestricted && Schema.FindField(fieldKey)?.Classification == RmsFieldClassification.Restricted; + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs index 79b8d688..de647abf 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs @@ -98,6 +98,10 @@ public class RecordsIndexView : RecordsBaseView public int? GroupFilter { get; set; } public bool SearchAvailable { get; set; } public bool NarrativeSearchAvailable { get; set; } + /// Bulk bar (RMS plan section 4.7): reviewers for assign-for-review; the packet buttons need ExportRecords. + public bool CanBulkAssign { get; set; } + public bool CanBulkPacket { get; set; } + public List Reviewers { get; set; } = new List(); public bool SearchDegraded { get; set; } public bool SearchTruncated { get; set; } } @@ -181,6 +185,8 @@ public class RecordEditView : RecordsBaseView public DateTime? StartedOn { get; set; } public DateTime? EndedOn { get; set; } public RmsOperationalRecordDetail Details { get; set; } = new RmsOperationalRecordDetail(); + /// Typed values posted by the definition-driven form (RMS-1B); empty for locked system definitions. + public List Values { get; set; } = new List(); public List ParticipantUserIds { get; set; } = new List(); public List ParticipantRows { get; set; } public List Units { get; set; } = new List(); @@ -217,6 +223,11 @@ public class RecordDetailView : RecordsBaseView public RecordPrintProvenance Provenance { get; set; } public RmsOperationalRecordType RecordType => (RmsOperationalRecordType)Aggregate.Record.RecordType.GetValueOrDefault(); public RmsRecordState State => (RmsRecordState)Aggregate.Record.State; + /// Set for a Record on a department definition (RMS-1B); the type-specific detail boxes do not apply then. + public string DefinitionName { get; set; } + public bool IsDefinitionRecord => Aggregate?.Record?.RecordType == null; + /// The Definition-scope print layout when one applies to this Record's pinned version (presentation only). + public RecordsDefinitionLayoutConfig DefinitionLayout { get; set; } } /// A single revision rendered from its snapshot. diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentAnalysis/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentAnalysis/Edit.cshtml index b033316c..538ed051 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentAnalysis/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentAnalysis/Edit.cshtml @@ -86,6 +86,7 @@ var row = Model.Modules[i];
+ diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml index 256d538b..b9d5ac52 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml @@ -368,6 +368,7 @@ var row = Model.Modules[i];
+ @@ -540,6 +541,7 @@ { var row = i < Model.Exposures.Count ? Model.Exposures[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentExposureRow();
+ @@ -559,7 +561,7 @@ { var row = i < Model.Resources.Count ? Model.Resources[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentResourceRow();
-
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Create.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Create.cshtml new file mode 100644 index 00000000..8ffefb36 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Create.cshtml @@ -0,0 +1,88 @@ +@model Resgrid.Web.Areas.User.Models.Records.RecordDefinitionCreateView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["CreateDefinition"]; +} + +
+
+

@localizer["CreateDefinition"]

+ +
+
+ +
+ @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} +
+
+
+ @Html.AntiForgeryToken() +
+
+
+ +
+
+
+ +
+
+
+ +
+ +
+
+
+ +
@localizer["DefinitionKeyHelp"]
+
+
+ +
+
+
+ +
+
+
+
+ + @commonLocalizer["Cancel"] +
+
+
+
+
+
+
+ @if (Model.Rendering != null) + { +
+
@localizer["TemplatePreview"]: @Model.Rendering.Template.Name
+
+

@Model.Rendering.ProvenanceStatement

+

@localizer["DefinitionPreset"]: @Model.Rendering.Template.LifecyclePreset · @localizer["DefinitionNumberPrefix"]: @Model.Rendering.Template.NumberPrefix · @localizer["TemplateProfile"]: @Model.Rendering.ProfileKey (@Model.Rendering.Locale, @Model.Rendering.MeasurementSystem@(string.IsNullOrEmpty(Model.Rendering.CurrencyCode) ? "" : ", " + Model.Rendering.CurrencyCode))

+ @foreach (var sec in Model.Rendering.Schema.Sections) + { +

@sec.Label @if (sec.Repeating) { @localizer["DefinitionRepeating"] }

+ + + + @foreach (var field in sec.Fields) + { + + } + +
@localizer["FieldKey"]@localizer["FieldLabel"]@localizer["FieldType"]@localizer["FieldRequired"]@localizer["FieldClassification"]
@field.Key@field.Label@field.Type@(field.Required || field.RequiredToFinalize ? localizer["Yes"] : localizer["No"])@field.Classification
+ } +
+
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml new file mode 100644 index 00000000..bb17a61a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml @@ -0,0 +1,141 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Records.RecordDefinitionEditView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + Model.Name; + var readOnly = !Model.IsDraft; +} + +
+
+

@Model.Name v@(Model.Version) · @(Model.VersionRow == null ? "" : ((RmsDefinitionVersionState)Model.VersionRow.State).ToString())

+ +
+
+
+ @localizer["DefinitionHistory"] + @localizer["DefinitionLayout"] + @if (Model.IsDraft) + { + @localizer["DefinitionImpactPublish"] + } + else if (Model.Aggregate?.Draft == null && !(Model.Aggregate?.Definition.IsRetired ?? false)) + { +
@Html.AntiForgeryToken()
+ } + else if (Model.Aggregate?.Draft != null && !Model.IsDraft) + { + @localizer["DefinitionDraftVersion"] @Model.Aggregate.Draft.Version + } +
+
+
+ +
+ @if (!string.IsNullOrEmpty(Model.Message)) {
@Model.Message
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} + @if (readOnly) {
@localizer["DefinitionPublishedReadOnly"]
} + @if (Model.Issues.Count > 0) + { +
+ @localizer["DefinitionIssues"] +
    @foreach (var issue in Model.Issues) {
  • @issue.Severity @issue.Path @issue.Message
  • }
+
+ } + @if (Model.TemplateDiff != null && Model.TemplateDiff.Entries.Count > 0) + { +
+ @localizer["DefinitionTemplateDrift"] (@Model.Aggregate.Definition.TemplateKey) +
    @foreach (var e in Model.TemplateDiff.Entries.Take(20)) {
  • @e.Kind @e.Change @e.Key @e.Detail
  • }
+
+ } + +
+ @Html.AntiForgeryToken() + +
+
+
+
@localizer["DefinitionSettings"]
+
+
+
+
+
+
+
+
+
+
+

@localizer["DefinitionNumbering"]

+
+
+
+

@localizer["DefinitionClassification"]

+
+

@localizer["DefinitionClientSurface"]

+
+ + + + + + +
+
+
+

@localizer["DefinitionCapability"]: @Model.MinimumClientCapability

+
+
+
+
+
+
@localizer["DefinitionSchema"]
+
+

@localizer["DefinitionSchemaHelp"]

+ +
+
+
+
@localizer["DefinitionFieldTable"]
+
+ @foreach (var sec in Model.Schema?.Sections ?? new List()) + { +

@sec.Label @sec.Key @if (sec.Repeating) { @localizer["DefinitionRepeating"] } @if (sec.Rules.Count > 0) { @localizer["DefinitionRules"]: @sec.Rules.Count }

+ + + + @foreach (var field in sec.Fields) + { + + + + + + } + +
@localizer["FieldKey"]@localizer["FieldLabel"]@localizer["FieldType"]@localizer["FieldRequired"]@localizer["FieldClassification"]@localizer["FieldFlags"]
@field.Key@field.Label@field.Type @if (field.Options.Count > 0) { (@field.Options.Count) }@(field.Required || field.RequiredToFinalize ? localizer["Yes"] : localizer["No"])@field.Classification@string.Join(" ", new[] { field.Searchable ? "search" : null, field.Filterable ? "filter" : null, field.Sortable ? "sort" : null, field.Groupable ? "group" : null, field.Aggregatable ? "sum" : null, field.WorkflowExposed ? "workflow" : null, field.Exportable ? "export" : null, field.Rules.Count > 0 ? "rules:" + field.Rules.Count : null }.Where(s => s != null))
+ } +
+
+
+
+ @if (!readOnly) + { +
+ + + @localizer["DefinitionImpactPublish"] + @commonLocalizer["Cancel"] +
+ } +
+ @if (!readOnly && Model.Version > 1 || !readOnly && Model.Aggregate?.Published == null) + { +
@Html.AntiForgeryToken()
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/History.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/History.cshtml new file mode 100644 index 00000000..dab26bfa --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/History.cshtml @@ -0,0 +1,85 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@model Resgrid.Web.Areas.User.Models.Records.RecordDefinitionHistoryView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["DefinitionHistory"]; + string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; + var key = Model.Aggregate.Definition.DefinitionKey; +} + +
+
+

@localizer["DefinitionHistory"]: @Model.Aggregate.Definition.Name

+ +
+
+
+ @if (!Model.Aggregate.Definition.IsRetired) + { +
+ @Html.AntiForgeryToken() + +
+ } +
+
+
+ +
+ @if (!string.IsNullOrEmpty(Model.Message)) {
@Model.Message
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} + @if (Model.Aggregate.Definition.IsRetired) {
@localizer["DefinitionRetiredLabel"]: @Model.Aggregate.Definition.RetiredReason (@When(Model.Aggregate.Definition.RetiredOn))
} +
+
+ + + + @foreach (var v in Model.Versions) + { + + + + + + + + + + } + +
@localizer["DefinitionVersion"]@localizer["DefinitionState"]@localizer["DefinitionPreset"]@localizer["DefinitionCapability"]@localizer["DefinitionPublishedOn"]@localizer["DefinitionChangeNotes"]
v@(v.Version) @if (Model.Aggregate.Definition.CurrentPublishedVersion == v.Version) { current }@((RmsDefinitionVersionState)v.State)@((RmsLifecyclePreset)v.LifecyclePreset)@v.MinimumClientCapability@When(v.PublishedOn)@v.ChangeNotes@(v.IsDraft ? localizer["Edit"] : commonLocalizer["View"])
+
+ + + + +
+ @if (Model.Diff != null) + { +

@localizer["DefinitionDiff"] v@(Model.Diff.FromVersion) → v@(Model.Diff.ToVersion) @if (Model.Diff.Breaking) { @localizer["DefinitionBreaking"] }

+ @if (Model.Diff.Entries.Count == 0) {

@localizer["DefinitionNoChanges"]

} + else + { + @foreach (var e in Model.Diff.Entries) { }
@e.Kind@e.Change@e.Key@e.Detail
+ } + @if (Model.Diff.ToVersion > Model.Diff.FromVersion && Model.Versions.Any(v => v.Version == Model.Diff.ToVersion && v.IsPublished)) + { +
+ @Html.AntiForgeryToken() +

@localizer["DefinitionMigrateDrafts"]

+
+
+ + +
+
+ } + } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Impact.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Impact.cshtml new file mode 100644 index 00000000..e18b26c0 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Impact.cshtml @@ -0,0 +1,73 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Records.RecordDefinitionImpactView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["DefinitionImpact"]; + var p = Model.Preview; + var canPublish = Model.CanPublish && Model.VersionRow.IsDraft && p.Issues.All(i => i.Severity != "error"); +} + +
+
+

@localizer["DefinitionImpact"]: @Model.Aggregate.Definition.Name v@(Model.VersionRow.Version)

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

@localizer["DefinitionImpactIntro"]

+
+
@localizer["DefinitionCapability"]
@p.MinimumClientCapability
+
@localizer["FieldType"]
@string.Join(", ", p.FieldTypesUsed) @if (p.UsesRepeatingGroups) { @localizer["DefinitionRepeating"] }
+
@localizer["DefinitionPublishedVersion"]
@(p.CurrentPublishedVersion?.ToString() ?? "-")
+
@localizer["DefinitionOpenDrafts"]
@p.OpenDraftsOnCurrentVersion
+
@localizer["DefinitionFinalizedEarlier"]
@p.FinalizedRecordsOnEarlierVersions
+
@localizer["DefinitionBreaking"]
@(p.BreakingChange ? localizer["Yes"] : localizer["No"])
+
+ @if (p.Issues.Count > 0) + { +
+
    @foreach (var issue in p.Issues) {
  • @issue.Severity @issue.Path @issue.Message
  • }
+
+ } +

@localizer["DefinitionClients"]

+ + + + @foreach (var c in p.Clients) + { + + } + +
App@localizer["Enabled"]@localizer["DefinitionClientSurface"]@localizer["DefinitionCapability"]
@c.App@(c.Enabled ? localizer["Yes"] : localizer["No"])@(c.EligibleOnSurface ? localizer["Yes"] : localizer["No"])@c.RequiredCapability@c.Message
+ @if (Model.Diff != null && Model.Diff.Entries.Count > 0) + { +

@localizer["DefinitionDiff"] v@(Model.Diff.FromVersion) → v@(Model.Diff.ToVersion)

+ + @foreach (var e in Model.Diff.Entries) { } +
@e.Kind@e.Change@e.Key@e.Detail
+ } + @if (canPublish) + { +
+ @Html.AntiForgeryToken() + + + @commonLocalizer["Cancel"] +
+ } + else if (!Model.CanPublish) + { +

@localizer["DefinitionPublishNeedsPermission"]

+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Index.cshtml new file mode 100644 index 00000000..43335173 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Index.cshtml @@ -0,0 +1,75 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Records.RecordDefinitionsIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Definitions"]; +} + +
+
+

@localizer["Definitions"]

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

@localizer["DefinitionsIntro"]

+

@(Model.IncludeRetired ? localizer["DefinitionHideRetired"] : localizer["DefinitionIncludeRetired"])

+ + + + + + + + + + + + + + + + @foreach (var d in Model.Definitions) + { + + + + + + + + + + + + } + +
@localizer["DefinitionName"]@localizer["DefinitionKey"]@localizer["DefinitionCategory"]@localizer["DefinitionOwner"]@localizer["DefinitionPublishedVersion"]@localizer["DefinitionDraftVersion"]@localizer["DefinitionPreset"]@localizer["DefinitionCapability"]
@d.Name @if (d.Retired) { @localizer["DefinitionRetiredLabel"] } @if (d.ArtifactStatus == RmsArtifactStatus.Compatible.ToString()) { @d.JurisdictionProfileKey }@d.Key@d.Category@(d.Locked ? localizer["DefinitionLocked"].Value : d.Owner)@(d.PublishedVersion?.ToString() ?? "-")@(d.DraftVersion?.ToString() ?? "-")@d.LifecyclePreset@d.MinimumClientCapability + @if (!d.Locked) + { + @localizer["Edit"] + @localizer["DefinitionHistory"] + @if (d.PublishedVersion.HasValue && !d.Retired) + { + @localizer["NewRecord"] + } + } +
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Layout.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Layout.cshtml new file mode 100644 index 00000000..22265c27 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Layout.cshtml @@ -0,0 +1,102 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Records.RecordDefinitionLayoutView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["DefinitionLayout"]; + var order = 0; +} + +
+
+

@localizer["DefinitionLayout"]: @Model.DefinitionName

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

@localizer["DefinitionLayoutIntro"] @localizer["LayoutCurrentVersion"]: @Model.LayoutVersion · @localizer["LayoutDepartmentDefault"]: @Model.DepartmentLayoutVersion

+
+ @Html.AntiForgeryToken() + +
+
+
+
@localizer["TemplateSections"]
+
+ + + + @foreach (var key in Model.OrderedSectionKeys()) + { + var sec = Model.Schema.FindSection(key); + if (sec == null) { continue; } + order += 10; + var visible = !Model.Visible.TryGetValue(key, out var v) || v; + var pageBreak = Model.PageBreak.TryGetValue(key, out var pb) && pb; + + + + + + + + + + + + } + +
@localizer["LayoutOrder"]@localizer["DefinitionName"]@localizer["LayoutHeading"]@localizer["LayoutVisible"]@localizer["LayoutPageBreak"]
@sec.Label @sec.Key @if (sec.Repeating) { @localizer["DefinitionRepeating"] }
+ @localizer["LayoutFields"]: + @foreach (var field in sec.Fields) + { + var fieldVisible = !Model.FieldVisible.TryGetValue(field.Key, out var fv) || fv; + + } +
+
+
+
+
+
+
@localizer["DefinitionSettings"]
+
+
+
+
+
+ +
+
+
+ + @commonLocalizer["Cancel"] +
+
+
+
+
+@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Templates.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Templates.cshtml new file mode 100644 index 00000000..873a6e98 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Templates.cshtml @@ -0,0 +1,53 @@ +@model Resgrid.Web.Areas.User.Models.Records.RecordTemplatesView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["BrowseTemplates"]; +} + +
+
+

@localizer["BrowseTemplates"]

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

@localizer["TemplatesIntro"]

+ @foreach (var pack in Model.Packs) + { +
+
@pack.Name @if (pack.IsPreview) { @localizer["PreviewPack"] } @pack.Category · @localizer["TemplateReviewedOn"] @(pack.ReviewedOn?.ToString("yyyy-MM-dd") ?? "-") · @pack.ArtifactStatus
+
+

@pack.Description

+ @if (pack.Sources.Count > 0) + { +

@localizer["TemplateSources"]: @string.Join("; ", pack.Sources.Select(s => s.Identifier + " (" + s.Publisher + ", " + s.Version + ")"))

+ } + + + + @foreach (var t in pack.Definitions) + { + + + + + + + + + + } + +
@localizer["DefinitionName"]@localizer["DefinitionCategory"]@localizer["DefinitionPreset"]@localizer["TemplateSections"]@localizer["TemplateFields"]@localizer["DefinitionCapability"]
@t.Name
@t.Description
@t.Category@t.LifecyclePreset@t.SectionCount@t.FieldCount@t.MinimumClientCapability@localizer["TemplateUseThis"]
+

@localizer["TemplateProfile"]: @string.Join(", ", pack.SupportedProfiles) · @localizer["TemplateLocale"]: @string.Join(", ", pack.SupportedLocales)

+
+
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Details.cshtml new file mode 100644 index 00000000..49b7a0e6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Details.cshtml @@ -0,0 +1,136 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@model Resgrid.Web.Areas.User.Models.Records.RecordDeploymentDetailsView +@inject IStringLocalizer localizer +@{ + var o = Model.Deployment.Order; + ViewBag.Title = "Resgrid | " + o.OrderNumber; + string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; + string Who(string userId) => string.IsNullOrEmpty(userId) ? "-" : (Model.PersonnelNames.TryGetValue(userId, out var n) ? n : userId); + var closed = o.Status == (int)RmsExternalOrderStatus.ClosedOut; + var canAct = Model.CanEdit && !closed; + RmsDeploymentFillStatus[] Next(int status) + { + switch ((RmsDeploymentFillStatus)status) + { + case RmsDeploymentFillStatus.Requested: return new[] { RmsDeploymentFillStatus.Accepted, RmsDeploymentFillStatus.Declined }; + case RmsDeploymentFillStatus.Accepted: return new[] { RmsDeploymentFillStatus.Mobilized }; + case RmsDeploymentFillStatus.Mobilized: return new[] { RmsDeploymentFillStatus.CheckedIn }; + case RmsDeploymentFillStatus.CheckedIn: return new[] { RmsDeploymentFillStatus.Assigned, RmsDeploymentFillStatus.Released }; + case RmsDeploymentFillStatus.Assigned: return new[] { RmsDeploymentFillStatus.Released }; + case RmsDeploymentFillStatus.Released: return new[] { RmsDeploymentFillStatus.Demobilized }; + case RmsDeploymentFillStatus.Demobilized: return new[] { RmsDeploymentFillStatus.Returned }; + default: return new RmsDeploymentFillStatus[0]; + } + } +} + +
+
+

@o.OrderNumber @o.IncidentName @o.IncidentNumber @((RmsExternalOrderStatus)o.Status) @localizer["PreviewPack"]

+ +
+
+
+ @if (Model.Deployment.Record != null) { @localizer["DeploymentRecord"] } + @if (!string.IsNullOrEmpty(o.ArtifactFileName)) { @localizer["DeploymentArtifact"] } +
+
+
+ +
+ @if (!string.IsNullOrEmpty(Model.Message)) {
@Model.Message
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} +
@Model.ProvenanceStatement
+
+
+
+
@localizer["DeploymentOrder"]
+
+
+
@localizer["TemplateProfile"]
@o.ProfileKey v@(o.ProfileVersion) @if (!string.IsNullOrEmpty(o.HomeProfileKey)) { (@o.HomeProfileKey ⇄ @o.HostProfileKey) }
+
@localizer["DeploymentSource"]
@o.SourceScheme @o.SourceSystem @if (!string.IsNullOrEmpty(o.SourceVersion)) { v@(o.SourceVersion) }
+
@localizer["DeploymentLocation"]
@o.IncidentCountry @o.IncidentSubdivision
+
@localizer["DeploymentOffices"]
@o.OrderingOffice / @o.DispatchOffice
+
@localizer["DeploymentAgencies"]
@o.RequestingAgency · @o.ReceivingAgency · @o.SendingAgency
+
@localizer["DeploymentRole"]
@o.DepartmentRole
+
@localizer["DeploymentCost"]
@o.CostCode @o.AgreementReference
+
@localizer["DeploymentUnitsAndCurrency"]
@o.CurrencyCode @o.MeasurementSystem @o.TimeZoneId
+
@localizer["DeploymentArtifact"]
@(o.ArtifactFileName ?? "-") @if (!string.IsNullOrEmpty(o.ArtifactChecksum)) { @o.ArtifactChecksum.Substring(0, Math.Min(12, o.ArtifactChecksum.Length)) } @if (!string.IsNullOrEmpty(o.ArtifactSafeUrl)) { @localizer["DeploymentSourceLink"] }
+
@localizer["Created"]
@When(o.CreatedOn) · @Who(o.CreatedByUserId)
+
@localizer["DeploymentMobilizedOn"]
@When(o.MobilizedOn)
+
@localizer["DeploymentReleasedOn"]
@When(o.ReleasedOn)
+ @if (closed) {
@localizer["DeploymentClosedOutOn"]
@When(o.ClosedOutOn) · @Who(o.ClosedOutByUserId)
@o.CloseoutNotes
} +
+ @if (canAct) + { +
+ @Html.AntiForgeryToken() +

@localizer["DeploymentNewSnapshot"]

+ + +
+
+
+ @Html.AntiForgeryToken() +
+ + @if (!Model.Deployment.AllReturned) { @localizer["DeploymentCloseoutBlocked"] } +
+ } +
+
+
+
+
+
@localizer["DeploymentFills"] (@Model.Deployment.Fills.Count)
+
+ + + + @foreach (var f in Model.Deployment.Fills) + { + var status = (RmsDeploymentFillStatus)f.Status; + + + + + + + + + } + +
@localizer["DeploymentRequestNumber"]@localizer["DeploymentResource"]@localizer["DeploymentPosition"]@localizer["DeploymentAssigned"]@localizer["DeploymentStatus"]
@f.RequestNumber @if (!string.IsNullOrEmpty(f.ParentRequestNumber)) { ↳ @f.ParentRequestNumber }
@f.RequestCategory @f.FillNumber
@f.ResourceKind @f.ResourceType @if (!string.IsNullOrEmpty(f.ResourceTypeScheme)) { (@f.ResourceTypeScheme) }@f.Position @if (f.IsTrainee) { (t) }@Who(f.AssignedUserId)
@f.HomeUnit @f.HostAgency
@status
@When(f.Status switch { 9 => f.ReturnedOn, 8 => f.DemobilizedOn, 7 => f.ReleasedOn, 6 => f.AssignedOn, 5 => f.CheckedInOn, 4 => f.MobilizedOn, 3 => f.FilledOn, 2 => f.FilledOn, _ => f.RequestedOn })@if (status == RmsDeploymentFillStatus.Declined) {
@f.DeclineReason }
+ @if (canAct) + { + foreach (var next in Next(f.Status)) + { +
+ @Html.AntiForgeryToken() + +
+ } + } +
+ @if (canAct) + { +
+ @Html.AntiForgeryToken() +

@localizer["DeploymentAddFill"]

+
+
+
+
+
+
+ } +
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Index.cshtml new file mode 100644 index 00000000..8e6016de --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Index.cshtml @@ -0,0 +1,51 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@model Resgrid.Web.Areas.User.Models.Records.RecordDeploymentsIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Deployments"]; +} + +
+
+

@localizer["Deployments"] @localizer["PreviewPack"]

+ +
+
+
+ @if (Model.CanCreate) { @localizer["NewDeployment"] } +
+
+
+ +
+ @if (!string.IsNullOrEmpty(Model.Message)) {
@Model.Message
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} +
+
+

@localizer["DeploymentsIntro"]

+

@(Model.IncludeClosed ? localizer["DeploymentHideClosed"] : localizer["DeploymentIncludeClosed"])

+ + + + @foreach (var o in Model.Orders) + { + + + + + + + + + + } + +
@localizer["DeploymentOrderNumber"]@localizer["DeploymentIncident"]@localizer["TemplateProfile"]@localizer["DeploymentSource"]@localizer["DeploymentStatus"]@localizer["Created"]
@o.OrderNumber@o.IncidentName @o.IncidentNumber@o.ProfileKey@o.SourceScheme@(string.IsNullOrEmpty(o.SourceSystem) ? "" : " / " + o.SourceSystem) @if (!string.IsNullOrEmpty(o.SourceVersion)) { v@(o.SourceVersion) }@((RmsExternalOrderStatus)o.Status)@o.CreatedOn.TimeConverterToString(Model.Department)@commonLocalizer["View"]
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/New.cshtml new file mode 100644 index 00000000..59cbf0c5 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/New.cshtml @@ -0,0 +1,64 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Records.RecordDeploymentNewView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["NewDeployment"]; +} + +
+
+

@localizer["NewDeployment"] @localizer["PreviewPack"]

+ +
+
+ +
+ @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} +
@localizer["DeploymentPreviewStatement"]
+
+ @Html.AntiForgeryToken() +
+
+
+
@localizer["DeploymentOrder"]
+
+
+
+
+
+
+
+
+
+
+
+
@localizer["DeploymentArtifactHelp"]
+
+
+
+
+
+
@localizer["DeploymentFirstFill"]
+
+

@localizer["DeploymentFirstFillHelp"]

+
+
+
+
+
+
+
+
+
+
+
+
+ + @commonLocalizer["Cancel"] +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml index c58f644c..8757c15c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml @@ -57,14 +57,15 @@ } @if (selection.SourceKind == RmsEvidenceKind.RunCardActivation) {

Capture the dispatch decisions recorded by Run Cards for this report’s Call. A missing recorded decision cannot be reconstructed by RMS.

} @if (selection.SourceKind == RmsEvidenceKind.InventoryUsage) {

Refresh the evidence for all recorded inventory consumption. This action does not consume stock again.

} - @if (selection.SourceKind == RmsEvidenceKind.TrackingFix || selection.SourceKind == RmsEvidenceKind.CertificationSnapshot || selection.SourceKind == RmsEvidenceKind.ChatPromotion) + @if (selection.SourceKind == RmsEvidenceKind.ModuleProjection) {

@localizer["ProjectionIntro"]

} + @if (selection.SourceKind == RmsEvidenceKind.TrackingFix || selection.SourceKind == RmsEvidenceKind.CertificationSnapshot || selection.SourceKind == RmsEvidenceKind.ChatPromotion || selection.SourceKind == RmsEvidenceKind.ModuleProjection) { -
@(selection.SourceKind == RmsEvidenceKind.TrackingFix ? "Units" : selection.SourceKind == RmsEvidenceKind.CertificationSnapshot ? "Personnel" : "Messages") +
@(selection.SourceKind == RmsEvidenceKind.TrackingFix ? "Units" : selection.SourceKind == RmsEvidenceKind.CertificationSnapshot ? "Personnel" : selection.SourceKind == RmsEvidenceKind.ModuleProjection ? localizer["ProjectionKind"].Value : "Messages") @if (selection.Choices.Count == 0) {

No accessible items on this page.

} @foreach (var choice in selection.Choices) { var field = selection.SourceKind == RmsEvidenceKind.TrackingFix ? "Input.UnitIds" : selection.SourceKind == RmsEvidenceKind.CertificationSnapshot ? "Input.UserIds" : "Input.SourceIds"; -