From a5bb46c7b2725c2b25bfdec2d8ecaf8f8f181563 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 8 Sep 2026 23:43:42 -0700 Subject: [PATCH] RG-T66 implemented work orders and monthly billing --- Core/Resgrid.Config/ReadinessProConfig.cs | 4 + .../Areas/User/Checklists/Checklists.ar.resx | 40 +++ .../Areas/User/Checklists/Checklists.de.resx | 40 +++ .../Areas/User/Checklists/Checklists.el.resx | 40 +++ .../Areas/User/Checklists/Checklists.en.resx | 40 +++ .../Areas/User/Checklists/Checklists.es.resx | 40 +++ .../Areas/User/Checklists/Checklists.fr.resx | 40 +++ .../Areas/User/Checklists/Checklists.it.resx | 40 +++ .../Areas/User/Checklists/Checklists.pl.resx | 40 +++ .../Areas/User/Checklists/Checklists.resx | 40 +++ .../Areas/User/Checklists/Checklists.sv.resx | 40 +++ .../Areas/User/Checklists/Checklists.uk.resx | 40 +++ .../Areas/User/WorkOrders/WorkOrders.ar.resx | 157 +++++++++ .../Areas/User/WorkOrders/WorkOrders.cs | 4 + .../Areas/User/WorkOrders/WorkOrders.de.resx | 157 +++++++++ .../Areas/User/WorkOrders/WorkOrders.el.resx | 157 +++++++++ .../Areas/User/WorkOrders/WorkOrders.en.resx | 157 +++++++++ .../Areas/User/WorkOrders/WorkOrders.es.resx | 157 +++++++++ .../Areas/User/WorkOrders/WorkOrders.fr.resx | 157 +++++++++ .../Areas/User/WorkOrders/WorkOrders.it.resx | 157 +++++++++ .../Areas/User/WorkOrders/WorkOrders.pl.resx | 157 +++++++++ .../Areas/User/WorkOrders/WorkOrders.resx | 157 +++++++++ .../Areas/User/WorkOrders/WorkOrders.sv.resx | 157 +++++++++ .../Areas/User/WorkOrders/WorkOrders.uk.resx | 157 +++++++++ Core/Resgrid.Model/AdpTableBinding.cs | 2 +- Core/Resgrid.Model/AuditLogTypes.cs | 3 +- .../Checklists/ChecklistMobile.cs | 29 ++ .../Checklists/ChecklistReporting.cs | 114 +++++++ .../Checklists/ChecklistWorkflowPayload.cs | 27 +- .../Checklists/ReadinessHistoryFields.cs | 2 +- Core/Resgrid.Model/PermissionTypes.cs | 4 +- Core/Resgrid.Model/ReportTypes.cs | 6 +- .../Repositories/IChecklistRepository.cs | 4 +- .../IReadinessProBillingRepository.cs | 15 + .../Repositories/IWorkOrderRepository.cs | 20 ++ .../Repositories/IWorkflowRunRepository.cs | 1 + .../IChecklistAuthorizationService.cs | 2 + .../IChecklistScheduledReportService.cs | 9 + .../Services/IChecklistsService.cs | 13 +- .../Services/IReadinessProBillingService.cs | 48 +++ .../Services/ISubscriptionsService.cs | 2 +- .../Services/IWorkOrderNotificationService.cs | 23 ++ .../Services/IWorkOrdersService.cs | 143 ++++++++ .../WorkOrders/WorkOrderModels.cs | 123 +++++++ .../WorkOrders/WorkOrderPermissionCatalog.cs | 13 + .../WorkOrders/WorkOrderWorkflowPayload.cs | 34 ++ .../WorkflowTemplateVariableCatalog.cs | 10 + .../Resgrid.Model/WorkflowTriggerEventType.cs | 5 +- Core/Resgrid.Services/AdpTableBindings.cs | 4 +- Core/Resgrid.Services/AuditService.cs | 10 +- .../ChecklistAccessMutationObserver.cs | 2 +- .../ChecklistAssignmentService.cs | 18 +- .../ChecklistAuthorizationService.cs | 25 +- Core/Resgrid.Services/ChecklistCalendar.cs | 11 +- Core/Resgrid.Services/ChecklistMobile.cs | 120 +++++++ .../ChecklistReminderService.cs | 2 +- .../ChecklistReportDocuments.cs | 79 +++++ Core/Resgrid.Services/ChecklistReporting.cs | 150 +++++++++ .../ChecklistScheduledReportService.cs | 50 +++ Core/Resgrid.Services/ChecklistsScheduling.cs | 61 ++-- Core/Resgrid.Services/ChecklistsService.cs | 69 +++- Core/Resgrid.Services/FeatureFlagMutations.cs | 31 +- Core/Resgrid.Services/FeatureToggleService.cs | 2 +- .../Resgrid.Services/GdprDataExportService.cs | 28 +- .../Resgrid.Services/ProtectedFieldCatalog.cs | 15 +- .../ReadinessAccessService.cs | 6 +- .../ReadinessHistoryProtectionService.cs | 6 +- .../ReadinessProBillingService.cs | 32 ++ .../Records/DomainEventOutboxService.cs | 18 +- .../Evidence/RecordEvidenceAdapters.cs | 40 ++- Core/Resgrid.Services/ServicesModule.cs | 5 + Core/Resgrid.Services/SubscriptionsService.cs | 4 +- .../WorkOrderAuthorizationService.cs | 114 +++++++ Core/Resgrid.Services/WorkOrderFiles.cs | 75 +++++ Core/Resgrid.Services/WorkOrderGdprExport.cs | 37 ++ .../WorkOrderNotificationService.cs | 86 +++++ Core/Resgrid.Services/WorkOrdersService.cs | 317 ++++++++++++++++++ .../WorkflowSampleDataGenerator.cs | 6 + Core/Resgrid.Services/WorkflowService.cs | 18 +- .../WorkflowTemplateContextBuilder.cs | 12 + .../OutboundEventProvider.cs | 2 +- .../WorkflowEventProvider.cs | 19 +- .../Resgrid.Providers.Claims/ClaimsLogic.cs | 6 +- .../ResgridClaimTypes.cs | 1 + .../ResgridResources.cs | 2 + .../Migrations/M0195_AddChecklistReminders.cs | 2 +- .../Migrations/M0197_AddWorkOrders.cs | 97 ++++++ .../M0195_AddChecklistRemindersPg.cs | 2 +- .../Migrations/M0197_AddWorkOrdersPg.cs | 97 ++++++ .../ChecklistDepartmentCleanup.cs | 6 +- .../ChecklistRepository.cs | 19 ++ .../ChecklistSchedulingRepository.cs | 4 +- .../DepartmentDataProtectionBulkRepository.cs | 10 +- .../FeatureFlagRepository.cs | 5 +- .../Modules/ApiDataModule.cs | 2 + .../Modules/DataModule.cs | 2 + .../Modules/NonWebDataModule.cs | 2 + .../Modules/TestingDataModule.cs | 2 + .../ReadinessProBillingRepository.cs | 47 +++ .../RmsRepositories.cs | 13 +- .../RmsRetentionRepository.cs | 4 +- .../WorkOrderRepository.cs | 81 +++++ .../WorkflowRunRepository.cs | 16 +- .../Allocations/trigger-baseline.json | 7 +- .../Rms/ChecklistReadinessEvidenceTests.cs | 47 +++ .../Rms/RecordsEvidenceServiceTests.cs | 4 +- .../Rms/RmsIdentifierPinTests.cs | 5 +- .../Rms/RmsPostgreSqlDatabaseTests.cs | 43 ++- .../Rms/RmsRetentionDatabaseTests.cs | 2 +- .../Services/ChecklistAuthorizationTests.cs | 33 ++ .../Services/ChecklistCalendarApiTests.cs | 3 + .../Services/ChecklistEventDeliveryTests.cs | 10 +- .../Services/ChecklistGdprTests.cs | 2 +- .../Services/ChecklistP1M3ApiTests.cs | 86 +++++ .../Services/ChecklistP1M3DatabaseTests.cs | 47 +++ .../Services/ChecklistP1M3Tests.cs | 122 +++++++ .../Services/ChecklistP1M4ApiTests.cs | 57 ++++ .../Services/ChecklistP1M4DatabaseTests.cs | 36 ++ .../Services/ChecklistP1M4Tests.cs | 182 ++++++++++ .../Services/ChecklistPageAcceptanceTests.cs | 2 +- .../Services/ChecklistPr504BoundaryTests.cs | 116 +++++++ .../Services/ChecklistPr504DatabaseTests.cs | 72 ++++ .../Services/ChecklistPr504PagingTests.cs | 55 +++ .../Services/ChecklistPr504SecurityTests.cs | 177 ++++++++++ .../Services/ChecklistPr504ServiceTests.cs | 82 +++++ .../ChecklistSchedulingMemoryStore.cs | 2 +- .../Services/ChecklistWorkflowTests.cs | 10 +- .../Services/ContactsServicePreplanTests.cs | 5 +- .../DepartmentDataMigrationEngineTests.cs | 2 +- .../Services/GdprExportProtectedDataTests.cs | 2 +- .../Services/ReadinessAccessServiceTests.cs | 8 +- .../Services/ReadinessApiTests.cs | 6 +- .../Services/ReadinessBillingReviewTests.cs | 22 ++ .../RemainingCandidateProtectionTests.cs | 5 +- .../Services/WorkOrderDatabaseTests.cs | 180 ++++++++++ .../Services/WorkOrderEvidenceTests.cs | 47 +++ .../Services/WorkOrderGdprTests.cs | 51 +++ .../Services/WorkOrderHttpTests.cs | 130 +++++++ .../Services/WorkOrderLocalizationTests.cs | 65 ++++ .../Services/WorkOrderNotificationTests.cs | 54 +++ .../Services/WorkOrderP2M1Tests.cs | 157 +++++++++ .../Services/WorkOrderProtectionTests.cs | 83 +++++ .../User/AdpAddonBillingAuthorizationTests.cs | 2 +- .../Resgrid.Tests/Web/checklist-live.test.cjs | 33 ++ Tests/Resgrid.Tests/Web/work-orders.test.cjs | 41 +++ .../Controllers/v4/CalendarController.cs | 2 +- .../v4/ChecklistApiControllerBase.cs | 56 ++++ .../v4/ChecklistManagementController.cs | 78 +++++ .../v4/ChecklistReportingController.cs | 19 ++ .../Controllers/v4/ChecklistRunsController.cs | 95 ++++++ .../Controllers/v4/ChecklistsController.cs | 5 +- .../Controllers/v4/WorkOrdersController.cs | 77 +++++ .../Helpers/ClaimsAuthorizationHelper.cs | 2 + .../Helpers/ResponseHelper.cs | 2 +- .../v4/Checklists/ChecklistApiContracts.cs | 135 ++++++++ .../v4/WorkOrders/WorkOrderApiContracts.cs | 12 + .../Resgrid.Web.Services.xml | 27 ++ Web/Resgrid.Web.Services/Startup.cs | 2 + .../User/Controllers/CalendarController.cs | 4 +- .../ChecklistReportingController.cs | 53 +++ .../Controllers/ChecklistReportsController.cs | 27 ++ .../User/Controllers/ChecklistsController.cs | 16 +- .../ChecklistsSchedulingController.cs | 10 +- .../User/Controllers/ProfileController.cs | 15 +- .../ReadinessProBillingController.cs | 52 +++ .../User/Controllers/ReportsController.cs | 2 +- .../User/Controllers/SecurityController.cs | 2 +- .../WorkOrderFormCultureAttribute.cs | 24 ++ .../User/Controllers/WorkOrdersController.cs | 101 ++++++ .../Checklists/ChecklistScheduleViews.cs | 2 +- .../User/Models/WorkOrders/WorkOrderViews.cs | 9 + .../Areas/User/Views/Calendar/Index.cshtml | 1 + .../User/Views/Checklists/Compliance.cshtml | 36 ++ .../Areas/User/Views/Checklists/Due.cshtml | 5 +- .../Areas/User/Views/Checklists/Index.cshtml | 1 + .../Views/Checklists/ReadinessPacket.cshtml | 20 ++ .../User/Views/Checklists/_LiveScripts.cshtml | 2 + .../Views/ReadinessProBilling/Index.cshtml | 32 ++ .../Reports/ChecklistComplianceReport.cshtml | 9 + .../Areas/User/Views/Reports/Index.cshtml | 2 + .../Areas/User/Views/Security/Index.cshtml | 5 +- .../User/Views/Shared/_Navigation.cshtml | 5 + .../User/Views/Subscription/Index.cshtml | 1 + .../Areas/User/Views/WorkOrders/Detail.cshtml | 67 ++++ .../Areas/User/Views/WorkOrders/Edit.cshtml | 39 +++ .../Areas/User/Views/WorkOrders/Index.cshtml | 31 ++ .../Areas/User/Views/WorkOrders/Locked.cshtml | 13 + .../Views/WorkOrders/_FilterFields.cshtml | 8 + .../User/Views/WorkOrders/_Protection.cshtml | 7 + .../User/Views/WorkOrders/_Scripts.cshtml | 9 + .../Areas/User/Views/Workflows/Edit.cshtml | 3 +- .../Areas/User/Views/Workflows/New.cshtml | 3 +- .../Helpers/ClaimsAuthorizationHelper.cs | 2 + Web/Resgrid.Web/Startup.cs | 2 + .../common/signalr/resgrid.common.signalr.js | 4 +- .../app/internal/checklists/checklist-live.js | 22 ++ .../internal/workorders/readiness-billing.js | 28 ++ .../js/app/internal/workorders/work-orders.js | 66 ++++ .../Logic/ChecklistReminderLogic.cs | 2 +- .../Logic/ChecklistSchedulingLogic.cs | 2 +- .../Logic/ReportDeliveryLogic.cs | 20 +- 201 files changed, 7851 insertions(+), 241 deletions(-) create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.cs create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx create mode 100644 Core/Resgrid.Model/Checklists/ChecklistMobile.cs create mode 100644 Core/Resgrid.Model/Checklists/ChecklistReporting.cs create mode 100644 Core/Resgrid.Model/Repositories/IReadinessProBillingRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs create mode 100644 Core/Resgrid.Model/Services/IChecklistScheduledReportService.cs create mode 100644 Core/Resgrid.Model/Services/IReadinessProBillingService.cs create mode 100644 Core/Resgrid.Model/Services/IWorkOrderNotificationService.cs create mode 100644 Core/Resgrid.Model/Services/IWorkOrdersService.cs create mode 100644 Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs create mode 100644 Core/Resgrid.Model/WorkOrders/WorkOrderPermissionCatalog.cs create mode 100644 Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs create mode 100644 Core/Resgrid.Services/ChecklistMobile.cs create mode 100644 Core/Resgrid.Services/ChecklistReportDocuments.cs create mode 100644 Core/Resgrid.Services/ChecklistReporting.cs create mode 100644 Core/Resgrid.Services/ChecklistScheduledReportService.cs create mode 100644 Core/Resgrid.Services/ReadinessProBillingService.cs create mode 100644 Core/Resgrid.Services/WorkOrderAuthorizationService.cs create mode 100644 Core/Resgrid.Services/WorkOrderFiles.cs create mode 100644 Core/Resgrid.Services/WorkOrderGdprExport.cs create mode 100644 Core/Resgrid.Services/WorkOrderNotificationService.cs create mode 100644 Core/Resgrid.Services/WorkOrdersService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0197_AddWorkOrders.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0197_AddWorkOrdersPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/ReadinessProBillingRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs create mode 100644 Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistP1M3ApiTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistP1M3DatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistP1M3Tests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistP1M4ApiTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistP1M4DatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistPr504DatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistPr504PagingTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistPr504ServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderEvidenceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderLocalizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderNotificationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderProtectionTests.cs create mode 100644 Tests/Resgrid.Tests/Web/checklist-live.test.cjs create mode 100644 Tests/Resgrid.Tests/Web/work-orders.test.cjs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/ChecklistApiControllerBase.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/ChecklistManagementController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/ChecklistReportingController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/ChecklistRunsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Checklists/ChecklistApiContracts.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WorkOrders/WorkOrderApiContracts.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportingController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/ReadinessProBillingController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/WorkOrderFormCultureAttribute.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/Compliance.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/ReadinessPacket.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/_LiveScripts.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/ReadinessProBilling/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Reports/ChecklistComplianceReport.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/_FilterFields.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Protection.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Scripts.cshtml create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklist-live.js create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/workorders/readiness-billing.js create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js diff --git a/Core/Resgrid.Config/ReadinessProConfig.cs b/Core/Resgrid.Config/ReadinessProConfig.cs index 5f8cd008b..1220ec606 100644 --- a/Core/Resgrid.Config/ReadinessProConfig.cs +++ b/Core/Resgrid.Config/ReadinessProConfig.cs @@ -3,6 +3,10 @@ namespace Resgrid.Config /// Department-level monthly Readiness Pro prices. Provider checkout is implemented in P2-M1. public static class ReadinessProConfig { + public static string StripeProductId = "prod_VDtkPNAa2qNBx3"; + public static string StripeTestProductId = ""; + public static string PaddleProductId = "pro_01m20xwmzpnkxzp7mm7nwwxp7p"; + public static string PaddleTestProductId = ""; public static decimal StripeMonthlyAmount = 150m; public static decimal PaddleMonthlyAmount = 195m; } diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx index 931422c5e..aaef61f01 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx @@ -637,4 +637,44 @@ وقت الملخص اليومي (المنطقة الزمنية للمؤسسة؛ يحل محل بداية المناوبة) إرسال ملخص عند بداية المناوبة أدخل وقتًا واحدًا بتنسيق HH:mm. + تقرير الالتزام بقوائم التحقق + تقرير الفحوص الفائتة + ملف أدلة الجاهزية + الفحوص المتوقعة + الفحوص المكتملة + الفحوص المنجزة في موعدها + المواعيد النهائية الفائتة + الفحوص المعفاة + نسبة الإكمال (%) + اتجاه المواعيد النهائية الفائتة + تُختار الفحوص حسب وقت بدايتها ضمن الفترة. لا تدخل في النسبة إلا النوافذ المجدولة التي انتهت. تُستثنى الفحوص المعفاة؛ وتظل الإكمالات المتأخرة إخفاقاً في الموعد. تظهر الفحوص عند الطلب والنوافذ المفتوحة في السجل فقط. + تُدرج فقط النتائج التي تملك صلاحية الاطلاع عليها. + من تاريخ + إلى تاريخ (غير مشمول) + جميع الأهداف + تحديث التقرير + تصدير CSV + طباعة التقرير + جدولة التقرير + معرّف البلاغ + عدد الأيام السابقة + تنزيل PDF وبيان الأدلة + تغطي الأدلة الأيام المحددة قبل البلاغ وتُقيّم في وقت البلاغ. لا تثبت الإرسالات اللاحقة جاهزية سابقة. تبقى السجلات المصدرية في الوحدات المسؤولة عنها. + الوحدات المرسلة + المعدات المصروفة وقت البلاغ + تحتوي الرسالة المجدولة على إشعار PDF ورابط آمن للتقرير. افتح التقرير للتحقق من صلاحياتك الحالية وفتح البيانات المحمية؛ لا تُرسل نتائج الفحوص أو البيانات الشخصية بالبريد الإلكتروني. + حدد فترة زمنية مرتبة بتوقيت UTC لا تتجاوز 93 يوماً. + هذا التقرير كبير جداً. حدد فترة أقصر. + البلاغ غير متاح أو تم رفض الوصول. + بيانات المصدر التاريخي للمعدات ناقصة أو غير متسقة. + بعض نتائج الفحوص خارج نطاق صلاحياتك. + حدثت بعض تغييرات الحالة بعد البلاغ؛ ولا يمكن إعادة بناء حالتها السابقة. + بعض الوحدات المرسلة غير متاحة أو خارج نطاق صلاحياتك. + أسماء الوحدات هي الأسماء الحالية؛ الأسماء التاريخية غير متاحة. + سجل معدات المقاولين غير متاح. + سجلات صرف المعدات التاريخية غير متاحة. + ستتوفر أدلة أوامر العمل في مرحلة لاحقة. + يتطلب حفظ أدلة الجاهزية بلاغاً ويغطي الثلاثين يوماً السابقة له؛ تواريخ التغطية المخصصة غير مدعومة. + نوع الهدف + المحطة / المجموعة diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx index f41488a8e..eab17b90a 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx @@ -637,4 +637,44 @@ Uhrzeit der täglichen Zusammenfassung (Zeitzone der Organisation; ersetzt Schichtbeginn) Zusammenfassung bei Schichtbeginn senden Geben Sie eine Uhrzeit im Format HH:mm ein. + Bericht zur Checklisten-Erfüllung + Bericht über versäumte Prüfungen + Bereitschaftsnachweise + Fällige Prüfungen + Abgeschlossene Prüfungen + Fristgerechte Prüfungen + Versäumte Fristen + Entschuldigte Prüfungen + Erfüllungsquote (%) + Verlauf versäumter Fristen + Der Zeitraum wählt Prüfungen nach ihrem Beginn aus. Nur abgelaufene geplante Zeitfenster zählen für die Quote. Entschuldigte Prüfungen sind ausgenommen; verspätete Abschlüsse bleiben Fristversäumnisse. Spontane Prüfungen und offene Zeitfenster erscheinen nur im Verlauf. + Es werden nur Ergebnisse angezeigt, für die Sie eine Leseberechtigung haben. + Ab Datum + Bis Datum (ausschließlich) + Alle Ziele + Bericht aktualisieren + CSV exportieren + Bericht drucken + Bericht planen + Einsatz-ID + Rückblick in Tagen + PDF und Nachweisverzeichnis herunterladen + Die Nachweise umfassen die gewählten Tage vor dem Einsatz und werden zum Einsatzzeitpunkt bewertet. Spätere Einreichungen belegen keine frühere Bereitschaft. Die Quelldaten verbleiben in ihren zuständigen Modulen. + Alarmierte Einheiten + Zum Einsatzzeitpunkt ausgegebene Ausrüstung + Der geplante Versand enthält einen PDF-Hinweis und einen sicheren Berichtslink. Öffnen Sie den Bericht, um Ihre aktuelle Berechtigung zu prüfen und geschützte Daten freizugeben. Prüfergebnisse und personenbezogene Daten werden nicht per E-Mail versendet. + Wählen Sie einen chronologischen UTC-Zeitraum von höchstens 93 Tagen. + Dieser Bericht ist zu groß. Wählen Sie einen kürzeren Zeitraum. + Der Einsatz ist nicht verfügbar oder der Zugriff wurde verweigert. + Die Herkunftsnachweise der früheren Ausrüstung sind unvollständig oder widersprüchlich. + Einige Prüfergebnisse liegen außerhalb Ihrer Berechtigungen. + Einige Statusänderungen erfolgten nach dem Einsatz; der frühere Status kann nicht rekonstruiert werden. + Einige alarmierte Einheiten sind nicht verfügbar oder liegen außerhalb Ihrer Berechtigungen. + Die Einheitsbezeichnungen sind aktuell; frühere Namen sind nicht verfügbar. + Der Ausrüstungsverlauf von Auftragnehmern ist nicht verfügbar. + Frühere Ausgabenachweise für Ausrüstung sind nicht verfügbar. + Nachweise zu Arbeitsaufträgen werden in einer späteren Phase verfügbar sein. + Die Erfassung von Bereitschaftsnachweisen erfordert einen Einsatz und umfasst die 30 Tage davor; eigene Zeiträume werden nicht unterstützt. + Zieltyp + Wache / Gruppe diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx index 10b84bdd5..68f0c9af3 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx @@ -637,4 +637,44 @@ Ώρα ημερήσιας σύνοψης (ζώνη ώρας οργανισμού· υπερισχύει της έναρξης βάρδιας) Αποστολή σύνοψης στην έναρξη βάρδιας Εισαγάγετε μία ώρα σε μορφή HH:mm. + Αναφορά συμμόρφωσης λιστών ελέγχου + Αναφορά παραλειφθέντων ελέγχων + Φάκελος ετοιμότητας + Αναμενόμενοι έλεγχοι + Ολοκληρωμένοι έλεγχοι + Εμπρόθεσμοι έλεγχοι + Χαμένες προθεσμίες + Δικαιολογημένες εξαιρέσεις + Ποσοστό ολοκλήρωσης (%) + Εξέλιξη χαμένων προθεσμιών + Η περίοδος επιλέγει ελέγχους βάσει της ώρας έναρξης. Στο ποσοστό υπολογίζονται μόνο προγραμματισμένα χρονικά παράθυρα που έληξαν. Οι δικαιολογημένες εξαιρέσεις αποκλείονται· οι καθυστερημένες ολοκληρώσεις παραμένουν χαμένες προθεσμίες. Οι έκτακτοι έλεγχοι και τα ανοικτά παράθυρα εμφανίζονται μόνο στο ιστορικό. + Περιλαμβάνονται μόνο αποτελέσματα που έχετε δικαίωμα να δείτε. + Από ημερομηνία + Έως ημερομηνία (δεν περιλαμβάνεται) + Όλοι οι στόχοι + Ανανέωση αναφοράς + Εξαγωγή CSV + Εκτύπωση αναφοράς + Προγραμματισμός αναφοράς + Αναγνωριστικό συμβάντος + Ημέρες αναδρομής + Λήψη PDF και καταλόγου τεκμηρίων + Τα τεκμήρια καλύπτουν τις επιλεγμένες ημέρες πριν από το συμβάν και αξιολογούνται κατά τον χρόνο του συμβάντος. Μεταγενέστερες υποβολές δεν αποδεικνύουν προηγούμενη ετοιμότητα. Τα αρχικά αρχεία παραμένουν στις αρμόδιες ενότητες. + Μονάδες που κινητοποιήθηκαν + Εξοπλισμός που είχε χορηγηθεί κατά το συμβάν + Η προγραμματισμένη αποστολή περιέχει ειδοποίηση PDF και ασφαλή σύνδεσμο αναφοράς. Ανοίξτε την αναφορά για επαλήθευση της τρέχουσας πρόσβασης και ξεκλείδωμα προστατευμένων δεδομένων· αποτελέσματα ελέγχων και προσωπικά δεδομένα δεν αποστέλλονται με ηλεκτρονικό ταχυδρομείο. + Επιλέξτε χρονολογική περίοδο UTC έως 93 ημερών. + Η αναφορά είναι πολύ μεγάλη. Επιλέξτε μικρότερη περίοδο. + Το συμβάν δεν είναι διαθέσιμο ή δεν επιτρέπεται η πρόσβαση. + Η προέλευση του παλαιότερου εξοπλισμού είναι ελλιπής ή ασυνεπής. + Ορισμένα αποτελέσματα ελέγχων υπερβαίνουν τα δικαιώματά σας. + Ορισμένες αλλαγές κατάστασης έγιναν μετά το συμβάν· η προηγούμενη κατάσταση δεν μπορεί να ανασυσταθεί. + Ορισμένες μονάδες που κινητοποιήθηκαν δεν είναι διαθέσιμες ή υπερβαίνουν τα δικαιώματά σας. + Οι ονομασίες μονάδων είναι οι τρέχουσες· παλαιότερες ονομασίες δεν είναι διαθέσιμες. + Το ιστορικό εξοπλισμού αναδόχων δεν είναι διαθέσιμο. + Τα παλαιότερα αρχεία χορήγησης εξοπλισμού δεν είναι διαθέσιμα. + Τεκμήρια εντολών εργασίας θα διατεθούν σε επόμενη φάση. + Η καταγραφή τεκμηρίων ετοιμότητας απαιτεί συμβάν και καλύπτει τις προηγούμενες 30 ημέρες· προσαρμοσμένες ημερομηνίες δεν υποστηρίζονται. + Τύπος στόχου + Σταθμός / ομάδα diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx index 40081c2aa..c4e399d6a 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx @@ -637,4 +637,44 @@ Daily digest time (department time zone; overrides shift starts) Send a digest at shift start Enter one time in HH:mm format. + Checklist compliance report + Missed checklist report + Readiness packet + Expected checks + Completed checks + On-time checks + Missed deadlines + Excused checks + Completion rate (%) + Missed deadline trend + The period selects checks by their start time. Only elapsed scheduled windows count toward the rate. Excused checks are excluded; late completions remain missed deadlines. On-demand checks and open windows appear in history only. + Only results you are authorized to view are included. + From date + Until date (exclusive) + All targets + Refresh report + Export CSV + Print report + Schedule report + Call ID + Lookback days + Download PDF and manifest + Evidence covers the selected days before the call, evaluated at call time. Later submissions do not prove earlier readiness. Source records remain with their owning modules. + Dispatched units + Equipment issued at call time + Scheduled delivery contains a PDF notice and a secure report link. Open the report to verify your current access and unlock protected data; checklist results and personal data are not emailed. + Select a chronological UTC period of at most 93 days. + This report is too large. Select a shorter period. + The call is unavailable or access is denied. + Historical equipment provenance is incomplete or inconsistent. + Some checklist results are outside your permissions. + Some lifecycle changes occurred after the call; their earlier state cannot be reconstructed. + Some dispatched units are unavailable or outside your permissions. + Unit labels are current; historical unit names are unavailable. + Contractor equipment history is unavailable. + Historical equipment issue records are unavailable. + Work-order evidence will be available in a later phase. + Readiness evidence capture requires a call and uses the 30 days before that call; custom coverage dates are not supported. + Target type + Station / group diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx index efbd7a342..f49829e0a 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx @@ -637,4 +637,44 @@ Hora del resumen diario (zona horaria de la organización; sustituye al inicio del turno) Enviar un resumen al inicio del turno Introduzca una sola hora con el formato HH:mm. + Informe de cumplimiento de listas de verificación + Informe de verificaciones omitidas + Expediente de preparación + Verificaciones previstas + Verificaciones completadas + Verificaciones a tiempo + Plazos incumplidos + Verificaciones justificadas + Tasa de finalización (%) + Evolución de plazos incumplidos + El período selecciona verificaciones por su hora de inicio. Solo las ventanas programadas vencidas cuentan para la tasa. Se excluyen las verificaciones justificadas; las finalizaciones tardías siguen siendo incumplimientos de plazo. Las verificaciones a demanda y las ventanas abiertas aparecen solo en el historial. + Solo se incluyen resultados que tiene permiso para ver. + Desde la fecha + Hasta la fecha (excluida) + Todos los objetivos + Actualizar informe + Exportar CSV + Imprimir informe + Programar informe + ID del incidente + Días anteriores + Descargar PDF y manifiesto + Las evidencias cubren los días seleccionados antes del incidente y se evalúan en el momento del incidente. Los envíos posteriores no demuestran una preparación anterior. Los registros de origen permanecen en sus módulos responsables. + Unidades movilizadas + Equipo entregado en el momento del incidente + El envío programado contiene un aviso PDF y un enlace seguro al informe. Abra el informe para verificar su acceso actual y desbloquear datos protegidos; no se envían por correo resultados de verificaciones ni datos personales. + Seleccione un período UTC cronológico de hasta 93 días. + Este informe es demasiado grande. Seleccione un período más corto. + El incidente no está disponible o se ha denegado el acceso. + La procedencia histórica del equipo está incompleta o es incoherente. + Algunos resultados de verificaciones quedan fuera de sus permisos. + Algunos cambios de estado ocurrieron después del incidente; no se puede reconstruir su estado anterior. + Algunas unidades movilizadas no están disponibles o quedan fuera de sus permisos. + Los nombres de las unidades son los actuales; no se dispone de los nombres históricos. + El historial de equipo de contratistas no está disponible. + Los registros históricos de entrega de equipo no están disponibles. + Las evidencias de órdenes de trabajo estarán disponibles en una fase posterior. + La captura de evidencias de preparación requiere un incidente y abarca los 30 días anteriores; no se admiten fechas de cobertura personalizadas. + Tipo de objetivo + Estación / grupo diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx index 9da7c7eb4..48b474147 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx @@ -637,4 +637,44 @@ Heure du récapitulatif quotidien (fuseau de l’organisation ; remplace le début de garde) Envoyer un récapitulatif au début de garde Saisissez une seule heure au format HH:mm. + Rapport de conformité des listes de contrôle + Rapport des contrôles manqués + Dossier de préparation opérationnelle + Contrôles attendus + Contrôles terminés + Contrôles dans les délais + Échéances manquées + Contrôles dispensés + Taux de réalisation (%) + Évolution des échéances manquées + La période sélectionne les contrôles selon leur début. Seules les plages planifiées échues comptent dans le taux. Les contrôles dispensés sont exclus ; les réalisations tardives restent des échéances manquées. Les contrôles à la demande et les plages ouvertes figurent uniquement dans l’historique. + Seuls les résultats que vous êtes autorisé à consulter sont inclus. + Date de début + Date de fin (exclue) + Toutes les cibles + Actualiser le rapport + Exporter en CSV + Imprimer le rapport + Programmer le rapport + Identifiant d’intervention + Jours précédents + Télécharger le PDF et le manifeste + Les éléments couvrent les jours sélectionnés avant l’intervention et sont évalués à sa date. Les soumissions ultérieures ne prouvent pas une préparation antérieure. Les enregistrements sources restent dans leurs modules responsables. + Unités mobilisées + Équipement attribué au moment de l’intervention + L’envoi programmé contient un avis PDF et un lien sécurisé vers le rapport. Ouvrez le rapport pour vérifier vos droits actuels et déverrouiller les données protégées ; les résultats de contrôle et les données personnelles ne sont pas envoyés par courriel. + Sélectionnez une période UTC chronologique de 93 jours au maximum. + Ce rapport est trop volumineux. Sélectionnez une période plus courte. + L’intervention est indisponible ou l’accès est refusé. + La provenance historique de l’équipement est incomplète ou incohérente. + Certains résultats de contrôle dépassent vos autorisations. + Certains changements d’état sont survenus après l’intervention ; leur état antérieur ne peut pas être reconstitué. + Certaines unités mobilisées sont indisponibles ou dépassent vos autorisations. + Les noms des unités sont actuels ; les noms historiques sont indisponibles. + L’historique des équipements des prestataires est indisponible. + Les anciens enregistrements d’attribution d’équipement sont indisponibles. + Les éléments relatifs aux ordres de travail seront disponibles dans une phase ultérieure. + La capture des éléments de préparation exige une intervention et couvre les 30 jours précédents ; les dates de couverture personnalisées ne sont pas prises en charge. + Type de cible + Caserne / groupe diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx index b8daf3045..4da3f6498 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx @@ -637,4 +637,44 @@ Ora del riepilogo giornaliero (fuso orario dell’organizzazione; sostituisce l’inizio turno) Invia un riepilogo all’inizio del turno Inserisci un solo orario nel formato HH:mm. + Rapporto di conformità delle liste di controllo + Rapporto dei controlli mancati + Fascicolo di prontezza operativa + Controlli previsti + Controlli completati + Controlli puntuali + Scadenze mancate + Controlli giustificati + Tasso di completamento (%) + Andamento delle scadenze mancate + Il periodo seleziona i controlli in base all’ora di inizio. Nel tasso contano solo le finestre pianificate scadute. I controlli giustificati sono esclusi; i completamenti tardivi restano scadenze mancate. I controlli su richiesta e le finestre aperte compaiono solo nella cronologia. + Sono inclusi solo i risultati che sei autorizzato a visualizzare. + Dalla data + Alla data (esclusa) + Tutti i destinatari + Aggiorna rapporto + Esporta CSV + Stampa rapporto + Programma rapporto + ID intervento + Giorni precedenti + Scarica PDF e manifesto + Le evidenze coprono i giorni selezionati prima dell’intervento e sono valutate al momento dell’intervento. Gli invii successivi non dimostrano una prontezza precedente. I dati originali restano nei moduli responsabili. + Unità mobilitate + Attrezzatura assegnata al momento dell’intervento + L’invio programmato contiene un avviso PDF e un collegamento sicuro al rapporto. Apri il rapporto per verificare l’accesso attuale e sbloccare i dati protetti; i risultati dei controlli e i dati personali non vengono inviati per e-mail. + Seleziona un periodo UTC cronologico di massimo 93 giorni. + Questo rapporto è troppo grande. Seleziona un periodo più breve. + L’intervento non è disponibile o l’accesso è negato. + La provenienza storica dell’attrezzatura è incompleta o incoerente. + Alcuni risultati dei controlli non rientrano nelle tue autorizzazioni. + Alcuni cambiamenti di stato sono avvenuti dopo l’intervento; lo stato precedente non può essere ricostruito. + Alcune unità mobilitate non sono disponibili o non rientrano nelle tue autorizzazioni. + I nomi delle unità sono quelli attuali; i nomi storici non sono disponibili. + La cronologia delle attrezzature degli appaltatori non è disponibile. + I registri storici di assegnazione dell’attrezzatura non sono disponibili. + Le evidenze degli ordini di lavoro saranno disponibili in una fase successiva. + L’acquisizione delle evidenze di prontezza richiede un intervento e copre i 30 giorni precedenti; non sono supportate date personalizzate. + Tipo di destinatario + Stazione / gruppo diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx index 498480efc..88dcfd555 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx @@ -637,4 +637,44 @@ Godzina codziennego podsumowania (strefa czasowa organizacji; zastępuje początek zmiany) Wyślij podsumowanie na początku zmiany Wprowadź jedną godzinę w formacie HH:mm. + Raport realizacji list kontrolnych + Raport pominiętych kontroli + Pakiet dowodów gotowości + Oczekiwane kontrole + Ukończone kontrole + Kontrole terminowe + Przekroczone terminy + Usprawiedliwione kontrole + Wskaźnik ukończenia (%) + Trend przekroczonych terminów + Okres wybiera kontrole według czasu rozpoczęcia. Do wskaźnika wlicza się tylko zakończone zaplanowane okna czasowe. Usprawiedliwione kontrole są wykluczone; spóźnione ukończenia nadal oznaczają przekroczenie terminu. Kontrole na żądanie i otwarte okna pojawiają się tylko w historii. + Uwzględniono tylko wyniki, do których masz uprawnienia. + Od daty + Do daty (bez tego dnia) + Wszystkie cele + Odśwież raport + Eksportuj CSV + Drukuj raport + Zaplanuj raport + Identyfikator zdarzenia + Liczba poprzednich dni + Pobierz PDF i wykaz dowodów + Dowody obejmują wybrane dni przed zdarzeniem i są oceniane na moment zdarzenia. Późniejsze zgłoszenia nie dowodzą wcześniejszej gotowości. Rekordy źródłowe pozostają w odpowiedzialnych za nie modułach. + Zadysponowane jednostki + Sprzęt wydany w chwili zdarzenia + Zaplanowana wysyłka zawiera powiadomienie PDF i bezpieczny odnośnik do raportu. Otwórz raport, aby zweryfikować bieżący dostęp i odblokować chronione dane; wyniki kontroli i dane osobowe nie są wysyłane pocztą elektroniczną. + Wybierz chronologiczny okres UTC nie dłuższy niż 93 dni. + Ten raport jest zbyt duży. Wybierz krótszy okres. + Zdarzenie jest niedostępne lub odmówiono dostępu. + Historyczne pochodzenie sprzętu jest niepełne lub niespójne. + Niektóre wyniki kontroli wykraczają poza Twoje uprawnienia. + Niektóre zmiany stanu nastąpiły po zdarzeniu; wcześniejszego stanu nie można odtworzyć. + Niektóre zadysponowane jednostki są niedostępne lub wykraczają poza Twoje uprawnienia. + Nazwy jednostek są aktualne; historyczne nazwy są niedostępne. + Historia sprzętu wykonawców jest niedostępna. + Historyczne zapisy wydania sprzętu są niedostępne. + Dowody dotyczące zleceń pracy będą dostępne na późniejszym etapie. + Utrwalenie dowodów gotowości wymaga zdarzenia i obejmuje poprzednie 30 dni; własne daty zakresu nie są obsługiwane. + Typ celu + Stacja / grupa diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx index 40081c2aa..c4e399d6a 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx @@ -637,4 +637,44 @@ Daily digest time (department time zone; overrides shift starts) Send a digest at shift start Enter one time in HH:mm format. + Checklist compliance report + Missed checklist report + Readiness packet + Expected checks + Completed checks + On-time checks + Missed deadlines + Excused checks + Completion rate (%) + Missed deadline trend + The period selects checks by their start time. Only elapsed scheduled windows count toward the rate. Excused checks are excluded; late completions remain missed deadlines. On-demand checks and open windows appear in history only. + Only results you are authorized to view are included. + From date + Until date (exclusive) + All targets + Refresh report + Export CSV + Print report + Schedule report + Call ID + Lookback days + Download PDF and manifest + Evidence covers the selected days before the call, evaluated at call time. Later submissions do not prove earlier readiness. Source records remain with their owning modules. + Dispatched units + Equipment issued at call time + Scheduled delivery contains a PDF notice and a secure report link. Open the report to verify your current access and unlock protected data; checklist results and personal data are not emailed. + Select a chronological UTC period of at most 93 days. + This report is too large. Select a shorter period. + The call is unavailable or access is denied. + Historical equipment provenance is incomplete or inconsistent. + Some checklist results are outside your permissions. + Some lifecycle changes occurred after the call; their earlier state cannot be reconstructed. + Some dispatched units are unavailable or outside your permissions. + Unit labels are current; historical unit names are unavailable. + Contractor equipment history is unavailable. + Historical equipment issue records are unavailable. + Work-order evidence will be available in a later phase. + Readiness evidence capture requires a call and uses the 30 days before that call; custom coverage dates are not supported. + Target type + Station / group diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx index 32ce38b5f..da140c9c3 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx @@ -637,4 +637,44 @@ Tid för daglig sammanställning (organisationens tidszon; ersätter skiftstart) Skicka en sammanställning vid skiftstart Ange en enda tid i formatet HH:mm. + Rapport om checklistornas genomförande + Rapport om missade kontroller + Beredskapsunderlag + Förväntade kontroller + Slutförda kontroller + Kontroller i tid + Missade tidsfrister + Undantagna kontroller + Andel slutförda (%) + Utveckling av missade tidsfrister + Perioden väljer kontroller utifrån deras starttid. Endast avslutade schemalagda tidsfönster räknas i andelen. Undantagna kontroller räknas inte; sena slutföranden förblir missade tidsfrister. Kontroller vid behov och öppna tidsfönster visas endast i historiken. + Endast resultat som du har behörighet att se ingår. + Från datum + Till datum (exklusive) + Alla mål + Uppdatera rapport + Exportera CSV + Skriv ut rapport + Schemalägg rapport + Insats-ID + Antal föregående dagar + Hämta PDF och underlagsförteckning + Underlaget omfattar de valda dagarna före insatsen och bedöms vid insatstidpunkten. Senare inlämningar bevisar inte tidigare beredskap. Källposterna finns kvar i sina ansvariga moduler. + Larmade enheter + Utrustning utlämnad vid insatstidpunkten + Den schemalagda leveransen innehåller ett PDF-meddelande och en säker rapportlänk. Öppna rapporten för att kontrollera din aktuella behörighet och låsa upp skyddade uppgifter; kontrollresultat och personuppgifter skickas inte med e-post. + Välj en kronologisk UTC-period på högst 93 dagar. + Rapporten är för stor. Välj en kortare period. + Insatsen är inte tillgänglig eller åtkomst nekas. + Utrustningens historiska ursprungsuppgifter är ofullständiga eller motsägelsefulla. + Vissa kontrollresultat ligger utanför din behörighet. + Vissa statusändringar skedde efter insatsen; den tidigare statusen kan inte återskapas. + Vissa larmade enheter är inte tillgängliga eller ligger utanför din behörighet. + Enhetsnamnen är aktuella; historiska namn är inte tillgängliga. + Entreprenörernas utrustningshistorik är inte tillgänglig. + Historiska uppgifter om utlämnad utrustning är inte tillgängliga. + Underlag för arbetsorder blir tillgängligt i en senare fas. + Insamling av beredskapsunderlag kräver en insats och omfattar de föregående 30 dagarna; egna täckningsdatum stöds inte. + Måltyp + Station / grupp diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx index ba898be5e..2f8f17157 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx @@ -637,4 +637,44 @@ Час щоденного зведення (часовий пояс організації; замінює початок зміни) Надсилати зведення на початку зміни Введіть один час у форматі HH:mm. + Звіт про виконання контрольних списків + Звіт про пропущені перевірки + Пакет доказів готовності + Очікувані перевірки + Завершені перевірки + Вчасні перевірки + Пропущені терміни + Виправдані пропуски + Відсоток завершення (%) + Динаміка пропущених термінів + Період вибирає перевірки за часом початку. До відсотка враховуються лише заплановані часові вікна, що завершилися. Виправдані пропуски виключаються; пізні завершення залишаються порушеннями терміну. Перевірки на вимогу та відкриті вікна відображаються лише в історії. + Включено лише результати, які ви маєте право переглядати. + З дати + До дати (не включно) + Усі цілі + Оновити звіт + Експортувати CSV + Друкувати звіт + Запланувати звіт + Ідентифікатор події + Кількість попередніх днів + Завантажити PDF і перелік доказів + Докази охоплюють вибрані дні до події та оцінюються на момент події. Пізніші подання не доводять попередньої готовності. Вихідні записи залишаються у відповідальних за них модулях. + Направлені підрозділи + Спорядження, видане на момент події + Запланована доставка містить PDF-повідомлення та захищене посилання на звіт. Відкрийте звіт, щоб перевірити поточні права доступу й розблокувати захищені дані; результати перевірок і персональні дані не надсилаються електронною поштою. + Виберіть хронологічний період UTC тривалістю не більше 93 днів. + Цей звіт завеликий. Виберіть коротший період. + Подія недоступна або в доступі відмовлено. + Історичні дані про походження спорядження неповні або суперечливі. + Деякі результати перевірок виходять за межі ваших прав доступу. + Деякі зміни стану відбулися після події; попередній стан неможливо відновити. + Деякі направлені підрозділи недоступні або виходять за межі ваших прав доступу. + Назви підрозділів поточні; історичні назви недоступні. + Історія спорядження підрядників недоступна. + Історичні записи видачі спорядження недоступні. + Докази щодо нарядів на роботи будуть доступні на наступному етапі. + Збереження доказів готовності потребує події та охоплює попередні 30 днів; довільні дати періоду не підтримуються. + Тип цілі + Станція / група diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx new file mode 100644 index 000000000..599b43585 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx @@ -0,0 +1,157 @@ + + +text/microsoft-resx +2.0 +System.Resources.ResXResourceReader, System.Windows.Forms +System.Resources.ResXResourceWriter, System.Windows.Forms +أوامر العمل +أمر عمل جديد +تعديل أمر العمل +الحالة +الأولوية +النوع +الكل +الوحدة +المجموعة +المعدات +المسندة إليّ +تصفية +الرقم +العنوان +تاريخ الاستحقاق +لا توجد أوامر عمل تطابق هذه المرشحات. +السابق +التالي +لا شيء +الوصف +الموقع أو المبنى أو المكان +العملة +التكلفة المقدّرة +التكلفة المعتمدة +مركز التكلفة +بيانات المورد +مرجع الضمان +عمل حرج للسلامة +عمل خطر +سجّل الإجراء المعتمد ومراجع عزل مصادر الطاقة. لا ينفّذ هذا التطبيق العزل الفعلي ولا يجيز إعادة المعدات إلى الخدمة. +مرجع الإجراء +إصدار الإجراء +مرجع التصريح +مرجع العزل +الموظفون المؤهلون +خطوات المهمة +إضافة خطوة +حفظ +إلغاء +أبلغ عنه +مسند إلى +المعالجة +السبب +أدلة التحقق والاختبار +تحقّق منه +تصدير +قبول الإسناد +إسناد +المستخدم +الدور +تغيير الحالة +المبرر +معرّف أمر العمل الأصلي +أؤكد اكتمال جميع خطوات المهمة. +النشاط +تعليق +إضافة تعليق +العمالة +تاريخ العمل +الساعات +الأجر بالساعة +ملاحظة +المستخدم الحالي +إضافة عمالة +القطع +تُسجّل هذه القطع كنص حر. لا يتغير رصيد المخزون. +الكمية +تكلفة الوحدة +الإجراءات +مبطلة +إبطال +إضافة قطعة +الملفات +مسحوب +سحب +ملف +ملفات PDF أو PNG أو JPEG بحجم يصل إلى 10 ميغابايت. يجب اجتياز الفحص الأمني قبل قبول الملفات. +رفع +مكتمل +قيد الانتظار +التحقق من الهوية والفتح +يبقى السجل متاحًا. يتطلب العمل الجديد اشتراكًا نشطًا في Readiness Pro وتفعيل وحدة الصيانة. +يلزم وجود عضوية نشطة في القسم. +يلزم Readiness Pro لأعمال الصيانة الجديدة. +ليس لديك إذن لتنفيذ هذا الإجراء. +أمر العمل أو الدليل غير متاح. +اختر هدفًا متاحًا في قسمك. +اختر عضوًا نشطًا في القسم أو دورًا للموظفين. +تغيّر أمر العمل هذا. أعد تحميله قبل الحفظ. +تحقق من القيم المدخلة والحقول المطلوبة. +تحقق من هويتك لفتح بيانات أمر العمل المحمية. +هذا الانتقال بين الحالات غير مسموح. +يجب قبول الإسناد قبل بدء العمل. +أكمل بيانات إجراء السلامة وإصداره والتصريح والعزل والموظفين المؤهلين. +أكمل جميع خطوات المهمة قبل وضع علامة اكتمال العمل. +يجب أن يتحقق من العمل الحرج للسلامة شخص مخوّل غير الشخص الذي أكمله. +هذا السجل أكبر من أن يُعرض في طلب واحد. +اختر ملف PDF أو PNG أو JPEG صالحًا لا يتجاوز 10 ميغابايت. +لم يجتز الملف الفحص. حاول مجددًا عندما يكون الفحص متاحًا. +بلغ أمر العمل الحد الأقصى لعدد الملفات. +فشل التحقق من سلامة الملف. +تعذر إكمال الطلب. لا تزال القيم التي أدخلتها موجودة في هذه الصفحة. +إدارة أوامر العمل +عرض أوامر عمل الأعضاء الآخرين +تقييم أوامر العمل وإسنادها والتحقق منها ضمن النطاق المسموح. +عرض أوامر العمل في المجموعة أو القسم المسموح. يحتفظ مقدّمو الطلبات بسجلاتهم الخاصة. +تم إنشاء أمر العمل +تغيّرت حالة أمر العمل +تم إسناد أمر العمل +تحديث أمر عمل +يحتاج أمر عمل في قسمك إلى الانتباه. افتح Resgrid للاطلاع على التفاصيل. +Readiness Pro +شهريًا +اشتراك +إدارة الاشتراك +إلغاء التجديد +الاشتراك في Readiness Pro غير متاح لإعدادات الفوترة هذه. +لا تفعّل عملية الدفع الوصول إلى الصيانة. يبدأ الوصول بعد التحقق من الدفع. +مدفوع حتى +تم إلغاء التجديد. يستمر الوصول طوال الفترة المدفوعة. +مطلوب +مقبول +مسند +قيد التنفيذ +معلّق +مكتمل +مغلق +مرفوض +مكرر +ملغى +منخفضة +عادية +عالية +طارئة +تصحيحي +وقائي +فحص +مرافق +آخر +تم تقديم طلب العمل +تم تحديث أمر العمل +تم تغيير الحالة +تم تغيير الإسناد +تم قبول الإسناد +تمت إضافة تعليق +تمت إضافة عمالة +تمت إضافة قطعة +تم إبطال قطعة +تمت إضافة ملف +تم سحب ملف + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.cs b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.cs new file mode 100644 index 000000000..df2ad2250 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.cs @@ -0,0 +1,4 @@ +namespace Resgrid.Localization.Areas.User.WorkOrders +{ + public class WorkOrders { } +} diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx new file mode 100644 index 000000000..203888932 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx @@ -0,0 +1,157 @@ + + +text/microsoft-resx +2.0 +System.Resources.ResXResourceReader, System.Windows.Forms +System.Resources.ResXResourceWriter, System.Windows.Forms +Arbeitsaufträge +Neuer Arbeitsauftrag +Arbeitsauftrag bearbeiten +Status +Priorität +Typ +Alle +Einheit +Gruppe +Ausrüstung +Mir zugewiesen +Filtern +Nummer +Titel +Fälligkeitsdatum +Keine Arbeitsaufträge entsprechen diesen Filtern. +Zurück +Weiter +Keine +Beschreibung +Standort, Gebäude oder Ort +Währung +Geschätzte Kosten +Genehmigte Kosten +Kostenstelle +Lieferantendetails +Garantiereferenz +Sicherheitskritische Arbeiten +Gefährliche Arbeiten +Dokumentieren Sie das genehmigte Verfahren und die Freischaltungsnachweise. Diese Anwendung führt keine physische Freischaltung durch und genehmigt keine Wiederinbetriebnahme. +Verfahrensreferenz +Verfahrensversion +Genehmigungsreferenz +Freischaltungsreferenz +Qualifiziertes Personal +Arbeitsschritte +Schritt hinzufügen +Speichern +Abbrechen +Gemeldet von +Zugewiesen an +Behebung +Ursache +Prüf- und Testnachweise +Geprüft von +Exportieren +Zuweisung annehmen +Zuweisen +Benutzer +Rolle +Status ändern +Begründung +ID des maßgeblichen Arbeitsauftrags +Ich bestätige, dass alle Arbeitsschritte abgeschlossen sind. +Aktivität +Kommentar +Kommentar hinzufügen +Arbeitsaufwand +Arbeitsdatum +Stunden +Stundensatz +Notiz +Aktueller Benutzer +Arbeitsaufwand hinzufügen +Teile +Diese Teile werden als Freitext erfasst. Der Lagerbestand wird nicht geändert. +Menge +Stückkosten +Aktionen +Storniert +Stornieren +Teil hinzufügen +Dateien +Zurückgezogen +Zurückziehen +Datei +PDF, PNG oder JPEG, bis zu 10 MB. Dateien müssen vor der Annahme die Sicherheitsprüfung bestehen. +Hochladen +Erledigt +Ausstehend +Identität bestätigen und öffnen +Der Verlauf bleibt verfügbar. Für neue Arbeiten sind ein aktives Readiness Pro-Abonnement und das aktivierte Wartungsmodul erforderlich. +Eine aktive Mitgliedschaft in der Organisation ist erforderlich. +Für neue Wartungsarbeiten ist Readiness Pro erforderlich. +Sie haben keine Berechtigung für diese Aktion. +Dieser Arbeitsauftrag oder Nachweis ist nicht verfügbar. +Wählen Sie ein verfügbares Ziel in Ihrer Organisation. +Wählen Sie ein aktives Mitglied oder eine Personalrolle der Organisation. +Dieser Arbeitsauftrag wurde geändert. Laden Sie ihn vor dem Speichern neu. +Prüfen Sie die eingegebenen Werte und Pflichtfelder. +Bestätigen Sie Ihre Identität, um geschützte Arbeitsauftragsdaten zu öffnen. +Dieser Statuswechsel ist nicht zulässig. +Die Zuweisung muss vor Arbeitsbeginn angenommen werden. +Vervollständigen Sie Verfahren, Version, Genehmigung, Freischaltung und Angaben zum qualifizierten Personal. +Schließen Sie alle Arbeitsschritte ab, bevor Sie die Arbeit als abgeschlossen markieren. +Sicherheitskritische Arbeiten müssen von einer befugten Person geprüft werden, die sie nicht selbst abgeschlossen hat. +Dieser Verlauf ist zu umfangreich für eine einzelne Anfrage. +Wählen Sie eine gültige PDF-, PNG- oder JPEG-Datei bis zu 10 MB. +Die Datei hat die Sicherheitsprüfung nicht bestanden. Versuchen Sie es erneut, wenn die Prüfung verfügbar ist. +Dieser Arbeitsauftrag hat seine Dateigrenze erreicht. +Die Integritätsprüfung der Datei ist fehlgeschlagen. +Die Anfrage konnte nicht abgeschlossen werden. Ihre Eingaben bleiben auf dieser Seite erhalten. +Arbeitsaufträge verwalten +Arbeitsaufträge anderer Mitglieder anzeigen +Arbeitsaufträge im erlaubten Bereich sichten, zuweisen und prüfen. +Arbeitsaufträge in der erlaubten Gruppe oder Organisation anzeigen. Antragsteller behalten Zugriff auf ihren eigenen Verlauf. +Arbeitsauftrag erstellt +Status des Arbeitsauftrags geändert +Arbeitsauftrag zugewiesen +Aktualisierung eines Arbeitsauftrags +Ein Arbeitsauftrag in Ihrer Organisation erfordert Aufmerksamkeit. Öffnen Sie Resgrid für weitere Informationen. +Readiness Pro +Monatlich +Abonnieren +Abonnement verwalten +Verlängerung kündigen +Der Readiness Pro-Kauf ist für diese Abrechnungskonfiguration nicht verfügbar. +Der Bezahlvorgang aktiviert keinen Wartungszugriff. Der Zugriff beginnt nach Bestätigung der Zahlung. +Bezahlt bis +Die Verlängerung wurde gekündigt. Der Zugriff bleibt für den bezahlten Zeitraum bestehen. +Angefordert +Angenommen +Zugewiesen +In Bearbeitung +Pausiert +Abgeschlossen +Geschlossen +Abgelehnt +Duplikat +Abgebrochen +Niedrig +Normal +Hoch +Notfall +Korrektiv +Präventiv +Inspektion +Gebäude +Sonstiges +Arbeitsanfrage eingereicht +Arbeitsauftrag aktualisiert +Status geändert +Zuweisung geändert +Zuweisung angenommen +Kommentar hinzugefügt +Arbeitsaufwand hinzugefügt +Teil hinzugefügt +Teil storniert +Datei hinzugefügt +Datei zurückgezogen + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx new file mode 100644 index 000000000..13962c817 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx @@ -0,0 +1,157 @@ + + +text/microsoft-resx +2.0 +System.Resources.ResXResourceReader, System.Windows.Forms +System.Resources.ResXResourceWriter, System.Windows.Forms +Εντολές εργασίας +Νέα εντολή εργασίας +Επεξεργασία εντολής εργασίας +Κατάσταση +Προτεραιότητα +Τύπος +Όλα +Μονάδα +Ομάδα +Εξοπλισμός +Ανατεθειμένες σε εμένα +Φιλτράρισμα +Αριθμός +Τίτλος +Προθεσμία +Καμία εντολή εργασίας δεν αντιστοιχεί σε αυτά τα φίλτρα. +Προηγούμενο +Επόμενο +Κανένα +Περιγραφή +Χώρος, κτίριο ή τοποθεσία +Νόμισμα +Εκτιμώμενο κόστος +Εγκεκριμένο κόστος +Κέντρο κόστους +Στοιχεία προμηθευτή +Αναφορά εγγύησης +Εργασία κρίσιμη για την ασφάλεια +Επικίνδυνη εργασία +Καταγράψτε την εγκεκριμένη διαδικασία και τις αναφορές απομόνωσης. Η εφαρμογή δεν εκτελεί φυσική απομόνωση ούτε εγκρίνει την επαναφορά σε λειτουργία. +Αναφορά διαδικασίας +Έκδοση διαδικασίας +Αναφορά άδειας +Αναφορά απομόνωσης +Ειδικευμένο προσωπικό +Βήματα εργασίας +Προσθήκη βήματος +Αποθήκευση +Ακύρωση +Αναφέρθηκε από +Ανατέθηκε σε +Αποκατάσταση +Αιτία +Αποδεικτικά επαλήθευσης και δοκιμών +Επαληθεύτηκε από +Εξαγωγή +Αποδοχή ανάθεσης +Ανάθεση +Χρήστης +Ρόλος +Αλλαγή κατάστασης +Αιτιολογία +Αναγνωριστικό κύριας εντολής εργασίας +Επιβεβαιώνω ότι ολοκληρώθηκαν όλα τα βήματα της εργασίας. +Δραστηριότητα +Σχόλιο +Προσθήκη σχολίου +Εργατοώρες +Ημερομηνία εργασίας +Ώρες +Ωριαία αμοιβή +Σημείωση +Τρέχων χρήστης +Προσθήκη εργατοωρών +Ανταλλακτικά +Τα ανταλλακτικά καταγράφονται ως ελεύθερο κείμενο. Το απόθεμα δεν μεταβάλλεται. +Ποσότητα +Κόστος μονάδας +Ενέργειες +Ακυρωμένο +Ακύρωση +Προσθήκη ανταλλακτικού +Αρχεία +Αποσυρμένο +Απόσυρση +Αρχείο +PDF, PNG ή JPEG, έως 10 MB. Τα αρχεία πρέπει να περάσουν τον έλεγχο ασφαλείας πριν γίνουν αποδεκτά. +Μεταφόρτωση +Ολοκληρωμένο +Σε αναμονή +Επαλήθευση ταυτότητας και άνοιγμα +Το ιστορικό παραμένει διαθέσιμο. Οι νέες εργασίες απαιτούν ενεργή συνδρομή Readiness Pro και ενεργοποιημένη ενότητα συντήρησης. +Απαιτείται ενεργή ιδιότητα μέλους του τμήματος. +Απαιτείται Readiness Pro για νέες εργασίες συντήρησης. +Δεν έχετε άδεια για αυτή την ενέργεια. +Αυτή η εντολή εργασίας ή το αποδεικτικό δεν είναι διαθέσιμο. +Επιλέξτε έναν διαθέσιμο στόχο στο τμήμα σας. +Επιλέξτε ένα ενεργό μέλος του τμήματος ή έναν ρόλο προσωπικού. +Αυτή η εντολή εργασίας άλλαξε. Φορτώστε την ξανά πριν από την αποθήκευση. +Ελέγξτε τις τιμές που εισαγάγατε και τα υποχρεωτικά πεδία. +Επαληθεύστε την ταυτότητά σας για να ανοίξετε τα προστατευμένα δεδομένα της εντολής εργασίας. +Αυτή η αλλαγή κατάστασης δεν επιτρέπεται. +Η ανάθεση πρέπει να γίνει αποδεκτή πριν αρχίσει η εργασία. +Συμπληρώστε τη διαδικασία ασφαλείας, την έκδοση, την άδεια, την απομόνωση και τα στοιχεία ειδικευμένου προσωπικού. +Ολοκληρώστε όλα τα βήματα πριν χαρακτηρίσετε την εργασία ολοκληρωμένη. +Η εργασία που είναι κρίσιμη για την ασφάλεια πρέπει να επαληθεύεται από εξουσιοδοτημένο άτομο διαφορετικό από εκείνο που την ολοκλήρωσε. +Το ιστορικό είναι πολύ μεγάλο για να εμφανιστεί σε ένα αίτημα. +Επιλέξτε έγκυρο αρχείο PDF, PNG ή JPEG έως 10 MB. +Το αρχείο δεν πέρασε τον έλεγχο. Δοκιμάστε ξανά όταν ο έλεγχος είναι διαθέσιμος. +Αυτή η εντολή εργασίας έφτασε το όριο αρχείων. +Ο έλεγχος ακεραιότητας του αρχείου απέτυχε. +Το αίτημα δεν ολοκληρώθηκε. Οι τιμές που εισαγάγατε παραμένουν σε αυτή τη σελίδα. +Διαχείριση εντολών εργασίας +Προβολή εντολών εργασίας άλλων μελών +Αξιολόγηση, ανάθεση και επαλήθευση εντολών εργασίας εντός του επιτρεπόμενου πεδίου. +Προβολή εντολών εργασίας της επιτρεπόμενης ομάδας ή του τμήματος. Οι αιτούντες διατηρούν το δικό τους ιστορικό. +Δημιουργήθηκε εντολή εργασίας +Άλλαξε η κατάσταση εντολής εργασίας +Ανατέθηκε εντολή εργασίας +Ενημέρωση εντολής εργασίας +Μια εντολή εργασίας στο τμήμα σας χρειάζεται προσοχή. Ανοίξτε το Resgrid για λεπτομέρειες. +Readiness Pro +Μηνιαία +Εγγραφή +Διαχείριση συνδρομής +Ακύρωση ανανέωσης +Η αγορά του Readiness Pro δεν είναι διαθέσιμη για αυτή τη ρύθμιση χρέωσης. +Η διαδικασία πληρωμής δεν ενεργοποιεί την πρόσβαση στη συντήρηση. Η πρόσβαση ξεκινά μετά την επαλήθευση της πληρωμής. +Πληρωμένο έως +Η ανανέωση ακυρώθηκε. Η πρόσβαση συνεχίζεται για την πληρωμένη περίοδο. +Ζητήθηκε +Αποδεκτή +Ανατεθειμένη +Σε εξέλιξη +Σε αναστολή +Ολοκληρωμένη +Κλειστή +Απορριφθείσα +Διπλότυπη +Ακυρωμένη +Χαμηλή +Κανονική +Υψηλή +Έκτακτη ανάγκη +Διορθωτική +Προληπτική +Επιθεώρηση +Εγκατάσταση +Άλλη +Υποβλήθηκε αίτημα εργασίας +Ενημερώθηκε η εντολή εργασίας +Άλλαξε η κατάσταση +Άλλαξε η ανάθεση +Έγινε αποδοχή ανάθεσης +Προστέθηκε σχόλιο +Προστέθηκαν εργατοώρες +Προστέθηκε ανταλλακτικό +Ακυρώθηκε ανταλλακτικό +Προστέθηκε αρχείο +Αποσύρθηκε αρχείο + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx new file mode 100644 index 000000000..a5cdf33c8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx @@ -0,0 +1,157 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Work orders + New work order + Edit work order + Status + Priority + Type + All + Unit + Group + Equipment + Assigned to me + Filter + Number + Title + Due date + No work orders match these filters. + Previous + Next + None + Description + Site, building or location + Currency + Estimated cost + Approved cost + Cost center + Vendor details + Warranty reference + Safety-critical work + Hazardous work + Record the approved procedure and isolation references. This application does not perform physical isolation or authorize return to service. + Procedure reference + Procedure version + Permit reference + Isolation reference + Qualified personnel + Task steps + Add step + Save + Cancel + Reported by + Assigned to + Resolution + Cause + Verification and test evidence + Verified by + Export + Accept assignment + Assign + User + Role + Change status + Reason + Canonical work order ID + I confirm all task steps are complete. + Activity + Comment + Add comment + Labor + Work date + Hours + Hourly rate + Note + Current user + Add labor + Parts + These parts are recorded as free text. Inventory stock is not changed. + Quantity + Unit cost + Actions + Voided + Void + Add part + Files + Withdrawn + Withdraw + File + PDF, PNG or JPEG, up to 10 MB. Files must pass scanning before they are accepted. + Upload + Done + Pending + Verify and open + History remains available. An active Readiness Pro subscription and enabled maintenance module are required for new work. + Active department membership is required. + Readiness Pro is required for new maintenance work. + You do not have permission to perform this action. + This work order or evidence is unavailable. + Select an available target in your department. + Select one active department member or personnel role. + This work order changed. Reload before saving. + Check the entered values and required fields. + Verify your identity to unlock protected work-order data. + This status transition is not allowed. + The assignment must be accepted before work starts. + Complete the safety procedure, version, permit, isolation and qualified-personnel details. + Complete all task steps before marking the work complete. + Safety-critical work must be verified by an authorized person other than the person who completed it. + This history is too large to display in one request. + Choose a valid PDF, PNG or JPEG file up to 10 MB. + The file has not passed scanning. Try again when scanning is available. + This work order has reached its file limit. + The file failed its integrity check. + The request could not be completed. Your entered values are still on this page. + Manage work orders + View other members' work orders + Triage, assign and verify work orders within the permitted scope. + View work orders in the permitted group or department. Requesters retain their own history. + Work order created + Work order status changed + Work order assigned + Work-order update + A work order in your department needs attention. Open Resgrid for details. + Readiness Pro + Monthly + Subscribe + Manage subscription + Cancel renewal + Readiness Pro checkout is not available for this billing configuration. + Checkout does not activate maintenance access. Access begins after payment is verified. + Paid through + Renewal is cancelled. Access continues through the paid period. + Requested + Accepted + Assigned + In progress + On hold + Completed + Closed + Rejected + Duplicate + Cancelled + Low + Normal + High + Emergency + Corrective + Preventive + Inspection + Facility + Other + Work request submitted + Work order updated + Status changed + Assignment changed + Assignment accepted + Comment added + Labor added + Part added + Part voided + File added + File withdrawn + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx new file mode 100644 index 000000000..ccf5b3963 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx @@ -0,0 +1,157 @@ + + +text/microsoft-resx +2.0 +System.Resources.ResXResourceReader, System.Windows.Forms +System.Resources.ResXResourceWriter, System.Windows.Forms +Órdenes de trabajo +Nueva orden de trabajo +Editar orden de trabajo +Estado +Prioridad +Tipo +Todos +Unidad +Grupo +Equipo +Asignadas a mí +Filtrar +Número +Título +Fecha límite +Ninguna orden de trabajo coincide con estos filtros. +Anterior +Siguiente +Ninguno +Descripción +Sitio, edificio o ubicación +Moneda +Coste estimado +Coste aprobado +Centro de costes +Datos del proveedor +Referencia de garantía +Trabajo crítico para la seguridad +Trabajo peligroso +Registre el procedimiento aprobado y las referencias de aislamiento. Esta aplicación no realiza el aislamiento físico ni autoriza la vuelta al servicio. +Referencia del procedimiento +Versión del procedimiento +Referencia del permiso +Referencia de aislamiento +Personal cualificado +Pasos de la tarea +Añadir paso +Guardar +Cancelar +Notificado por +Asignado a +Resolución +Causa +Pruebas de verificación y ensayo +Verificado por +Exportar +Aceptar asignación +Asignar +Usuario +Rol +Cambiar estado +Motivo +ID de la orden de trabajo principal +Confirmo que todos los pasos de la tarea están completos. +Actividad +Comentario +Añadir comentario +Mano de obra +Fecha de trabajo +Horas +Tarifa por hora +Nota +Usuario actual +Añadir mano de obra +Piezas +Estas piezas se registran como texto libre. No se modifican las existencias del inventario. +Cantidad +Coste unitario +Acciones +Anulada +Anular +Añadir pieza +Archivos +Retirado +Retirar +Archivo +PDF, PNG o JPEG, hasta 10 MB. Los archivos deben superar el análisis de seguridad antes de aceptarse. +Subir +Hecho +Pendiente +Verificar identidad y abrir +El historial sigue disponible. Para nuevos trabajos se requieren una suscripción activa a Readiness Pro y el módulo de mantenimiento habilitado. +Se requiere pertenecer activamente al departamento. +Readiness Pro es necesario para nuevos trabajos de mantenimiento. +No tiene permiso para realizar esta acción. +Esta orden de trabajo o prueba no está disponible. +Seleccione un destino disponible en su departamento. +Seleccione un miembro activo del departamento o un rol de personal. +Esta orden de trabajo ha cambiado. Vuelva a cargarla antes de guardar. +Revise los valores introducidos y los campos obligatorios. +Verifique su identidad para abrir los datos protegidos de la orden de trabajo. +Esta transición de estado no está permitida. +La asignación debe aceptarse antes de comenzar el trabajo. +Complete los datos del procedimiento de seguridad, versión, permiso, aislamiento y personal cualificado. +Complete todos los pasos antes de marcar el trabajo como completado. +El trabajo crítico para la seguridad debe ser verificado por una persona autorizada distinta de quien lo completó. +Este historial es demasiado grande para mostrarse en una sola solicitud. +Seleccione un archivo PDF, PNG o JPEG válido de hasta 10 MB. +El archivo no ha superado el análisis. Inténtelo de nuevo cuando el análisis esté disponible. +Esta orden de trabajo ha alcanzado su límite de archivos. +El archivo no superó la comprobación de integridad. +No se pudo completar la solicitud. Los valores introducidos siguen en esta página. +Gestionar órdenes de trabajo +Ver las órdenes de trabajo de otros miembros +Evaluar, asignar y verificar órdenes de trabajo dentro del ámbito permitido. +Ver órdenes de trabajo del grupo o departamento permitido. Los solicitantes conservan su propio historial. +Orden de trabajo creada +Estado de la orden de trabajo cambiado +Orden de trabajo asignada +Actualización de una orden de trabajo +Una orden de trabajo de su departamento requiere atención. Abra Resgrid para ver los detalles. +Readiness Pro +Mensual +Suscribirse +Gestionar suscripción +Cancelar renovación +La contratación de Readiness Pro no está disponible para esta configuración de facturación. +El proceso de pago no activa el acceso al mantenimiento. El acceso comienza cuando se verifica el pago. +Pagado hasta +La renovación está cancelada. El acceso continúa durante el periodo pagado. +Solicitada +Aceptada +Asignada +En curso +En espera +Completada +Cerrada +Rechazada +Duplicada +Cancelada +Baja +Normal +Alta +Emergencia +Correctivo +Preventivo +Inspección +Instalaciones +Otro +Solicitud de trabajo enviada +Orden de trabajo actualizada +Estado cambiado +Asignación cambiada +Asignación aceptada +Comentario añadido +Mano de obra añadida +Pieza añadida +Pieza anulada +Archivo añadido +Archivo retirado + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx new file mode 100644 index 000000000..6ef23cd75 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx @@ -0,0 +1,157 @@ + + +text/microsoft-resx +2.0 +System.Resources.ResXResourceReader, System.Windows.Forms +System.Resources.ResXResourceWriter, System.Windows.Forms +Ordres de travail +Nouvel ordre de travail +Modifier l’ordre de travail +État +Priorité +Type +Tous +Unité +Groupe +Équipement +Affectés à moi +Filtrer +Numéro +Titre +Échéance +Aucun ordre de travail ne correspond à ces filtres. +Précédent +Suivant +Aucun +Description +Site, bâtiment ou emplacement +Devise +Coût estimé +Coût approuvé +Centre de coûts +Coordonnées du fournisseur +Référence de garantie +Travail critique pour la sécurité +Travail dangereux +Consignez la procédure approuvée et les références de consignation. Cette application n’effectue aucune consignation physique et n’autorise pas la remise en service. +Référence de la procédure +Version de la procédure +Référence du permis +Référence de consignation +Personnel qualifié +Étapes de la tâche +Ajouter une étape +Enregistrer +Annuler +Signalé par +Affecté à +Résolution +Cause +Preuves de vérification et d’essai +Vérifié par +Exporter +Accepter l’affectation +Affecter +Utilisateur +Rôle +Changer l’état +Motif +Identifiant de l’ordre de travail de référence +Je confirme que toutes les étapes de la tâche sont terminées. +Activité +Commentaire +Ajouter un commentaire +Main-d’œuvre +Date du travail +Heures +Tarif horaire +Note +Utilisateur actuel +Ajouter de la main-d’œuvre +Pièces +Ces pièces sont saisies en texte libre. Les stocks ne sont pas modifiés. +Quantité +Coût unitaire +Actions +Annulée +Annuler +Ajouter une pièce +Fichiers +Retiré +Retirer +Fichier +PDF, PNG ou JPEG, jusqu’à 10 Mo. Les fichiers doivent réussir l’analyse de sécurité avant d’être acceptés. +Téléverser +Terminé +En attente +Vérifier l’identité et ouvrir +L’historique reste disponible. Les nouveaux travaux nécessitent un abonnement Readiness Pro actif et l’activation du module de maintenance. +Une appartenance active au service est requise. +Readiness Pro est requis pour les nouveaux travaux de maintenance. +Vous n’avez pas l’autorisation d’effectuer cette action. +Cet ordre de travail ou cette pièce justificative n’est pas disponible. +Sélectionnez une cible disponible dans votre service. +Sélectionnez un membre actif du service ou un rôle du personnel. +Cet ordre de travail a changé. Rechargez-le avant d’enregistrer. +Vérifiez les valeurs saisies et les champs obligatoires. +Vérifiez votre identité pour ouvrir les données protégées de l’ordre de travail. +Cette transition d’état n’est pas autorisée. +L’affectation doit être acceptée avant le début du travail. +Renseignez la procédure de sécurité, sa version, le permis, la consignation et le personnel qualifié. +Terminez toutes les étapes avant de déclarer le travail terminé. +Un travail critique pour la sécurité doit être vérifié par une personne autorisée autre que celle qui l’a terminé. +Cet historique est trop volumineux pour être affiché en une seule requête. +Choisissez un fichier PDF, PNG ou JPEG valide de 10 Mo maximum. +Le fichier n’a pas réussi l’analyse. Réessayez lorsque l’analyse sera disponible. +Cet ordre de travail a atteint sa limite de fichiers. +Le contrôle d’intégrité du fichier a échoué. +La demande n’a pas pu aboutir. Les valeurs saisies sont conservées sur cette page. +Gérer les ordres de travail +Voir les ordres de travail des autres membres +Évaluer, affecter et vérifier les ordres de travail dans le périmètre autorisé. +Voir les ordres de travail du groupe ou du service autorisé. Les demandeurs conservent leur propre historique. +Ordre de travail créé +État de l’ordre de travail modifié +Ordre de travail affecté +Mise à jour d’un ordre de travail +Un ordre de travail de votre service nécessite votre attention. Ouvrez Resgrid pour consulter les détails. +Readiness Pro +Mensuel +S’abonner +Gérer l’abonnement +Annuler le renouvellement +La souscription à Readiness Pro n’est pas disponible pour cette configuration de facturation. +Le passage en caisse n’active pas l’accès à la maintenance. L’accès commence après vérification du paiement. +Payé jusqu’au +Le renouvellement est annulé. L’accès est maintenu pendant la période payée. +Demandé +Accepté +Affecté +En cours +Suspendu +Terminé +Clôturé +Rejeté +Doublon +Annulé +Basse +Normale +Haute +Urgence +Correctif +Préventif +Inspection +Bâtiment +Autre +Demande de travail soumise +Ordre de travail mis à jour +État modifié +Affectation modifiée +Affectation acceptée +Commentaire ajouté +Main-d’œuvre ajoutée +Pièce ajoutée +Pièce annulée +Fichier ajouté +Fichier retiré + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx new file mode 100644 index 000000000..5d251f3d5 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx @@ -0,0 +1,157 @@ + + +text/microsoft-resx +2.0 +System.Resources.ResXResourceReader, System.Windows.Forms +System.Resources.ResXResourceWriter, System.Windows.Forms +Ordini di lavoro +Nuovo ordine di lavoro +Modifica ordine di lavoro +Stato +Priorità +Tipo +Tutti +Unità +Gruppo +Attrezzatura +Assegnati a me +Filtra +Numero +Titolo +Scadenza +Nessun ordine di lavoro corrisponde a questi filtri. +Precedente +Successivo +Nessuno +Descrizione +Sito, edificio o ubicazione +Valuta +Costo stimato +Costo approvato +Centro di costo +Dati del fornitore +Riferimento della garanzia +Lavoro critico per la sicurezza +Lavoro pericoloso +Registrare la procedura approvata e i riferimenti di isolamento. Questa applicazione non esegue l’isolamento fisico né autorizza il ritorno in servizio. +Riferimento della procedura +Versione della procedura +Riferimento del permesso +Riferimento di isolamento +Personale qualificato +Fasi del lavoro +Aggiungi fase +Salva +Annulla +Segnalato da +Assegnato a +Risoluzione +Causa +Evidenze di verifica e collaudo +Verificato da +Esporta +Accetta assegnazione +Assegna +Utente +Ruolo +Cambia stato +Motivo +ID dell’ordine di lavoro principale +Confermo che tutte le fasi del lavoro sono completate. +Attività +Commento +Aggiungi commento +Manodopera +Data del lavoro +Ore +Tariffa oraria +Nota +Utente attuale +Aggiungi manodopera +Ricambi +Questi ricambi sono registrati come testo libero. Le scorte di magazzino non vengono modificate. +Quantità +Costo unitario +Azioni +Annullato +Annulla +Aggiungi ricambio +File +Ritirato +Ritira +File +PDF, PNG o JPEG, fino a 10 MB. I file devono superare la scansione prima di essere accettati. +Carica +Completato +In attesa +Verifica l’identità e apri +La cronologia rimane disponibile. Per nuovi lavori sono necessari un abbonamento Readiness Pro attivo e il modulo di manutenzione abilitato. +È richiesta un’appartenenza attiva al dipartimento. +Readiness Pro è necessario per nuovi lavori di manutenzione. +Non hai l’autorizzazione per eseguire questa azione. +Questo ordine di lavoro o questa evidenza non è disponibile. +Seleziona un destinatario disponibile nel tuo dipartimento. +Seleziona un membro attivo del dipartimento o un ruolo del personale. +Questo ordine di lavoro è cambiato. Ricaricalo prima di salvare. +Controlla i valori inseriti e i campi obbligatori. +Verifica la tua identità per aprire i dati protetti dell’ordine di lavoro. +Questa transizione di stato non è consentita. +L’assegnazione deve essere accettata prima di iniziare il lavoro. +Completa i dati della procedura di sicurezza, versione, permesso, isolamento e personale qualificato. +Completa tutte le fasi prima di contrassegnare il lavoro come completato. +Un lavoro critico per la sicurezza deve essere verificato da una persona autorizzata diversa da chi lo ha completato. +Questa cronologia è troppo estesa per essere visualizzata in una sola richiesta. +Scegli un file PDF, PNG o JPEG valido fino a 10 MB. +Il file non ha superato la scansione. Riprova quando la scansione sarà disponibile. +Questo ordine di lavoro ha raggiunto il limite di file. +Il controllo di integrità del file non è riuscito. +Non è stato possibile completare la richiesta. I valori inseriti sono ancora presenti su questa pagina. +Gestisci ordini di lavoro +Visualizza gli ordini di lavoro degli altri membri +Valuta, assegna e verifica gli ordini di lavoro nell’ambito consentito. +Visualizza gli ordini di lavoro del gruppo o dipartimento consentito. I richiedenti conservano la propria cronologia. +Ordine di lavoro creato +Stato dell’ordine di lavoro cambiato +Ordine di lavoro assegnato +Aggiornamento dell’ordine di lavoro +Un ordine di lavoro del tuo dipartimento richiede attenzione. Apri Resgrid per i dettagli. +Readiness Pro +Mensile +Abbonati +Gestisci abbonamento +Annulla rinnovo +La sottoscrizione di Readiness Pro non è disponibile per questa configurazione di fatturazione. +La procedura di pagamento non attiva l’accesso alla manutenzione. L’accesso inizia dopo la verifica del pagamento. +Pagato fino al +Il rinnovo è annullato. L’accesso continua per il periodo pagato. +Richiesto +Accettato +Assegnato +In corso +Sospeso +Completato +Chiuso +Rifiutato +Duplicato +Annullato +Bassa +Normale +Alta +Emergenza +Correttivo +Preventivo +Ispezione +Struttura +Altro +Richiesta di lavoro inviata +Ordine di lavoro aggiornato +Stato cambiato +Assegnazione cambiata +Assegnazione accettata +Commento aggiunto +Manodopera aggiunta +Ricambio aggiunto +Ricambio annullato +File aggiunto +File ritirato + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx new file mode 100644 index 000000000..8d625361e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx @@ -0,0 +1,157 @@ + + +text/microsoft-resx +2.0 +System.Resources.ResXResourceReader, System.Windows.Forms +System.Resources.ResXResourceWriter, System.Windows.Forms +Zlecenia pracy +Nowe zlecenie pracy +Edytuj zlecenie pracy +Stan +Priorytet +Typ +Wszystkie +Jednostka +Grupa +Sprzęt +Przypisane do mnie +Filtruj +Numer +Tytuł +Termin +Żadne zlecenie pracy nie pasuje do tych filtrów. +Poprzednia +Następna +Brak +Opis +Obiekt, budynek lub lokalizacja +Waluta +Szacowany koszt +Zatwierdzony koszt +Centrum kosztów +Dane dostawcy +Oznaczenie gwarancji +Praca krytyczna dla bezpieczeństwa +Praca niebezpieczna +Zapisz zatwierdzoną procedurę i oznaczenia odłączenia energii. Aplikacja nie wykonuje fizycznego odłączenia ani nie zezwala na przywrócenie do eksploatacji. +Oznaczenie procedury +Wersja procedury +Oznaczenie zezwolenia +Oznaczenie odłączenia energii +Wykwalifikowany personel +Etapy zadania +Dodaj etap +Zapisz +Anuluj +Zgłoszone przez +Przypisane do +Rozwiązanie +Przyczyna +Dowody weryfikacji i testów +Zweryfikowane przez +Eksportuj +Przyjmij przydział +Przypisz +Użytkownik +Rola +Zmień stan +Powód +Identyfikator głównego zlecenia pracy +Potwierdzam ukończenie wszystkich etapów zadania. +Aktywność +Komentarz +Dodaj komentarz +Robocizna +Data pracy +Godziny +Stawka godzinowa +Notatka +Bieżący użytkownik +Dodaj robociznę +Części +Części są zapisywane jako dowolny tekst. Stan magazynowy nie jest zmieniany. +Ilość +Koszt jednostkowy +Działania +Unieważniono +Unieważnij +Dodaj część +Pliki +Wycofano +Wycofaj +Plik +PDF, PNG lub JPEG, do 10 MB. Pliki muszą przejść skanowanie przed przyjęciem. +Prześlij +Gotowe +Oczekujące +Potwierdź tożsamość i otwórz +Historia pozostaje dostępna. Nowe prace wymagają aktywnej subskrypcji Readiness Pro i włączonego modułu konserwacji. +Wymagane jest aktywne członkostwo w dziale. +Readiness Pro jest wymagany do nowych prac konserwacyjnych. +Nie masz uprawnień do wykonania tej czynności. +To zlecenie pracy lub dowód jest niedostępny. +Wybierz dostępny obiekt w swoim dziale. +Wybierz jednego aktywnego członka działu lub rolę personelu. +To zlecenie pracy uległo zmianie. Odśwież je przed zapisaniem. +Sprawdź wpisane wartości i wymagane pola. +Potwierdź swoją tożsamość, aby otworzyć chronione dane zlecenia pracy. +Ta zmiana stanu jest niedozwolona. +Przydział musi zostać przyjęty przed rozpoczęciem pracy. +Uzupełnij procedurę bezpieczeństwa, wersję, zezwolenie, odłączenie energii i dane wykwalifikowanego personelu. +Ukończ wszystkie etapy przed oznaczeniem pracy jako zakończonej. +Pracę krytyczną dla bezpieczeństwa musi zweryfikować upoważniona osoba inna niż ta, która ją ukończyła. +Ta historia jest zbyt duża, aby wyświetlić ją w jednym żądaniu. +Wybierz prawidłowy plik PDF, PNG lub JPEG o rozmiarze do 10 MB. +Plik nie przeszedł skanowania. Spróbuj ponownie, gdy skanowanie będzie dostępne. +Osiągnięto limit plików tego zlecenia pracy. +Plik nie przeszedł kontroli integralności. +Nie udało się wykonać żądania. Wpisane wartości pozostają na tej stronie. +Zarządzaj zleceniami pracy +Wyświetlaj zlecenia pracy innych członków +Oceniaj, przypisuj i weryfikuj zlecenia pracy w dozwolonym zakresie. +Wyświetlaj zlecenia pracy dozwolonej grupy lub działu. Zgłaszający zachowują własną historię. +Utworzono zlecenie pracy +Zmieniono stan zlecenia pracy +Przypisano zlecenie pracy +Aktualizacja zlecenia pracy +Zlecenie pracy w Twoim dziale wymaga uwagi. Otwórz Resgrid, aby zobaczyć szczegóły. +Readiness Pro +Miesięcznie +Subskrybuj +Zarządzaj subskrypcją +Anuluj odnowienie +Zakup Readiness Pro nie jest dostępny dla tej konfiguracji rozliczeń. +Przejście do płatności nie włącza dostępu do konserwacji. Dostęp rozpoczyna się po weryfikacji płatności. +Opłacono do +Odnowienie zostało anulowane. Dostęp trwa do końca opłaconego okresu. +Zgłoszone +Przyjęte +Przypisane +W toku +Wstrzymane +Ukończone +Zamknięte +Odrzucone +Duplikat +Anulowane +Niski +Normalny +Wysoki +Nagły wypadek +Naprawcze +Zapobiegawcze +Kontrola +Obiekt +Inne +Przesłano zgłoszenie pracy +Zaktualizowano zlecenie pracy +Zmieniono stan +Zmieniono przydział +Przyjęto przydział +Dodano komentarz +Dodano robociznę +Dodano część +Unieważniono część +Dodano plik +Wycofano plik + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx new file mode 100644 index 000000000..a5cdf33c8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx @@ -0,0 +1,157 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Work orders + New work order + Edit work order + Status + Priority + Type + All + Unit + Group + Equipment + Assigned to me + Filter + Number + Title + Due date + No work orders match these filters. + Previous + Next + None + Description + Site, building or location + Currency + Estimated cost + Approved cost + Cost center + Vendor details + Warranty reference + Safety-critical work + Hazardous work + Record the approved procedure and isolation references. This application does not perform physical isolation or authorize return to service. + Procedure reference + Procedure version + Permit reference + Isolation reference + Qualified personnel + Task steps + Add step + Save + Cancel + Reported by + Assigned to + Resolution + Cause + Verification and test evidence + Verified by + Export + Accept assignment + Assign + User + Role + Change status + Reason + Canonical work order ID + I confirm all task steps are complete. + Activity + Comment + Add comment + Labor + Work date + Hours + Hourly rate + Note + Current user + Add labor + Parts + These parts are recorded as free text. Inventory stock is not changed. + Quantity + Unit cost + Actions + Voided + Void + Add part + Files + Withdrawn + Withdraw + File + PDF, PNG or JPEG, up to 10 MB. Files must pass scanning before they are accepted. + Upload + Done + Pending + Verify and open + History remains available. An active Readiness Pro subscription and enabled maintenance module are required for new work. + Active department membership is required. + Readiness Pro is required for new maintenance work. + You do not have permission to perform this action. + This work order or evidence is unavailable. + Select an available target in your department. + Select one active department member or personnel role. + This work order changed. Reload before saving. + Check the entered values and required fields. + Verify your identity to unlock protected work-order data. + This status transition is not allowed. + The assignment must be accepted before work starts. + Complete the safety procedure, version, permit, isolation and qualified-personnel details. + Complete all task steps before marking the work complete. + Safety-critical work must be verified by an authorized person other than the person who completed it. + This history is too large to display in one request. + Choose a valid PDF, PNG or JPEG file up to 10 MB. + The file has not passed scanning. Try again when scanning is available. + This work order has reached its file limit. + The file failed its integrity check. + The request could not be completed. Your entered values are still on this page. + Manage work orders + View other members' work orders + Triage, assign and verify work orders within the permitted scope. + View work orders in the permitted group or department. Requesters retain their own history. + Work order created + Work order status changed + Work order assigned + Work-order update + A work order in your department needs attention. Open Resgrid for details. + Readiness Pro + Monthly + Subscribe + Manage subscription + Cancel renewal + Readiness Pro checkout is not available for this billing configuration. + Checkout does not activate maintenance access. Access begins after payment is verified. + Paid through + Renewal is cancelled. Access continues through the paid period. + Requested + Accepted + Assigned + In progress + On hold + Completed + Closed + Rejected + Duplicate + Cancelled + Low + Normal + High + Emergency + Corrective + Preventive + Inspection + Facility + Other + Work request submitted + Work order updated + Status changed + Assignment changed + Assignment accepted + Comment added + Labor added + Part added + Part voided + File added + File withdrawn + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx new file mode 100644 index 000000000..26b7823bd --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx @@ -0,0 +1,157 @@ + + +text/microsoft-resx +2.0 +System.Resources.ResXResourceReader, System.Windows.Forms +System.Resources.ResXResourceWriter, System.Windows.Forms +Arbetsorder +Ny arbetsorder +Redigera arbetsorder +Status +Prioritet +Typ +Alla +Enhet +Grupp +Utrustning +Tilldelade mig +Filtrera +Nummer +Titel +Förfallodatum +Inga arbetsorder matchar dessa filter. +Föregående +Nästa +Ingen +Beskrivning +Plats, byggnad eller läge +Valuta +Beräknad kostnad +Godkänd kostnad +Kostnadsställe +Leverantörsuppgifter +Garantireferens +Säkerhetskritiskt arbete +Farligt arbete +Dokumentera den godkända proceduren och referenser för frånkoppling. Denna applikation utför ingen fysisk frånkoppling och godkänner inte återgång till drift. +Procedurreferens +Procedurversion +Tillståndsreferens +Frånkopplingsreferens +Kvalificerad personal +Arbetssteg +Lägg till steg +Spara +Avbryt +Rapporterat av +Tilldelad till +Åtgärd +Orsak +Underlag för verifiering och provning +Verifierat av +Exportera +Acceptera tilldelning +Tilldela +Användare +Roll +Ändra status +Orsak +ID för den huvudsakliga arbetsordern +Jag bekräftar att alla arbetssteg är slutförda. +Aktivitet +Kommentar +Lägg till kommentar +Arbetstid +Arbetsdatum +Timmar +Timpris +Anteckning +Aktuell användare +Lägg till arbetstid +Delar +Dessa delar registreras som fritext. Lagersaldot ändras inte. +Antal +Styckkostnad +Åtgärder +Makulerad +Makulera +Lägg till del +Filer +Återkallad +Återkalla +Fil +PDF, PNG eller JPEG, upp till 10 MB. Filer måste godkännas i säkerhetsskanningen innan de accepteras. +Ladda upp +Klart +Väntande +Verifiera identiteten och öppna +Historiken är fortsatt tillgänglig. Nya arbeten kräver en aktiv Readiness Pro-prenumeration och en aktiverad underhållsmodul. +Aktivt medlemskap i avdelningen krävs. +Readiness Pro krävs för nya underhållsarbeten. +Du har inte behörighet att utföra denna åtgärd. +Denna arbetsorder eller detta underlag är inte tillgängligt. +Välj ett tillgängligt mål inom din avdelning. +Välj en aktiv avdelningsmedlem eller personalroll. +Denna arbetsorder har ändrats. Läs in den igen innan du sparar. +Kontrollera angivna värden och obligatoriska fält. +Verifiera din identitet för att öppna skyddade arbetsorderuppgifter. +Denna statusändring är inte tillåten. +Tilldelningen måste accepteras innan arbetet påbörjas. +Fyll i säkerhetsprocedur, version, tillstånd, frånkoppling och uppgifter om kvalificerad personal. +Slutför alla arbetssteg innan arbetet markeras som klart. +Säkerhetskritiskt arbete måste verifieras av en behörig person som inte är den som slutförde det. +Historiken är för stor för att visas i en enda begäran. +Välj en giltig PDF-, PNG- eller JPEG-fil på högst 10 MB. +Filen har inte godkänts i skanningen. Försök igen när skanningen är tillgänglig. +Denna arbetsorder har nått sin filgräns. +Filens integritetskontroll misslyckades. +Begäran kunde inte slutföras. Dina angivna värden finns kvar på denna sida. +Hantera arbetsorder +Visa andra medlemmars arbetsorder +Bedöm, tilldela och verifiera arbetsorder inom tillåtet område. +Visa arbetsorder i den tillåtna gruppen eller avdelningen. Beställare behåller sin egen historik. +Arbetsorder skapad +Arbetsorderns status ändrad +Arbetsorder tilldelad +Uppdatering av arbetsorder +En arbetsorder i din avdelning behöver uppmärksamhet. Öppna Resgrid för mer information. +Readiness Pro +Månadsvis +Prenumerera +Hantera prenumeration +Avsluta förnyelse +Readiness Pro kan inte köpas med denna faktureringskonfiguration. +Kassan aktiverar inte åtkomst till underhåll. Åtkomsten börjar efter att betalningen verifierats. +Betald till +Förnyelsen har avslutats. Åtkomsten fortsätter under den betalda perioden. +Begärd +Accepterad +Tilldelad +Pågår +Pausad +Slutförd +Stängd +Avvisad +Dubblett +Avbruten +Låg +Normal +Hög +Nödläge +Avhjälpande +Förebyggande +Inspektion +Fastighet +Övrigt +Arbetsbegäran inskickad +Arbetsorder uppdaterad +Status ändrad +Tilldelning ändrad +Tilldelning accepterad +Kommentar tillagd +Arbetstid tillagd +Del tillagd +Del makulerad +Fil tillagd +Fil återkallad + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx new file mode 100644 index 000000000..b7fa630de --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx @@ -0,0 +1,157 @@ + + +text/microsoft-resx +2.0 +System.Resources.ResXResourceReader, System.Windows.Forms +System.Resources.ResXResourceWriter, System.Windows.Forms +Наряди на роботи +Новий наряд на роботи +Редагувати наряд на роботи +Стан +Пріоритет +Тип +Усі +Одиниця +Група +Обладнання +Призначені мені +Фільтрувати +Номер +Назва +Строк виконання +Жоден наряд на роботи не відповідає цим фільтрам. +Попередня +Наступна +Немає +Опис +Об’єкт, будівля або місце +Валюта +Орієнтовна вартість +Затверджена вартість +Центр витрат +Дані постачальника +Посилання на гарантію +Робота, критична для безпеки +Небезпечна робота +Запишіть затверджену процедуру та посилання на відключення джерел енергії. Цей застосунок не виконує фізичного відключення й не дозволяє повернення до експлуатації. +Посилання на процедуру +Версія процедури +Посилання на дозвіл +Посилання на відключення +Кваліфікований персонал +Етапи завдання +Додати етап +Зберегти +Скасувати +Повідомив +Призначено +Усунення +Причина +Докази перевірки та випробувань +Перевірив +Експортувати +Прийняти призначення +Призначити +Користувач +Роль +Змінити стан +Обґрунтування +Ідентифікатор основного наряду на роботи +Підтверджую завершення всіх етапів завдання. +Дії +Коментар +Додати коментар +Трудовитрати +Дата роботи +Години +Погодинна ставка +Примітка +Поточний користувач +Додати трудовитрати +Деталі +Ці деталі записуються довільним текстом. Складські залишки не змінюються. +Кількість +Вартість одиниці +Дії +Анульовано +Анулювати +Додати деталь +Файли +Відкликано +Відкликати +Файл +PDF, PNG або JPEG, до 10 МБ. Перед прийняттям файли мають пройти перевірку безпеки. +Завантажити +Готово +Очікується +Підтвердити особу та відкрити +Історія залишається доступною. Нові роботи потребують активної підписки Readiness Pro та ввімкненого модуля обслуговування. +Потрібне активне членство в підрозділі. +Для нових робіт з обслуговування потрібен Readiness Pro. +Ви не маєте дозволу на цю дію. +Цей наряд на роботи або доказ недоступний. +Виберіть доступний об’єкт у своєму підрозділі. +Виберіть одного активного учасника підрозділу або роль персоналу. +Цей наряд на роботи змінено. Оновіть сторінку перед збереженням. +Перевірте введені значення та обов’язкові поля. +Підтвердьте свою особу, щоб відкрити захищені дані наряду на роботи. +Такий перехід стану не дозволений. +Призначення необхідно прийняти до початку роботи. +Заповніть процедуру безпеки, її версію, дозвіл, відключення та дані кваліфікованого персоналу. +Завершіть усі етапи, перш ніж позначати роботу як виконану. +Роботу, критичну для безпеки, має перевірити уповноважена особа, яка не виконувала її. +Ця історія завелика для відображення в одному запиті. +Виберіть дійсний файл PDF, PNG або JPEG розміром до 10 МБ. +Файл не пройшов перевірку. Спробуйте знову, коли перевірка буде доступною. +Досягнуто обмеження кількості файлів для цього наряду. +Файл не пройшов перевірку цілісності. +Не вдалося виконати запит. Введені значення залишаються на цій сторінці. +Керувати нарядами на роботи +Переглядати наряди інших учасників +Оцінювати, призначати та перевіряти наряди на роботи в дозволених межах. +Переглядати наряди на роботи дозволеної групи або підрозділу. Заявники зберігають власну історію. +Створено наряд на роботи +Змінено стан наряду на роботи +Призначено наряд на роботи +Оновлення наряду на роботи +Наряд на роботи у вашому підрозділі потребує уваги. Відкрийте Resgrid, щоб переглянути подробиці. +Readiness Pro +Щомісяця +Підписатися +Керувати підпискою +Скасувати поновлення +Оформлення Readiness Pro недоступне для цих налаштувань оплати. +Оформлення платежу не активує доступ до обслуговування. Доступ починається після перевірки оплати. +Сплачено до +Поновлення скасовано. Доступ триває до завершення оплаченого періоду. +Запитано +Прийнято +Призначено +Виконується +Призупинено +Виконано +Закрито +Відхилено +Дублікат +Скасовано +Низький +Звичайний +Високий +Надзвичайний +Коригувальна +Профілактична +Перевірка +Об’єкт +Інша +Подано заявку на роботу +Оновлено наряд на роботи +Змінено стан +Змінено призначення +Прийнято призначення +Додано коментар +Додано трудовитрати +Додано деталь +Анульовано деталь +Додано файл +Відкликано файл + diff --git a/Core/Resgrid.Model/AdpTableBinding.cs b/Core/Resgrid.Model/AdpTableBinding.cs index 894a19d5d..8c919abd4 100644 --- a/Core/Resgrid.Model/AdpTableBinding.cs +++ b/Core/Resgrid.Model/AdpTableBinding.cs @@ -76,7 +76,7 @@ public static AdpTableBinding ViaParent(string tableName, string pkColumn, bool public AdpRowDiscriminator Discriminator { get; init; } } - public sealed record AdpRowDiscriminator(string Column, IReadOnlyList Integers = null, string Text = null, bool OnParent = false); + public sealed record AdpRowDiscriminator(string Column, IReadOnlyList Integers = null, string Text = null, bool OnParent = false, IReadOnlyList Texts = null); /// One cataloged column inside a binding. public sealed class AdpColumnSpec diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 4af7e7c7f..d2cc57db5 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -219,6 +219,7 @@ public enum AuditLogTypes ChecklistScheduleUpdated, ChecklistOccurrenceMissed, ChecklistOccurrenceSkipped, - ChecklistReminderSettingsUpdated +ChecklistReminderSettingsUpdated, + WorkOrderChanged } } diff --git a/Core/Resgrid.Model/Checklists/ChecklistMobile.cs b/Core/Resgrid.Model/Checklists/ChecklistMobile.cs new file mode 100644 index 000000000..3dcffef1d --- /dev/null +++ b/Core/Resgrid.Model/Checklists/ChecklistMobile.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Checklists +{ + public sealed class ChecklistMobileQuery + { + public int Page { get; set; } + public string UnitId { get; set; } + public string AssetId { get; set; } + public bool ForCurrentUser { get; set; } + public string DefinitionId { get; set; } + public string UserId { get; set; } + public DateTime? Start { get; set; } + public DateTime? End { get; set; } + } + public sealed class ChecklistMobileDefinition + { + public ChecklistDefinitionView Definition { get; set; } + public List Targets { get; set; } = new List(); + } + public sealed class ChecklistMobilePage + { + public List Occurrences { get; set; } = new List(); + public List Definitions { get; set; } = new List(); + public bool HasMoreOccurrences { get; set; } + public bool HasMoreDefinitions { get; set; } + } +} diff --git a/Core/Resgrid.Model/Checklists/ChecklistReporting.cs b/Core/Resgrid.Model/Checklists/ChecklistReporting.cs new file mode 100644 index 000000000..35cd3771c --- /dev/null +++ b/Core/Resgrid.Model/Checklists/ChecklistReporting.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Checklists +{ + public sealed class ChecklistReportQuery + { + public DateTime FromUtc { get; set; } + public DateTime UntilUtc { get; set; } + public ChecklistTargetType? TargetType { get; set; } + public string TargetId { get; set; } + } + /// Authorized snapshot; never includes answer text, locations, signatures or file bodies. + public sealed class ChecklistReportEntry + { + public string DefinitionId { get; set; } + public string VersionId { get; set; } + public int Version { get; set; } + public string OccurrenceId { get; set; } + public int OccurrenceRevision { get; set; } + public string CompletionId { get; set; } + public int? CompletionRevision { get; set; } + public string Name { get; set; } + public ChecklistTarget Target { get; set; } + public DateTime StartUtc { get; set; } + public DateTime? DueUtc { get; set; } + public DateTime? SubmittedUtc { get; set; } + public bool Scheduled { get; set; } + public bool Expected { get; set; } + public bool Completed { get; set; } + public bool OnTime { get; set; } + public bool Missed { get; set; } + public bool Skipped { get; set; } + public bool? Passed { get; set; } + public decimal? Score { get; set; } + } + public sealed class ChecklistComplianceGroup + { + public ChecklistTarget Target { get; set; } + public int Expected { get; set; } + public int Completed { get; set; } + public int OnTime { get; set; } + public int Missed { get; set; } + public int Skipped { get; set; } + public decimal? CompletionRate => Expected == 0 ? null : Math.Round(100m * Completed / Expected, 2); + } + public sealed class ChecklistMissedTrend { public DateTime DayUtc { get; set; } public int Expected { get; set; } public int Missed { get; set; } } + public sealed class ChecklistComplianceSummary + { + public ChecklistTargetType? TargetType { get; set; } + public string TargetId { get; set; } + public DateTime FromUtc { get; set; } + public DateTime UntilUtc { get; set; } + public DateTime AsOfUtc { get; set; } + public bool IsRedacted { get; set; } + public List Groups { get; set; } = new(); + public List Trend { get; set; } = new(); + public List Entries { get; set; } = new(); + public List UnavailableSources { get; set; } = new(); + } + public sealed class ReadinessUnitSnapshot + { + public int UnitId { get; set; } + public string Name { get; set; } + public int DispatchId { get; set; } + public DateTime DispatchedUtc { get; set; } + } + public sealed class ReadinessAssetSnapshot + { + public int DepartmentId { get; set; } + public string AssetId { get; set; } + public int? UnitId { get; set; } + public int? CallId { get; set; } + public string SourceSubsystem { get; set; } + public string Name { get; set; } + public string SourceId { get; set; } + public string SourceVersion { get; set; } + public DateTime IssuedUtc { get; set; } + public DateTime? ReturnedUtc { get; set; } + } + /// Optional historical inventory/deployment seam. Implementations authorize every asset with + /// the supplied actor/grant, return immutable issue/deployment provenance, and never infer history from + /// current assignments. A null result means unavailable; an empty list means available with no assets. + public interface IChecklistHistoricalAssetSource + { + Task> AtCallAsync(ChecklistActor actor, int callId, DateTime callUtc, IReadOnlyCollection unitIds, bool contractorEquipment); + } + public sealed class ReadinessEvidenceManifestV1 + { + public string Schema => "ReadinessEvidenceManifestV1"; + public string GeneratorVersion => "checklists-p1m4/1"; + public string ProfileVersion => "checklists-only/1"; + public int DepartmentId { get; set; } + public int CallId { get; set; } + public DateTime CallUtc { get; set; } + public DateTime GeneratedUtc { get; set; } + public DateTime CoverageStartUtc { get; set; } + public DateTime CoverageEndUtc { get; set; } + public string Classification => "Restricted"; + public List Units { get; set; } = new(); + public List Assets { get; set; } = new(); + public List Checklists { get; set; } = new(); + public List UnavailableSources { get; set; } = new(); + } + /// Checksum the exact UTF-8 ManifestJson bytes and decoded PDF bytes, not this wrapper. + public sealed class ReadinessEvidencePackage + { + public string ManifestJson { get; set; } + public string ManifestSha256 { get; set; } + public byte[] Pdf { get; set; } + public string PdfSha256 { get; set; } + } +} diff --git a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs index 204096c98..8f09a26ce 100644 --- a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs +++ b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json.Linq; @@ -9,8 +10,17 @@ namespace Resgrid.Model.Checklists /// Only reviewed routing facts and protected outcomes may cross the Workflow boundary. public static class ChecklistWorkflowPayload { - public static readonly int[] Triggers = { 67, 68, 69, 164, 165 }; + public static readonly IReadOnlyList Triggers = Array.AsReadOnly(new[] + { + (int)WorkflowTriggerEventType.ChecklistCompleted, (int)WorkflowTriggerEventType.ChecklistFailed, + (int)WorkflowTriggerEventType.ChecklistMissed, (int)WorkflowTriggerEventType.WorkOrderCreated, + (int)WorkflowTriggerEventType.WorkOrderStatusChanged, (int)WorkflowTriggerEventType.WorkOrderAssigned, + (int)WorkflowTriggerEventType.ChecklistScheduleChanged, (int)WorkflowTriggerEventType.ChecklistOccurrenceSkipped + }); private static readonly string[] Identifiers = { "CompletionId", "DefinitionId", "VersionId", "ItemId", "ScheduleId", "OccurrenceId" }; + private static bool IsStructuralTarget(int type, string target) => + type >= 0 && type <= 2 && int.TryParse(target, out var numeric) && numeric > 0 + || type == (int)ChecklistTargetType.InventoryAsset && Guid.TryParse(target, out _); private static void Timing(JObject source, JObject target) { foreach (var name in new[] { "State", "Revision" }) if (source[name]?.Type == JTokenType.Integer) target[name] = source[name].DeepClone(); @@ -20,7 +30,9 @@ private static void Timing(JObject source, JObject target) } public static string Routing(string payloadJson, string aggregateId) { - var source = JObject.Parse(payloadJson ?? "{}"); var safe = new JObject(); + var source = JObject.Parse(payloadJson ?? "{}"); + if (source["WorkOrderId"] != null) return Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Routing(source); + var safe = new JObject(); foreach (var name in Identifiers) if (source[name]?.Type == JTokenType.String && Guid.TryParse(source[name].Value(), out var id)) safe[name] = id.ToString("D"); if (safe["CompletionId"] == null && safe["ScheduleId"] == null && safe["OccurrenceId"] == null && Guid.TryParse(aggregateId, out var completion)) safe["CompletionId"] = completion.ToString("D"); @@ -29,27 +41,28 @@ public static string Routing(string payloadJson, string aggregateId) if (source["TargetId"]?.Type == JTokenType.String) { var target = source["TargetId"].Value(); var type = safe["TargetType"]?.Value() ?? -1; - var structural = type >= 0 && type <= 2 && int.TryParse(target, out var numeric) && numeric > 0 - || type == (int)ChecklistTargetType.InventoryAsset && Guid.TryParse(target, out _); - safe["TargetId"] = structural ? target : ProtectedDataEnvelope.RedactionValue; + safe["TargetId"] = IsStructuralTarget(type, target) ? target : ProtectedDataEnvelope.RedactionValue; } safe["Score"] = ProtectedDataEnvelope.RedactionValue; safe["Passed"] = ProtectedDataEnvelope.RedactionValue; safe["is_redacted"] = true; safe["redacted_fields"] = new JArray(new[] { "Score", "Passed", "TargetId" }.Where(n => safe[n]?.Value() == ProtectedDataEnvelope.RedactionValue)); safe["catalog_version"] = ReadinessHistoryFields.CatalogVersion; return safe.ToString(Newtonsoft.Json.Formatting.None); } public static bool IsChecklist(int trigger) => Triggers.Contains(trigger); + public static bool IsReadinessProducer(string producer) => producer == "Checklists" || producer == "WorkOrders"; public static async Task ProjectAsync(int departmentId, object value, IProtectedProjectionService protection, bool wrapped = false) { if (protection == null) throw new InvalidOperationException("Checklist workflow protection is unavailable."); var source = value as JObject ?? (value == null ? new JObject() : JObject.FromObject(value)); var payload = wrapped ? source["Payload"] as JObject ?? new JObject() : source; + if (payload["WorkOrderId"] != null) return await Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.ProjectAsync(departmentId, payload, protection, wrapped); var safe = new JObject(); foreach (var name in Identifiers) if (Guid.TryParse(payload[name]?.Value(), out var id)) safe[name] = id.ToString("D"); Timing(payload, safe); if (payload["TargetType"]?.Type == JTokenType.Integer) safe["TargetType"] = payload["TargetType"].DeepClone(); if (payload["TargetId"]?.Type == JTokenType.String && payload["TargetId"].Value().Length <= 128) - safe["TargetId"] = payload["TargetId"].DeepClone(); + safe["TargetId"] = IsStructuralTarget(safe["TargetType"]?.Value() ?? -1, payload["TargetId"].Value()) + ? payload["TargetId"].DeepClone() : new JValue(ProtectedDataEnvelope.RedactionValue); foreach (var name in new[] { "Score", "Passed" }) { var token = payload[name]; @@ -60,7 +73,7 @@ public static async Task ProjectAsync(int departmentId, object value, IP var projected = JObject.Parse(await protection.BuildSafeWorkflowPayloadAsync(departmentId, safe) ?? throw new InvalidOperationException("Checklist workflow projection failed.")); // A later policy change cannot restore a value already withheld by an earlier projection. - if (projected["is_redacted"]?.Value() == true && projected["TargetType"]?.Value() == (int)ChecklistTargetType.Personnel) + if (projected["TargetId"]?.Type == JTokenType.String && !IsStructuralTarget(projected["TargetType"]?.Value() ?? -1, projected["TargetId"].Value())) projected["TargetId"] = ProtectedDataEnvelope.RedactionValue; var redacted = new[] { "Score", "Passed", "TargetId" }.Where(n => projected[n]?.Type == JTokenType.String && projected[n].Value() == ProtectedDataEnvelope.RedactionValue).ToArray(); projected["is_redacted"] = projected["is_redacted"]?.Value() == true || redacted.Length > 0; diff --git a/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs b/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs index e3234695f..bb379bfaa 100644 --- a/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs +++ b/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs @@ -7,7 +7,7 @@ namespace Resgrid.Model.Checklists public static class ReadinessHistoryFields { public const int CatalogVersion = 16; - public static readonly int[] AuditTypes = Enum.GetValues().Where(x => x.ToString().StartsWith("Checklist", StringComparison.Ordinal)).Select(x => (int)x).ToArray(); + public static readonly int[] AuditTypes = Enum.GetValues().Where(x => (x.ToString().StartsWith("Checklist", StringComparison.Ordinal) || x.ToString().StartsWith("WorkOrder", StringComparison.Ordinal))).Select(x => (int)x).ToArray(); public static bool IsChecklistAudit(int type) => AuditTypes.Contains(type); public static readonly IReadOnlyDictionary Get, Action Set)> Audits = new Dictionary, Action)> { ["auditlogs.data"] = (x => x.Data, (x, v) => x.Data = v) }; diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs index 7126282b8..726ede0b7 100644 --- a/Core/Resgrid.Model/PermissionTypes.cs +++ b/Core/Resgrid.Model/PermissionTypes.cs @@ -151,7 +151,9 @@ public enum PermissionTypes RecordsPreventionAdmin = 69, ManageChecklists = 112, - ViewChecklistResults = 113 +ViewChecklistResults = 113, + ManageWorkOrders = 114, + ViewAllWorkOrders = 115 } } diff --git a/Core/Resgrid.Model/ReportTypes.cs b/Core/Resgrid.Model/ReportTypes.cs index 03b83a3c0..227cbc84d 100644 --- a/Core/Resgrid.Model/ReportTypes.cs +++ b/Core/Resgrid.Model/ReportTypes.cs @@ -1,10 +1,12 @@ -namespace Resgrid.Model +namespace Resgrid.Model { public enum ReportTypes { Staffing = 0, Personnel = 1, Certifications = 2, - ShiftReadiness = 3 + ShiftReadiness = 3, + ChecklistCompliance = 4, + ChecklistMissed = 5 } } \ No newline at end of file diff --git a/Core/Resgrid.Model/Repositories/IChecklistRepository.cs b/Core/Resgrid.Model/Repositories/IChecklistRepository.cs index 3468c41d0..82f09c93c 100644 --- a/Core/Resgrid.Model/Repositories/IChecklistRepository.cs +++ b/Core/Resgrid.Model/Repositories/IChecklistRepository.cs @@ -8,14 +8,16 @@ namespace Resgrid.Model.Repositories { public interface IChecklistRepository { + Task> ReportOccurrencesAsync(int departmentId, DateTime fromUtc, DateTime untilUtc, int skip, CancellationToken ct = default); Task LockDepartmentAsync(int departmentId, CancellationToken ct = default); Task LockAccessFenceAsync(CancellationToken ct = default); - Task ApplyAccessStateAsync(int departmentId, bool enabled, DateTime nowUtc, CancellationToken ct = default, bool inventoryEnabled = true); + Task ApplyAccessStateAsync(int departmentId, bool enabled, DateTime nowUtc, bool inventoryEnabled = true, CancellationToken ct = default); Task> CalendarOccurrencesAsync(int departmentId, DateTime fromUtc, DateTime untilUtc, int skip, CancellationToken ct = default); Task> ShiftStartsAsync(int departmentId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default); Task AdvanceDigestSweepAsync(int departmentId, DateTime nowUtc, CancellationToken ct = default); Task GetAsync(int departmentId, string id, CancellationToken ct = default) where T : ChecklistRow; Task> ListAsync(int departmentId, string parentId = null, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow; + Task> ListForMemberAsync(int departmentId, string userId, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow; Task> ListChildrenAsync(int departmentId, IReadOnlyCollection parentIds, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow; Task WriteAsync(T row, bool insert, CancellationToken ct = default) where T : ChecklistRow; Task ReplaceAnswersAsync(int departmentId, string completionId, IEnumerable items, CancellationToken ct = default); diff --git a/Core/Resgrid.Model/Repositories/IReadinessProBillingRepository.cs b/Core/Resgrid.Model/Repositories/IReadinessProBillingRepository.cs new file mode 100644 index 000000000..678209191 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IReadinessProBillingRepository.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IReadinessProBillingRepository + { + Task LockDepartmentAsync(int departmentId); + Task GetAsync(int departmentId); + Task FindAsync(string provider, string customerId, string subscriptionId, string checkoutId); + Task SaveAsync(ReadinessProBillingAccount account); + Task> PaymentsAsync(int departmentId, string planAddonId); + Task SavePaymentAsync(PaymentAddon payment, bool insert); + } +} diff --git a/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs b/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs new file mode 100644 index 000000000..8c69308fc --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Model.Repositories +{ + public interface IWorkOrderRepository + { + Task LockDepartmentAsync(int departmentId); + Task ClaimNotificationAsync(WorkOrderNotification row, System.DateTime now); + Task FinishNotificationAsync(WorkOrderNotification row, int state, System.DateTime now); + Task GetAsync(int departmentId, int id, bool includeData = true) where T : WorkOrderRow; + Task RequestAsync(int departmentId, string requestId); + Task> ListAsync(int departmentId, WorkOrderReadScope scope, WorkOrderFilter filter); + Task> ChildrenAsync(int departmentId, int orderId, int skip = 0) where T : WorkOrderRow; + Task NextNumberAsync(int departmentId, int year); + Task AllocateAsync(T row) where T : WorkOrderRow; + Task WriteAsync(T row) where T : WorkOrderRow; + } +} diff --git a/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs b/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs index 4bbb27f00..7d1d53e5e 100644 --- a/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs +++ b/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs @@ -15,5 +15,6 @@ public interface IWorkflowRunRepository : IRepository /// The initial run for a (WorkflowId, EventId) pair, or null; plan section 5.6 dedup (retries reuse it). Task GetByWorkflowAndEventAsync(string workflowId, string eventId); + Task> GetByWorkflowsAndEventAsync(int departmentId, IReadOnlyCollection workflowIds, string eventId); } } diff --git a/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs b/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs index 0d662cc9c..a797430fa 100644 --- a/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs +++ b/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading.Tasks; using Resgrid.Model.Checklists; @@ -9,6 +10,7 @@ public interface IChecklistAuthorizationService Task RequireMemberAsync(ChecklistActor actor); Task CanManageAsync(ChecklistActor actor); Task CanReadAsync(ChecklistActor actor, ChecklistCompletion completion); + Task>> ReadFilterAsync(ChecklistActor actor); Task TargetAsync(ChecklistActor actor, ChecklistTargetType type, string id); Task> TargetsAsync(ChecklistActor actor, ChecklistTargetType type); } diff --git a/Core/Resgrid.Model/Services/IChecklistScheduledReportService.cs b/Core/Resgrid.Model/Services/IChecklistScheduledReportService.cs new file mode 100644 index 000000000..106f5231d --- /dev/null +++ b/Core/Resgrid.Model/Services/IChecklistScheduledReportService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IChecklistScheduledReportService + { + Task BuildAsync(ScheduledTask queuedTask); + } +} diff --git a/Core/Resgrid.Model/Services/IChecklistsService.cs b/Core/Resgrid.Model/Services/IChecklistsService.cs index d5ab48ef6..030506a4a 100644 --- a/Core/Resgrid.Model/Services/IChecklistsService.cs +++ b/Core/Resgrid.Model/Services/IChecklistsService.cs @@ -8,6 +8,9 @@ namespace Resgrid.Model.Services { public interface IChecklistsService { + Task GetComplianceSummaryAsync(ChecklistActor actor, ChecklistReportQuery query); + Task> GetEntityChecklistHistoryAsync(ChecklistActor actor, ChecklistTargetType entityType, string entityId, DateTime fromUtc, DateTime untilUtc); + Task GetReadinessPacketForCallAsync(ChecklistActor actor, int callId, int lookbackDays = 30); Task CanManageAsync(ChecklistActor actor); Task> CalendarAsync(ChecklistActor actor, DateTime fromUtc, DateTime untilUtc); Task OccurrenceAsync(ChecklistActor actor, string id); @@ -22,6 +25,12 @@ public interface IChecklistsService Task RetireAsync(ChecklistActor actor, string id, int revision, bool delete = false); Task> TargetsAsync(ChecklistActor actor, ChecklistTargetType type); Task StartAsync(ChecklistActor actor, string definitionId, string targetId, string completionId); + Task StartPinnedAsync(ChecklistActor actor, string definitionId, string versionId, string targetId, string completionId); + Task StartOccurrenceWithIdAsync(ChecklistActor actor, string occurrenceId, string completionId); + Task PreviewOccurrenceAsync(ChecklistActor actor, string occurrenceId); + Task MobileDueAsync(ChecklistActor actor, ChecklistMobileQuery query); + Task> MobileHistoryAsync(ChecklistActor actor, ChecklistMobileQuery query); + Task AddFileAtRevisionAsync(ChecklistActor actor, string id, string itemId, int revision, string fileName, string contentType, byte[] data); Task GetRunAsync(ChecklistActor actor, string id); Task> HistoryAsync(ChecklistActor actor, string definitionId, int page = 0, bool includeNext = false); Task SaveRunAsync(ChecklistActor actor, string id, ChecklistRunInput input, bool submit); @@ -29,10 +38,12 @@ public interface IChecklistsService Task AddFileAsync(ChecklistActor actor, string id, string itemId, string fileName, string contentType, byte[] data); Task GetFileAsync(ChecklistActor actor, string id); Task DeleteFileAsync(ChecklistActor actor, string id); + Task DeleteFileAtRevisionAsync(ChecklistActor actor, string id, int revision); Task> SchedulesAsync(ChecklistActor actor, string definitionId, int page = 0); Task GetScheduleAsync(ChecklistActor actor, string id); Task SaveScheduleAsync(ChecklistActor actor, ChecklistScheduleInput input); - Task> DueAsync(ChecklistActor actor, int page = 0); + Task DisableScheduleAsync(ChecklistActor actor, string id, int revision); + Task> DueAsync(ChecklistActor actor, int page = 0, bool includeNext = false); Task StartOccurrenceAsync(ChecklistActor actor, string occurrenceId); Task SkipOccurrenceAsync(ChecklistActor actor, string occurrenceId, int revision, string reason); Task SweepSchedulesAsync(DateTime utcNow, CancellationToken ct = default); diff --git a/Core/Resgrid.Model/Services/IReadinessProBillingService.cs b/Core/Resgrid.Model/Services/IReadinessProBillingService.cs new file mode 100644 index 000000000..767f1a72b --- /dev/null +++ b/Core/Resgrid.Model/Services/IReadinessProBillingService.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IReadinessProBillingService + { + Task GetAsync(int departmentId); + Task BeginCheckoutAsync(int departmentId); + Task CancelRenewalAsync(int departmentId); + } +} + +namespace Resgrid.Model +{ + public sealed class ReadinessProBillingStatus + { + public string Provider { get; set; } + public string Currency { get; set; } + public decimal MonthlyAmount { get; set; } + public bool CheckoutAvailable { get; set; } + public bool Active { get; set; } + public bool CanCancel { get; set; } + public bool Cancelled { get; set; } + public DateTime? PaidThroughUtc { get; set; } + } + public sealed class ReadinessProCheckout + { + public string Provider { get; set; } + public string Url { get; set; } + public string TransactionId { get; set; } + } + /// Provider routing and an expiring checkout reference only; never a card, grant or invoice payload. + public sealed class ReadinessProBillingAccount + { + public int DepartmentId { get; set; } + public string Provider { get; set; } + public string CustomerId { get; set; } + public string PlanAddonId { get; set; } + public string PriceId { get; set; } + public string SubscriptionId { get; set; } + public string CheckoutId { get; set; } + public string CheckoutUrl { get; set; } + public DateTime? CheckoutExpiresOn { get; set; } + public string CheckoutAttempt { get; set; } + public DateTime UpdatedOn { get; set; } + } +} diff --git a/Core/Resgrid.Model/Services/ISubscriptionsService.cs b/Core/Resgrid.Model/Services/ISubscriptionsService.cs index a06f76937..260b7877e 100644 --- a/Core/Resgrid.Model/Services/ISubscriptionsService.cs +++ b/Core/Resgrid.Model/Services/ISubscriptionsService.cs @@ -192,7 +192,7 @@ Task CreateFreePlanPaymentAsync(int departmentId, string userId, /// /// The type of plan addon. /// Task<List<PlanAddon>>. - Task> GetAllAddonPlansByTypeAsync(PlanAddonTypes planAddonType); + Task> GetAllAddonPlansByTypeAsync(PlanAddonTypes planAddonType, bool bypassCache = false); /// /// Gets all plan addons active for a subscription asynchronous. diff --git a/Core/Resgrid.Model/Services/IWorkOrderNotificationService.cs b/Core/Resgrid.Model/Services/IWorkOrderNotificationService.cs new file mode 100644 index 000000000..1a36c67f4 --- /dev/null +++ b/Core/Resgrid.Model/Services/IWorkOrderNotificationService.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IWorkOrderNotificationService { Task DispatchAsync(DomainEventOutboxEntry entry); } +} + +namespace Resgrid.Model.WorkOrders +{ + /// Routing-only, durable per-event/recipient handoff. Contains no protected content. + public sealed class WorkOrderNotification + { + public string EventId { get; set; } + public string UserId { get; set; } + public int DepartmentId { get; set; } + public int WorkOrderId { get; set; } + public int State { get; set; } + public string LeaseOwner { get; set; } + public DateTime? LeaseExpiresOn { get; set; } + public DateTime UpdatedOn { get; set; } + } +} diff --git a/Core/Resgrid.Model/Services/IWorkOrdersService.cs b/Core/Resgrid.Model/Services/IWorkOrdersService.cs new file mode 100644 index 000000000..6fe39650c --- /dev/null +++ b/Core/Resgrid.Model/Services/IWorkOrdersService.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Model.Services +{ + public interface IWorkOrdersService + { + Task ListAsync(ChecklistActor actor, WorkOrderFilter filter); + Task GetAsync(ChecklistActor actor, int id); + Task CreateAsync(ChecklistActor actor, WorkOrderInput input); + Task UpdateAsync(ChecklistActor actor, int id, WorkOrderInput input); + Task TransitionAsync(ChecklistActor actor, int id, WorkOrderTransition input); + Task AssignAsync(ChecklistActor actor, int id, WorkOrderAssignment input); + Task AcceptAssignmentAsync(ChecklistActor actor, int id, int revision); + Task CommentAsync(ChecklistActor actor, int id, int revision, string note); + Task AddLaborAsync(ChecklistActor actor, int id, WorkOrderLaborInput input); + Task AddPartAsync(ChecklistActor actor, int id, WorkOrderPartInput input); + Task VoidPartAsync(ChecklistActor actor, int id, int partId, int revision, string reason); + Task AddFileAsync(ChecklistActor actor, int id, int revision, string filename, string contentType, byte[] data); + Task GetFileAsync(ChecklistActor actor, int id); + Task WithdrawFileAsync(ChecklistActor actor, int id, int fileId, int revision, string reason); + Task ChoicesAsync(ChecklistActor actor); + } + public interface IWorkOrderAuthorizationService + { + Task RequireMemberAsync(ChecklistActor actor); + Task ScopeAsync(ChecklistActor actor); + Task CanManageAsync(ChecklistActor actor, int? groupId); + Task CanContributeAsync(ChecklistActor actor, WorkOrder row); + Task ValidateTargetAsync(ChecklistActor actor, WorkOrderInput input); + Task ValidateAssignmentAsync(ChecklistActor actor, WorkOrder row, string userId, int? roleId); + Task ChoicesAsync(ChecklistActor actor); + Task> RecipientsAsync(int departmentId, WorkOrder row); + } +} + +namespace Resgrid.Model.WorkOrders +{ + public sealed class WorkOrderException : Exception + { + public int StatusCode { get; } + public string Code { get; } + public WorkOrderException(int status, string code) : base(code) { StatusCode = status; Code = code; } + } + public sealed class WorkOrderFilter + { + public int Page { get; set; } + public WorkOrderStatus? Status { get; set; } + public WorkOrderPriority? Priority { get; set; } + public bool AssignedToMe { get; set; } + public int? UnitId { get; set; } + public int? GroupId { get; set; } + public string AssetId { get; set; } + } + public sealed class WorkOrderReadScope + { + public string UserId { get; set; } + public bool All { get; set; } + public int? GroupId { get; set; } + public int[] RoleIds { get; set; } = Array.Empty(); + public bool Allows(WorkOrder row) => All || row.CreatedBy == UserId || row.AssignedToUserId == UserId || GroupId.HasValue && row.TargetGroupId == GroupId || row.AssignedToRoleId.HasValue && Array.IndexOf(RoleIds, row.AssignedToRoleId.Value) >= 0; + } + public sealed class WorkOrderInput + { + public string RequestId { get; set; } = Guid.NewGuid().ToString("D"); + public int Revision { get; set; } + public WorkOrderType Type { get; set; } + public WorkOrderPriority Priority { get; set; } = WorkOrderPriority.Normal; + public int? TargetUnitId { get; set; } + public int? TargetGroupId { get; set; } + public string InventoryAssetId { get; set; } + public DateTime? DueOn { get; set; } + public WorkOrderContent Content { get; set; } = new WorkOrderContent(); + } + public sealed class WorkOrderTransition + { + public int Revision { get; set; } + public WorkOrderStatus Status { get; set; } + public string Reason { get; set; } + public int? DuplicateOfId { get; set; } + public string Resolution { get; set; } + public string Cause { get; set; } + public string VerificationEvidence { get; set; } + public bool ConfirmTasksComplete { get; set; } + } + public sealed class WorkOrderAssignment { public int Revision { get; set; } public string UserId { get; set; } public int? RoleId { get; set; } } + public sealed class WorkOrderLaborInput { public int Revision { get; set; } public string UserId { get; set; } public DateTime WorkDate { get; set; } public WorkOrderLaborContent Content { get; set; } = new WorkOrderLaborContent(); } + public sealed class WorkOrderPartInput { public int Revision { get; set; } public WorkOrderPartContent Content { get; set; } = new WorkOrderPartContent(); } + public sealed class WorkOrderSummary + { + public WorkOrderStatus? QuickStatus { get; set; } + public int Id { get; set; } + public string Number { get; set; } + public string Title { get; set; } + public WorkOrderStatus Status { get; set; } + public WorkOrderPriority Priority { get; set; } + public int Revision { get; set; } + public DateTime UpdatedOn { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime? DueOn { get; set; } + public string AssignedToUserId { get; set; } + public int? AssignedToRoleId { get; set; } + public int? UnitId { get; set; } + public int? GroupId { get; set; } + public string AssetId { get; set; } + } + public sealed class WorkOrderPage { public List Items { get; set; } = new List(); public bool HasMore { get; set; } public bool CanWrite { get; set; } } + public sealed class WorkOrderDetail + { + public WorkOrderSummary Order { get; set; } + public WorkOrderInput Input { get; set; } + public bool CanWrite { get; set; } + public bool CanManage { get; set; } + public bool CanEdit { get; set; } + public bool CanContribute { get; set; } + public bool CanAccept { get; set; } + public WorkOrderStatus[] Transitions { get; set; } = Array.Empty(); + public string ReportedBy { get; set; } + public string VerifiedBy { get; set; } + public DateTime? CompletedOn { get; set; } + public DateTime? ClosedOn { get; set; } + public List Activities { get; set; } = new List(); + public List Labor { get; set; } = new List(); + public List Parts { get; set; } = new List(); + public List Files { get; set; } = new List(); + } + public sealed class WorkOrderActivityView { public WorkOrderContent Snapshot { get; set; } public string AssignedToUserId { get; set; } public int? AssignedToRoleId { get; set; } public int Id { get; set; } public WorkOrderActivityType Type { get; set; } public string UserId { get; set; } public DateTime CreatedOn { get; set; } public string Note { get; set; } public int? OldStatus { get; set; } public int? NewStatus { get; set; } } + public sealed class WorkOrderLaborView { public int Id { get; set; } public string UserId { get; set; } public DateTime WorkDate { get; set; } public WorkOrderLaborContent Content { get; set; } } + public sealed class WorkOrderPartView { public int Id { get; set; } public DateTime? VoidedOn { get; set; } public WorkOrderPartContent Content { get; set; } } + public sealed class WorkOrderFileView { public int Id { get; set; } public string Name { get; set; } public string ContentType { get; set; } public int Size { get; set; } public DateTime? WithdrawnOn { get; set; } } + public sealed class WorkOrderChoice { public string Id { get; set; } public string Name { get; set; } } + public sealed class WorkOrderChoices + { + public List Users { get; set; } = new List(); + public List Roles { get; set; } = new List(); + public List Units { get; set; } = new List(); + public List Groups { get; set; } = new List(); + public List Assets { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs new file mode 100644 index 000000000..da2a1480d --- /dev/null +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; + +namespace Resgrid.Model.WorkOrders +{ + public enum WorkOrderStatus { Requested = 0, Accepted = 1, Assigned = 2, InProgress = 3, OnHold = 4, Completed = 5, Closed = 6, Rejected = 7, Duplicate = 8, Cancelled = 9 } + public enum WorkOrderPriority { Low = 0, Normal = 1, High = 2, Emergency = 3 } + public enum WorkOrderType { Corrective = 0, Preventive = 1, Inspection = 2, Facility = 3, Other = 4 } + public enum WorkOrderActivityType { Created = 0, Updated = 1, StatusChanged = 2, Assigned = 3, AssignmentAccepted = 4, Comment = 5, LaborAdded = 6, PartAdded = 7, PartVoided = 8, FileAdded = 9, FileWithdrawn = 10 } + /// Free text, monetary details and file names live only in cataloged Content. Numeric identities are allocated with empty content before sealing. + public abstract class WorkOrderRow : IEntity + { + [NotMapped, Newtonsoft.Json.JsonIgnore] public object IdValue { get => Id; set => Id = Convert.ToInt32(value); } + [NotMapped, Newtonsoft.Json.JsonIgnore] public string TableName => WorkOrderTables.All[GetType()]; + [NotMapped, Newtonsoft.Json.JsonIgnore] public string IdName => "Id"; + [NotMapped, Newtonsoft.Json.JsonIgnore] public int IdType => 0; + [NotMapped, Newtonsoft.Json.JsonIgnore] public IEnumerable IgnoredProperties => new[] { "IdValue", "TableName", "IdName", "IdType", "IgnoredProperties" }; + public int Id { get; set; } + public int DepartmentId { get; set; } + public int? WorkOrderId { get; set; } + public string Content { get; set; } + public int Revision { get; set; } = 1; + public DateTime CreatedOn { get; set; } + public DateTime UpdatedOn { get; set; } + public string CreatedBy { get; set; } + public bool IsProtected { get; set; } + } + public sealed class WorkOrder : WorkOrderRow + { + public string RequestId { get; set; } + public int NumberYear { get; set; } + public int NumberSequence { get; set; } + public int Type { get; set; } + public int Priority { get; set; } + public int Status { get; set; } + public int SourceType { get; set; } + public string SourceChecklistCompletionId { get; set; } + public string SourceChecklistItemId { get; set; } + public string SourceOccurrenceId { get; set; } + public int? TargetUnitId { get; set; } + public int? TargetGroupId { get; set; } + public string InventoryAssetId { get; set; } + public string AssignedToUserId { get; set; } + public int? AssignedToRoleId { get; set; } + public DateTime? DueOn { get; set; } + public DateTime? TriagedOn { get; set; } + public DateTime? AssignedOn { get; set; } + public DateTime? AssignmentAcceptedOn { get; set; } + public string AssignmentAcceptedBy { get; set; } + public DateTime? StartedOn { get; set; } + public DateTime? CompletedOn { get; set; } + public string CompletedBy { get; set; } + public DateTime? ClosedOn { get; set; } + public string VerifiedBy { get; set; } + public int? DuplicateOfId { get; set; } + public bool SetUnitOutOfService { get; set; } + public bool RestoreUnitStateOnClose { get; set; } + public int? PreviousUnitStateType { get; set; } + public string WorkOrderRecurrenceId { get; set; } + public bool IsDeleted { get; set; } + } + public sealed class WorkOrderActivity : WorkOrderRow + { + public int ActivityType { get; set; } + public int? OldStatus { get; set; } + public int? NewStatus { get; set; } + } + public sealed class WorkOrderLabor : WorkOrderRow { public string UserId { get; set; } public DateTime WorkDate { get; set; } } + public sealed class WorkOrderPart : WorkOrderRow + { + public string InventoryItemId { get; set; } + public string InventoryTransactionId { get; set; } + public DateTime? VoidedOn { get; set; } + } + public sealed class WorkOrderFile : WorkOrderRow + { + public string ContentType { get; set; } + public int Size { get; set; } + public string Sha256 { get; set; } + public byte[] Data { get; set; } + public int ScanState { get; set; } + public DateTime? WithdrawnOn { get; set; } + } + public sealed class WorkOrderContent + { + public string Title { get; set; } + public string Description { get; set; } + public string LocationText { get; set; } + public string CostCenter { get; set; } + public string Currency { get; set; } = "USD"; + public decimal? EstimatedCost { get; set; } + public decimal? ApprovedCost { get; set; } + public string VendorDetails { get; set; } + public string WarrantyReference { get; set; } + public string ProcedureReference { get; set; } + public string ProcedureVersion { get; set; } + public string PermitReference { get; set; } + public string IsolationReference { get; set; } + public string QualifiedPersonnel { get; set; } + public bool SafetyCritical { get; set; } + public bool HazardousWork { get; set; } + public string Resolution { get; set; } + public string Cause { get; set; } + public string VerificationEvidence { get; set; } + public List Steps { get; set; } = new List(); + } + public sealed class WorkOrderTaskStep { public string Text { get; set; } public bool Completed { get; set; } } + public sealed class WorkOrderLaborContent { public decimal Hours { get; set; } public decimal? RatePerHour { get; set; } public string Note { get; set; } } + public sealed class WorkOrderPartContent { public string Description { get; set; } public decimal Quantity { get; set; } public decimal? UnitCost { get; set; } public string VoidReason { get; set; } } + public static class WorkOrderTables + { + public const int CatalogVersion = 18; + public static readonly IReadOnlyDictionary All = new Dictionary + { + [typeof(WorkOrder)] = "WorkOrders", [typeof(WorkOrderActivity)] = "WorkOrderActivities", [typeof(WorkOrderLabor)] = "WorkOrderLabors", + [typeof(WorkOrderPart)] = "WorkOrderParts", [typeof(WorkOrderFile)] = "WorkOrderFiles" + }; + public static IReadOnlyDictionary Get, Action Set)> Fields() where T : WorkOrderRow => + new Dictionary, Action)> { [All[typeof(T)].ToLowerInvariant() + ".content"] = (x => x.Content, (x, v) => x.Content = v) }; + } +} diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderPermissionCatalog.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderPermissionCatalog.cs new file mode 100644 index 000000000..8dc49d03a --- /dev/null +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderPermissionCatalog.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + public static class WorkOrderPermissionCatalog + { + public static readonly IReadOnlyList All = new[] + { + new RecordPermissionDescriptor(PermissionTypes.ManageWorkOrders, PermissionActions.DepartmentAdminsOnly, false, "ManageWorkOrdersNote", false), + new RecordPermissionDescriptor(PermissionTypes.ViewAllWorkOrders, PermissionActions.DepartmentAndGroupAdmins, true, "ViewAllWorkOrdersNote") + }; + } +} diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs new file mode 100644 index 000000000..209d4a20d --- /dev/null +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model.Services; + +namespace Resgrid.Model.WorkOrders +{ + /// Workflow and notification routing never contains work-order content, personnel IDs, file names or grants. + public static class WorkOrderWorkflowPayload + { + public static readonly (string Variable, string Property)[] Variables = { ("id", "WorkOrderId"), ("revision", "Revision"), ("status", "Status"), ("priority", "Priority"), ("unit_id", "TargetUnitId"), ("group_id", "TargetGroupId"), ("asset_id", "InventoryAssetId"), ("role_id", "AssignedToRoleId"), ("due_on", "DueOn"), ("title", "Title") }; + public static string Routing(JObject payload) + { + var safe = new JObject(); + foreach (var field in new[] { "WorkOrderId", "Revision", "Status", "Priority", "TargetUnitId", "TargetGroupId", "AssignedToRoleId" }) + if (payload[field]?.Type == JTokenType.Integer && payload[field].Value() >= 0 && payload[field].Value() <= int.MaxValue) safe[field] = payload[field].DeepClone(); + if (payload["InventoryAssetId"]?.Type == JTokenType.String && Guid.TryParseExact(payload["InventoryAssetId"].Value(), "D", out var asset)) safe["InventoryAssetId"] = asset.ToString("D"); + if ((payload["DueOn"]?.Type == JTokenType.String || payload["DueOn"]?.Type == JTokenType.Date) && DateTimeOffset.TryParse(payload["DueOn"].ToString(), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var due)) safe["DueOn"] = due.UtcDateTime.ToString("O"); + safe["Title"] = ProtectedDataEnvelope.RedactionValue; safe["is_redacted"] = true; safe["redacted_fields"] = new JArray("Title"); safe["catalog_version"] = WorkOrderTables.CatalogVersion; + return safe.ToString(Formatting.None); + } + public static async Task ProjectAsync(int departmentId, JObject payload, IProtectedProjectionService protection, bool wrapped) + { + if (protection == null) throw new InvalidOperationException("Work-order workflow protection is unavailable."); + var safe = JObject.Parse(Routing(payload)); + var projected = await protection.BuildSafeWorkflowPayloadAsync(departmentId, safe); + if (projected == null) throw new InvalidOperationException("Work-order workflow protection is unavailable."); + // Reapply the whitelist after the generic projector. Neither policy nor replay restores content. + safe = JObject.Parse(Routing(JObject.Parse(projected))); + return (wrapped ? new JObject { ["Payload"] = safe } : safe).ToString(Formatting.None); + } + } +} diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index bd901d578..50f2e38a1 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -386,6 +386,16 @@ public static IReadOnlyList GetVariableCatalog(Workf switch (eventType) { + case WorkflowTriggerEventType.WorkOrderCreated: + case WorkflowTriggerEventType.WorkOrderStatusChanged: + case WorkflowTriggerEventType.WorkOrderAssigned: + foreach (var pair in WorkOrders.WorkOrderWorkflowPayload.Variables) + list.Add(new TemplateVariableDescriptor("work_order." + pair.Variable, pair.Property, pair.Variable is "asset_id" or "due_on" or "title" ? "string" : "int", false)); + list.Add(new TemplateVariableDescriptor("work_order.url", "Authenticated work-order link", "string", false)); + list.Add(new TemplateVariableDescriptor("protection.is_redacted", "Sensitive work-order fields are withheld", "bool", false)); + list.Add(new TemplateVariableDescriptor("protection.redacted_fields", "Withheld fields", "array", false)); + list.Add(new TemplateVariableDescriptor("protection.catalog_version", "Protection catalog", "int", false)); + break; case WorkflowTriggerEventType.ChecklistCompleted: case WorkflowTriggerEventType.ChecklistFailed: case WorkflowTriggerEventType.ChecklistMissed: diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 15a7e4c8d..302f8783c 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -167,7 +167,10 @@ public enum WorkflowTriggerEventType RecordViolationOverdue = 162, /// An issued permit is inside its expiry notice window (RMS-5); raised once per permit by the daily sweep. - RecordPermitExpiring = 163 +RecordPermitExpiring = 163, + WorkOrderCreated = 70, + WorkOrderStatusChanged = 71, + WorkOrderAssigned = 72 } public static class WorkflowTriggerEventTypes diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index debad0cf0..6210399d2 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -66,7 +66,7 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => AdpTableBinding.Direct("AuditLogs", "AuditLogId", true, "DepartmentId", new[] { Text("AuditLogs", "Data") }) with { Discriminator = new AdpRowDiscriminator("LogType", Resgrid.Model.Checklists.ReadinessHistoryFields.AuditTypes) }, AdpTableBinding.Direct("DomainEventOutbox", "DomainEventOutboxId", true, "DepartmentId", new[] { Text("DomainEventOutbox", "PayloadJson"), Text("DomainEventOutbox", "LastError") }) - with { Discriminator = new AdpRowDiscriminator("ProducerSubsystem", Text: "Checklists") }, + with { Discriminator = new AdpRowDiscriminator("ProducerSubsystem", Texts: new[] { "Checklists", "WorkOrders" }) }, AdpTableBinding.Direct("WorkflowRuns", "WorkflowRunId", false, "DepartmentId", new[] { Text("WorkflowRuns", "InputPayload"), Text("WorkflowRuns", "ErrorMessage") }) with { Discriminator = new AdpRowDiscriminator("TriggerEventType", Resgrid.Model.Checklists.ChecklistWorkflowPayload.Triggers) }, AdpTableBinding.ViaParent("WorkflowRunLogs", "WorkflowRunLogId", false, "WorkflowRunId", "WorkflowRuns", "WorkflowRunId", new[] { Text("WorkflowRunLogs", "RenderedOutput"), Text("WorkflowRunLogs", "ActionResult"), Text("WorkflowRunLogs", "ErrorMessage") }) @@ -505,6 +505,8 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => Text("RmsPreventionAttachments", "FileName"), Text("RmsPreventionAttachments", "Description"), Binary("RmsPreventionAttachments", "Data") }) with { ProtectedMarkerColumn = "IsProtected" } }; + bindings.AddRange(Resgrid.Model.WorkOrders.WorkOrderTables.All.Values.Select(table => + AdpTableBinding.Direct(table, "Id", true, "DepartmentId", table == "WorkOrderFiles" ? new[] { Text(table, "Content"), Binary(table, "Data") } : new[] { Text(table, "Content") }) with { ProtectedMarkerColumn = "IsProtected" })); return bindings.Concat(Resgrid.Model.Checklists.ChecklistTables.All.Values.Select(table => AdpTableBinding.Direct(table, "Id", false, "DepartmentId", table switch { diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs index 779fa269e..da51f3a05 100644 --- a/Core/Resgrid.Services/AuditService.cs +++ b/Core/Resgrid.Services/AuditService.cs @@ -38,7 +38,10 @@ public async Task GetAuditLogByIdAsync(int auditLogId) public async Task> GetAllAuditLogsForDepartmentAsync(int departmentId) { var logs = await _auditLogsRepository.GetAllByDepartmentIdAsync(departmentId); - return (await Task.WhenAll(logs.Select(DisplayAsync))).ToList(); + var display = new List(); + foreach (var log in logs) + display.Add(await DisplayAsync(log)); + return display; } public async Task> GetAuditLogsForDepartmentPagedAsync(int departmentId, DateTime startDate, DateTime endDate, AuditLogTypes? logType, int page, int pageSize) @@ -54,7 +57,10 @@ public async Task> GetAuditLogsForDepartmentPagedAsync(int depart var safeEnd = (endDate == default(DateTime) || endDate < safeStart) ? DateTime.UtcNow : endDate; var logs = await _auditLogsRepository.GetAuditLogsForDepartmentPagedAsync(departmentId, safeStart, safeEnd, (int?)logType, safePage, safePageSize); - return (await Task.WhenAll(logs.Select(DisplayAsync))).ToList(); + var display = new List(); + foreach (var log in logs) + display.Add(await DisplayAsync(log)); + return display; } private Task DisplayAsync(AuditLog log) diff --git a/Core/Resgrid.Services/ChecklistAccessMutationObserver.cs b/Core/Resgrid.Services/ChecklistAccessMutationObserver.cs index c1b90e375..62492e657 100644 --- a/Core/Resgrid.Services/ChecklistAccessMutationObserver.cs +++ b/Core/Resgrid.Services/ChecklistAccessMutationObserver.cs @@ -36,7 +36,7 @@ public async Task AfterChangeAsync(Func> evaluate, Cance var row = await _settings.GetDepartmentSettingByIdTypeAsync(department, DepartmentSettingTypes.ModuleSettings); var module = row == null ? new DepartmentModuleSettings() : ObjectSerialization.Deserialize(row.Setting); var enabled = module != null && !module.ChecklistsDisabled && await evaluate(FeatureFlagKeys.ChecklistsSystem, department); - await _store.ApplyAccessStateAsync(department, enabled, _clock.GetUtcNow().UtcDateTime, ct, module?.InventoryDisabled != true); + await _store.ApplyAccessStateAsync(department, enabled, _clock.GetUtcNow().UtcDateTime, module?.InventoryDisabled != true, ct); } } } diff --git a/Core/Resgrid.Services/ChecklistAssignmentService.cs b/Core/Resgrid.Services/ChecklistAssignmentService.cs index df3735b1a..3d4d73c3d 100644 --- a/Core/Resgrid.Services/ChecklistAssignmentService.cs +++ b/Core/Resgrid.Services/ChecklistAssignmentService.cs @@ -26,14 +26,14 @@ public ChecklistAssignmentService(IDepartmentsService departments, IDepartmentGr { _departments = departments; _groups = groups; _units = units; _roles = roles; } public async Task ValidateAsync(int departmentId, int type, string id) { - if (type == 0 && string.IsNullOrEmpty(id)) return; + if (type == (int)ChecklistAssignmentType.Automatic && string.IsNullOrEmpty(id)) return; if (!Enum.IsDefined(typeof(ChecklistAssignmentType), type) || string.IsNullOrWhiteSpace(id) || id.Length > 128) throw new ChecklistException(400, "AssignmentUnavailable"); var valid = false; - if (type == 1) { var m = await _departments.GetDepartmentMemberAsync(id, departmentId, true); valid = m?.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true; } + if (type == (int)ChecklistAssignmentType.User) { var m = await _departments.GetDepartmentMemberAsync(id, departmentId, true); valid = m?.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true; } else if (int.TryParse(id, NumberStyles.None, CultureInfo.InvariantCulture, out var numeric)) { - valid = type switch { 2 => (await _roles.GetRoleByIdAsync(numeric))?.DepartmentId == departmentId, - 3 => (await _groups.GetGroupByIdAsync(numeric, true))?.DepartmentId == departmentId, 4 => (await _units.GetUnitByIdAsync(numeric))?.DepartmentId == departmentId, _ => false }; + valid = (ChecklistAssignmentType)type switch { ChecklistAssignmentType.Role => (await _roles.GetRoleByIdAsync(numeric))?.DepartmentId == departmentId, + ChecklistAssignmentType.Group => (await _groups.GetGroupByIdAsync(numeric, true))?.DepartmentId == departmentId, ChecklistAssignmentType.Unit => (await _units.GetUnitByIdAsync(numeric))?.DepartmentId == departmentId, _ => false }; } if (!valid) throw new ChecklistException(400, "AssignmentUnavailable"); } @@ -41,12 +41,12 @@ public async Task> MembersAsync(int departmentId, int type, stri { try { await ValidateAsync(departmentId, type, id); } catch (ChecklistException) { return new HashSet(); } var current = (await _departments.GetAllMembersForDepartmentUnlimitedAsync(departmentId, true)).Where(m => m.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true).Select(m => m.UserId).ToHashSet(StringComparer.Ordinal); - IEnumerable assigned = type switch + IEnumerable assigned = (ChecklistAssignmentType)type switch { - 0 => current, 1 => new[] { id }, - 2 => (await _roles.GetAllMembersOfRoleAsync(int.Parse(id, CultureInfo.InvariantCulture))).Select(m => m.UserId), - 3 => (await _groups.GetAllMembersForGroupAsync(int.Parse(id, CultureInfo.InvariantCulture))).Where(m => m.DepartmentId == departmentId).Select(m => m.UserId), - 4 => (await _units.GetActiveRolesForUnitAsync(int.Parse(id, CultureInfo.InvariantCulture))).Where(m => m.DepartmentId == departmentId).Select(m => m.UserId), + ChecklistAssignmentType.Automatic => current, ChecklistAssignmentType.User => new[] { id }, + ChecklistAssignmentType.Role => (await _roles.GetAllMembersOfRoleAsync(int.Parse(id, CultureInfo.InvariantCulture))).Select(m => m.UserId), + ChecklistAssignmentType.Group => (await _groups.GetAllMembersForGroupAsync(int.Parse(id, CultureInfo.InvariantCulture))).Where(m => m.DepartmentId == departmentId).Select(m => m.UserId), + ChecklistAssignmentType.Unit => (await _units.GetActiveRolesForUnitAsync(int.Parse(id, CultureInfo.InvariantCulture))).Where(m => m.DepartmentId == departmentId).Select(m => m.UserId), _ => Array.Empty() }; current.IntersectWith(assigned); return current; diff --git a/Core/Resgrid.Services/ChecklistAuthorizationService.cs b/Core/Resgrid.Services/ChecklistAuthorizationService.cs index baa64e5e5..f2383e443 100644 --- a/Core/Resgrid.Services/ChecklistAuthorizationService.cs +++ b/Core/Resgrid.Services/ChecklistAuthorizationService.cs @@ -27,6 +27,8 @@ public async Task RequireMemberAsync(ChecklistActor actor) if (member == null || member.IsDeleted || member.IsDisabled.GetValueOrDefault()) throw new ChecklistException(403, "Active department membership is required."); } private async Task AllowedAsync(ChecklistActor actor, PermissionTypes type, PermissionActions fallback, int? targetGroup = null) + => (await PermissionFilterAsync(actor, type, fallback))(targetGroup); + private async Task> PermissionFilterAsync(ChecklistActor actor, PermissionTypes type, PermissionActions fallback) { await RequireMemberAsync(actor); var member = await _departments.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true); @@ -34,19 +36,28 @@ private async Task AllowedAsync(ChecklistActor actor, PermissionTypes type var admin = member.IsAdmin.GetValueOrDefault() || department?.ManagingUserId == actor.UserId; var group = await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId); var permission = await _permissions.GetPermissionByDepartmentTypeAsync(actor.DepartmentId, type); - if (!RecordPermissionEvaluation.IsSatisfied(permission?.Action ?? (int)fallback, permission?.Data, admin, group?.IsUserGroupAdmin(actor.UserId) == true, await _roles.GetRolesForUserAsync(actor.UserId, actor.DepartmentId))) return false; + var allowed = RecordPermissionEvaluation.IsSatisfied(permission?.Action ?? (int)fallback, permission?.Data, admin, group?.IsUserGroupAdmin(actor.UserId) == true, await _roles.GetRolesForUserAsync(actor.UserId, actor.DepartmentId)); var lockToGroup = permission?.LockToGroup ?? type == PermissionTypes.ViewChecklistResults; - return admin || !lockToGroup || targetGroup.HasValue && targetGroup == group?.DepartmentGroupId; + var groupId = group?.DepartmentGroupId; + return targetGroup => allowed && (admin || !lockToGroup || targetGroup.HasValue && targetGroup == groupId); } public Task CanManageAsync(ChecklistActor actor) => AllowedAsync(actor, PermissionTypes.ManageChecklists, PermissionActions.DepartmentAdminsOnly); public async Task CanReadAsync(ChecklistActor actor, ChecklistCompletion completion) + => await (await ReadFilterAsync(actor))(completion); + public async Task>> ReadFilterAsync(ChecklistActor actor) { await RequireMemberAsync(actor); - if (completion == null || completion.DepartmentId != actor.DepartmentId) return false; - if (completion.CreatedBy == actor.UserId || completion.WitnessUserId == actor.UserId) return true; - int? groupId = completion.TargetGroupId; - if (completion.TargetType == (int)ChecklistTargetType.Group && int.TryParse(completion.TargetId, out var id)) groupId = id; - return await AllowedAsync(actor, PermissionTypes.ViewChecklistResults, PermissionActions.DepartmentAndGroupAdmins, groupId); + // This snapshot belongs to one read operation. Later commands recheck membership and permissions. + Task> permission = null; + return async completion => + { + if (completion == null || completion.DepartmentId != actor.DepartmentId) return false; + if (completion.CreatedBy == actor.UserId || completion.WitnessUserId == actor.UserId) return true; + int? groupId = completion.TargetGroupId; + if (completion.TargetType == (int)ChecklistTargetType.Group && int.TryParse(completion.TargetId, out var id)) groupId = id; + var allowed = await (permission ??= PermissionFilterAsync(actor, PermissionTypes.ViewChecklistResults, PermissionActions.DepartmentAndGroupAdmins)); + return allowed(groupId); + }; } public async Task TargetAsync(ChecklistActor actor, ChecklistTargetType type, string id) { diff --git a/Core/Resgrid.Services/ChecklistCalendar.cs b/Core/Resgrid.Services/ChecklistCalendar.cs index d2046fe7a..8ba1a86f7 100644 --- a/Core/Resgrid.Services/ChecklistCalendar.cs +++ b/Core/Resgrid.Services/ChecklistCalendar.cs @@ -40,13 +40,20 @@ public async Task> CalendarAsync(ChecklistActor act var result = new List(); if (!await _access.CanUseChecklistsAsync(actor.DepartmentId)) return result; var names = new Dictionary(); + var targets = new Dictionary<(int Type, string Id), bool>(); for (var skip = 0; ; skip += 500) { var rows = await _store.CalendarOccurrencesAsync(actor.DepartmentId, fromUtc, untilUtc, skip); foreach (var row in rows) { - try { await _authorization.TargetAsync(actor, (ChecklistTargetType)row.TargetType, row.TargetId); } - catch (ChecklistException ex) when (ex.StatusCode == 403 || ex.StatusCode == 404) { continue; } + var key = (row.TargetType, row.TargetId); + if (!targets.TryGetValue(key, out var allowed)) + { + try { await _authorization.TargetAsync(actor, (ChecklistTargetType)row.TargetType, row.TargetId); allowed = true; } + catch (ChecklistException ex) when (ex.StatusCode == 403 || ex.StatusCode == 404) { allowed = false; } + targets[key] = allowed; + } + if (!allowed) continue; if (!names.TryGetValue(row.ScheduleId, out var name)) { var schedule = await _store.GetAsync(actor.DepartmentId, row.ScheduleId); diff --git a/Core/Resgrid.Services/ChecklistMobile.cs b/Core/Resgrid.Services/ChecklistMobile.cs new file mode 100644 index 000000000..1bbf3edd7 --- /dev/null +++ b/Core/Resgrid.Services/ChecklistMobile.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; + +namespace Resgrid.Services +{ + public partial class ChecklistsService + { + public async Task DisableScheduleAsync(ChecklistActor actor, string id, int revision) + { + Id(id); await RequireWriteAsync(actor, true); + await TransactionAsync(actor, async events => + { + var row = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, id)); Revision(row, revision); + if (!row.IsActive) return true; + var now = _clock.GetUtcNow().UtcDateTime; row.IsActive = false; row.Revision++; + await _store.CancelUnstartedOccurrencesAsync(actor.DepartmentId, id, null, now); + await PersistAsync(actor, row, false); await AuditAsync(actor, row, Model.AuditLogTypes.ChecklistScheduleUpdated); + await ScheduleEventAsync(row, null, Model.WorkflowTriggerEventType.ChecklistScheduleChanged, events, now); return true; + }, accessFence: true); + } + private async Task MobileQueryAsync(ChecklistActor actor, ChecklistMobileQuery query) + { + await _authorization.RequireMemberAsync(actor); + if (query == null || query.Page < 0 || query.Page > 10000 || query.UnitId != null && query.AssetId != null + || query.Start.HasValue && query.End.HasValue && query.Start > query.End) + throw new ChecklistException(400, "Invalid page."); + if (query.DefinitionId != null) Id(query.DefinitionId); + if (query.UserId?.Length > 128) throw new ChecklistException(400, "Select a target."); + if (query.UnitId != null) await _authorization.TargetAsync(actor, ChecklistTargetType.Unit, query.UnitId); + if (query.AssetId != null) await _authorization.TargetAsync(actor, ChecklistTargetType.InventoryAsset, query.AssetId); + } + private async Task MobileTargetAsync(ChecklistActor actor, ChecklistMobileQuery query, ChecklistTargetType type, string id, bool currentAssetLocation) + { + if (query.AssetId != null) return type == ChecklistTargetType.InventoryAsset && id == query.AssetId; + if (query.UnitId != null) + { + if (type == ChecklistTargetType.Unit) return id == query.UnitId; + if (!currentAssetLocation || type != ChecklistTargetType.InventoryAsset || _assets == null) return false; + var asset = await _assets.GetAsync(actor, id); + return asset?.DepartmentId == actor.DepartmentId && asset.Id == id && asset.UnitId?.ToString() == query.UnitId; + } + return !query.ForCurrentUser || type != ChecklistTargetType.Personnel || id == actor.UserId; + } + public async Task MobileDueAsync(ChecklistActor actor, ChecklistMobileQuery query) + { + await RequireWriteAsync(actor); await MobileQueryAsync(actor, query); + var result = new ChecklistMobilePage(); var now = _clock.GetUtcNow().UtcDateTime; + var occurrences = new Dictionary(); + var rows = await ReadPageAsync(actor.DepartmentId, query.DefinitionId, query.Page, true, async row => + { + if (row.ScheduleId == null || !new[] { 0, 1, 3, 4 }.Contains(row.State) || row.PeriodStartUtc > now.AddDays(7)) return false; + if (!await MobileTargetAsync(actor, query, (ChecklistTargetType)row.TargetType, row.TargetId, true)) return false; + var schedule = await _store.GetAsync(actor.DepartmentId, row.ScheduleId); + if (schedule == null || !schedule.IsActive || schedule.IsSuspended || !await CanPerformScheduleAsync(actor, schedule)) return false; + var completion = await _store.GetAsync(actor.DepartmentId, row.CompletionId); + if (completion != null && completion.CreatedBy != actor.UserId) return false; + ChecklistTarget target; + try { target = await _authorization.TargetAsync(actor, (ChecklistTargetType)row.TargetType, row.TargetId); } + catch (ChecklistException ex) when (ex.StatusCode == 403 || ex.StatusCode == 404) { return false; } + await RevealAsync(actor, schedule); row.Content = null; + occurrences[row.Id] = new ChecklistOccurrenceView { Occurrence = row, Target = target, Name = Decode(schedule.Content).Name, + CanStart = row.PeriodStartUtc <= now && row.State != 1, CanSkip = false }; + return true; + }); + result.Occurrences = rows.Take(50).Select(r => occurrences[r.Id]).ToList(); result.HasMoreOccurrences = rows.Count > 50; + var definitions = new Dictionary(); + var candidates = await ReadPageAsync(actor.DepartmentId, null, query.Page, true, async row => + { + if (row.Retired || row.DeletedOn.HasValue || row.CurrentVersionId == null || query.DefinitionId != null && row.Id != query.DefinitionId) return false; + var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.CurrentVersionId)); + var form = Decode(version.Content); var targets = new List(); + foreach (var target in await _authorization.TargetsAsync(actor, form.TargetType)) + if (await MobileTargetAsync(actor, query, target.Type, target.Id, true)) targets.Add(target); + if (targets.Count == 0) return false; + row.Content = null; + definitions[row.Id] = new ChecklistMobileDefinition { Definition = new ChecklistDefinitionView { Definition = row, Form = form, PublishedForm = form }, Targets = targets }; + return true; + }); + result.Definitions = candidates.Take(50).Select(r => definitions[r.Id]).ToList(); result.HasMoreDefinitions = candidates.Count > 50; + return result; + } + public async Task PreviewOccurrenceAsync(ChecklistActor actor, string occurrenceId) + { + Id(occurrenceId); await RequireWriteAsync(actor); + var row = await _store.GetAsync(actor.DepartmentId, occurrenceId); + if (row?.ScheduleId == null) throw new ChecklistException(404, "OccurrenceUnavailable"); + var schedule = await _store.GetAsync(actor.DepartmentId, row.ScheduleId); + if (schedule?.IsActive != true || schedule.IsSuspended || !await CanPerformScheduleAsync(actor, schedule) || !new[] { 0, 1, 3, 4 }.Contains(row.State)) throw new ChecklistException(403, "AssignmentPermission"); + var target = await _authorization.TargetAsync(actor, (ChecklistTargetType)row.TargetType, row.TargetId); + var completion = await _store.GetAsync(actor.DepartmentId, row.CompletionId); + if (completion != null) return await RunViewAsync(actor, completion); + var definition = await _store.GetAsync(actor.DepartmentId, row.ParentId); + if (definition == null || definition.Retired || definition.DeletedOn.HasValue) throw new ChecklistException(409, "Publish an active checklist before starting a run."); + var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.VersionId)); + if (version.ParentId != row.ParentId) throw new ChecklistException(409, "OccurrenceUnavailable"); + return new ChecklistRunView { Completion = new ChecklistCompletion { Id = row.CompletionId, DepartmentId = actor.DepartmentId, ParentId = row.ParentId, VersionId = row.VersionId, + OccurrenceId = row.Id, TargetId = row.TargetId, TargetType = row.TargetType, CreatedBy = actor.UserId, Revision = 0, IsProtected = version.IsProtected }, + Form = Decode(version.Content), Target = target, Input = new ChecklistRunInput { Revision = 0 }, VersionNumber = version.Version }; + } + public async Task> MobileHistoryAsync(ChecklistActor actor, ChecklistMobileQuery query) + { + await MobileQueryAsync(actor, query); + var rows = await ReadPageAsync(actor.DepartmentId, query.DefinitionId, query.Page, true, async row => + (query.UserId == null || row.CreatedBy == query.UserId) && (!query.ForCurrentUser || row.CreatedBy == actor.UserId) + && (!query.Start.HasValue || row.CreatedOn >= query.Start) && (!query.End.HasValue || row.CreatedOn <= query.End) + && await MobileTargetAsync(actor, query, (ChecklistTargetType)row.TargetType, row.TargetId, false) && await _authorization.CanReadAsync(actor, row)); + var result = new List(); + foreach (var row in rows) + { + await RevealAsync(actor, row); + var occurrence = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.OccurrenceId)); + result.Add(new ChecklistHistoryEntry { Completion = row, TargetName = Decode(occurrence.Content).Name }); + } + return result; + } + } +} diff --git a/Core/Resgrid.Services/ChecklistReminderService.cs b/Core/Resgrid.Services/ChecklistReminderService.cs index d5d115649..fd8371893 100644 --- a/Core/Resgrid.Services/ChecklistReminderService.cs +++ b/Core/Resgrid.Services/ChecklistReminderService.cs @@ -67,7 +67,7 @@ public async Task SweepAsync(DateTime utcNow, Canc } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } - catch { result.Errors++; } // Never persist provider exceptions or sensitive content. + catch (Exception ex) { result.Errors++; Resgrid.Framework.Logging.LogError($"Checklist reminder sweep failed for department {departmentId}: {ex.GetType().FullName}."); } } } return result; diff --git a/Core/Resgrid.Services/ChecklistReportDocuments.cs b/Core/Resgrid.Services/ChecklistReportDocuments.cs new file mode 100644 index 000000000..d89cc31de --- /dev/null +++ b/Core/Resgrid.Services/ChecklistReportDocuments.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Resources; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; + +namespace Resgrid.Services +{ + /// In-memory exports from already authorized snapshots. No remote images, scripts or user HTML. + public static class ChecklistReportDocuments + { + private static readonly ResourceManager Strings = new("Resgrid.Localization.Areas.User.Checklists.Checklists", typeof(Resgrid.Localization.SupportedLocales).Assembly); + public static string Text(string key) => Strings.GetString(key, CultureInfo.CurrentUICulture) ?? key; + private static string H(object value) => WebUtility.HtmlEncode(Convert.ToString(value, CultureInfo.CurrentCulture)); + private static string Cell(object value) => "" + H(value) + ""; + private static string Head(params string[] keys) => "" + string.Concat(keys.Select(k => "" + H(Text(k)) + "")) + ""; + private static string Page(string title, string body) => "" + H(Text(title)) + "

" + H(Text(title)) + "

" + body + ""; + public static string Locked() => Page("ChecklistComplianceReport", "

" + H(Text("ScheduledReportProtected")) + "

"); + public static string Compliance(ChecklistComplianceSummary report, bool missedOnly = false) + { + var body = new StringBuilder("

" + H(report.FromUtc.ToString("u")) + " — " + H(report.UntilUtc.ToString("u")) + "

" + H(Text("ComplianceMethod")) + "

"); + body.Append("

").Append(H(Text("AuthorizedScope"))).Append("

").Append(Head("Target", "ExpectedChecks", "CompletedChecks", "OnTimeChecks", "MissedChecks", "ExcusedChecks", "CompletionRate")); + foreach (var group in report.Groups.Where(g => !missedOnly || g.Missed > 0)) body.Append("").Append(Cell(group.Target.Name)).Append(Cell(group.Expected)).Append(Cell(group.Completed)).Append(Cell(group.OnTime)).Append(Cell(group.Missed)).Append(Cell(group.Skipped)).Append(Cell(group.CompletionRate?.ToString("0.##") ?? "—")).Append(""); + body.Append("

").Append(H(Text("MissedTrend"))).Append("

").Append(Head("Date", "ExpectedChecks", "MissedChecks")); + foreach (var day in report.Trend) body.Append("").Append(Cell(day.DayUtc.ToString("yyyy-MM-dd"))).Append(Cell(day.Expected)).Append(Cell(day.Missed)).Append(""); + body.Append("
").Append(Entries(report.Entries.Where(e => !missedOnly || e.Missed))).Append(Unavailable(report.UnavailableSources)); + return Page(missedOnly ? "ChecklistMissedReport" : "ChecklistComplianceReport", body.ToString()); + } + public static string ComplianceBody(ChecklistComplianceSummary report, bool missedOnly = false) + { + var html = Compliance(report, missedOnly); + return html.Substring(html.IndexOf("", StringComparison.Ordinal) + 6).Replace("", ""); + } + private static string Entries(IEnumerable entries) + { + var body = new StringBuilder("" + Head("Name", "Target", "ScheduledFor", "WindowEnds", "CompletedChecks", "MissedChecks", "Result", "Score")); + foreach (var entry in entries) body.Append("").Append(Cell(entry.Name)).Append(Cell(entry.Target.Name)).Append(Cell(entry.StartUtc.ToString("u"))).Append(Cell(entry.DueUtc?.ToString("u"))).Append(Cell(Text(entry.Completed ? "Yes" : "No"))).Append(Cell(Text(entry.Missed ? "Yes" : "No"))).Append(Cell(entry.Passed.HasValue ? Text(entry.Passed.Value ? "Passed" : "Failed") : "—")).Append(Cell(entry.Score)).Append(""); + return body.Append("
").ToString(); + } + private static string Unavailable(IEnumerable sources) => "
    " + string.Concat(sources.Select(s => "
  • " + H(Text(s)) + "
  • ")) + "
"; + public static string Packet(ReadinessEvidenceManifestV1 packet) => Page("ReadinessPacketReport", "

" + H(Text("CallId")) + ": " + H(packet.CallId) + "

" + H(packet.CoverageStartUtc.ToString("u")) + " — " + H(packet.CoverageEndUtc.ToString("u")) + "

" + H(Text("PacketMethod")) + "

" + H(Text("DispatchedUnits")) + "

    " + string.Concat(packet.Units.Select(u => "
  • " + H(u.Name) + " (#" + H(u.UnitId) + "; " + H(u.DispatchedUtc.ToString("u")) + ")
  • ")) + "

" + H(Text("IssuedEquipment")) + "

    " + string.Concat(packet.Assets.Select(a => "
  • " + H(a.Name) + " (" + H(a.AssetId) + ")
  • ")) + "
" + Entries(packet.Checklists) + Unavailable(packet.UnavailableSources)); + public static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + public static void EnsureStillAuthorized(ReadinessEvidenceManifestV1 captured, ReadinessEvidenceManifestV1 current) + { + if (captured.DepartmentId != current.DepartmentId || captured.CallId != current.CallId + || captured.Checklists.Any(e => !current.Checklists.Any(c => c.OccurrenceId == e.OccurrenceId)) + || captured.Units.Any(u => !current.Units.Any(c => c.UnitId == u.UnitId)) + || captured.Assets.Any(a => !current.Assets.Any(c => c.AssetId == a.AssetId && c.SourceId == a.SourceId))) + throw new ChecklistException(403, "The request could not be completed."); + } + public static ReadinessEvidencePackage Package(ReadinessEvidenceManifestV1 manifest, IPdfProvider pdf) + { + var json = JsonConvert.SerializeObject(manifest, Formatting.None, new JsonSerializerSettings { DateTimeZoneHandling = DateTimeZoneHandling.Utc, Culture = CultureInfo.InvariantCulture }); + var bytes = pdf.ConvertHtmlToPdf(Packet(manifest)); + if (bytes == null || bytes.Length < 5 || Encoding.ASCII.GetString(bytes, 0, 5) != "%PDF-") throw new InvalidOperationException("Checklist PDF generation failed."); + if (bytes.Length > 16 * 1024 * 1024) throw new ChecklistException(400, "ReportTooLarge"); + return new ReadinessEvidencePackage { ManifestJson = json, ManifestSha256 = Sha256(Encoding.UTF8.GetBytes(json)), Pdf = bytes, PdfSha256 = Sha256(bytes) }; + } + private static string CsvCell(object value) + { + var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; + // CSV quoting alone does not stop spreadsheet formula execution, including leading whitespace. + if (text.TrimStart().FirstOrDefault() is '=' or '+' or '-' or '@' || text.StartsWith('\t') || text.StartsWith('\r') || text.StartsWith('\n')) text = "'" + text; + return "\"" + text.Replace("\"", "\"\"") + "\""; + } + public static byte[] Csv(ChecklistComplianceSummary report) + { + var csv = new StringBuilder(string.Join(",", new[] { "Target", "TargetType", "ExpectedChecks", "CompletedChecks", "OnTimeChecks", "MissedChecks", "ExcusedChecks", "CompletionRate" }.Select(k => CsvCell(Text(k)))) + "\r\n"); + foreach (var g in report.Groups) csv.AppendLine(string.Join(",", new object[] { g.Target.Name, Text(g.Target.Type.ToString()), g.Expected, g.Completed, g.OnTime, g.Missed, g.Skipped, g.CompletionRate }.Select(CsvCell))); + return new UTF8Encoding(true).GetPreamble().Concat(Encoding.UTF8.GetBytes(csv.ToString())).ToArray(); + } + } +} diff --git a/Core/Resgrid.Services/ChecklistReporting.cs b/Core/Resgrid.Services/ChecklistReporting.cs new file mode 100644 index 000000000..bf6b4bfe4 --- /dev/null +++ b/Core/Resgrid.Services/ChecklistReporting.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Checklists; + +namespace Resgrid.Services +{ + public partial class ChecklistsService + { + private static void ReportRange(ChecklistReportQuery query) + { + if (query == null || query.FromUtc == default || query.UntilUtc <= query.FromUtc || query.UntilUtc - query.FromUtc > TimeSpan.FromDays(93) + || query.FromUtc.Kind == DateTimeKind.Local || query.UntilUtc.Kind == DateTimeKind.Local + || query.TargetType.HasValue && !Enum.IsDefined(query.TargetType.Value) || query.TargetId != null && (!query.TargetType.HasValue || query.TargetId.Length > 128)) + throw new ChecklistException(400, "ReportRangeInvalid"); + } + public async Task GetComplianceSummaryAsync(ChecklistActor actor, ChecklistReportQuery query) + { + await RequireWriteAsync(actor); ReportRange(query); + var asOf = _clock.GetUtcNow().UtcDateTime; + var summary = new ChecklistComplianceSummary { FromUtc = query.FromUtc, UntilUtc = query.UntilUtc, AsOfUtc = asOf, TargetType = query.TargetType, TargetId = query.TargetId }; + summary.Entries = await ReportEntriesAsync(actor, query, asOf, summary.UnavailableSources); + summary.Groups = summary.Entries.GroupBy(e => (e.Target.Type, e.Target.Id)).Select(g => new ChecklistComplianceGroup + { + Target = g.Last().Target, Expected = g.Count(e => e.Expected), Completed = g.Count(e => e.Expected && e.Completed), + OnTime = g.Count(e => e.Expected && e.OnTime), Missed = g.Count(e => e.Missed), Skipped = g.Count(e => e.Skipped) + }).OrderBy(g => g.Target.Type).ThenBy(g => g.Target.Id, StringComparer.Ordinal).ToList(); + summary.Trend = summary.Entries.Where(e => e.Expected).GroupBy(e => e.DueUtc.Value.Date) + .Select(g => new ChecklistMissedTrend { DayUtc = g.Key, Expected = g.Count(), Missed = g.Count(e => e.Missed) }).OrderBy(g => g.DayUtc).ToList(); + await RequireWriteAsync(actor); + return summary; + } + public async Task> GetEntityChecklistHistoryAsync(ChecklistActor actor, ChecklistTargetType entityType, string entityId, DateTime fromUtc, DateTime untilUtc) + { + var query = new ChecklistReportQuery { FromUtc = fromUtc, UntilUtc = untilUtc, TargetType = entityType, TargetId = entityId }; + await RequireWriteAsync(actor); ReportRange(query); + if (string.IsNullOrWhiteSpace(entityId)) throw new ChecklistException(400, "ReportRangeInvalid"); + return await ReportEntriesAsync(actor, query, _clock.GetUtcNow().UtcDateTime, new List()); + } + private async Task> ReportEntriesAsync(ChecklistActor actor, ChecklistReportQuery query, DateTime asOf, List unavailable, + HashSet<(ChecklistTargetType, string)> targets = null) + { + var result = new List(); + var versions = new Dictionary(); + var schedules = new Dictionary(); + for (var skip = 0; ; skip += 500) + { + var page = await _store.ReportOccurrencesAsync(actor.DepartmentId, query.FromUtc, query.UntilUtc, skip); + if (skip + page.Count > 10000) throw new ChecklistException(400, "ReportTooLarge"); + foreach (var occurrence in page) + { + var type = (ChecklistTargetType)occurrence.TargetType; + if (occurrence.DepartmentId != actor.DepartmentId || query.TargetType.HasValue && query.TargetType != type || query.TargetId != null && query.TargetId != occurrence.TargetId + || targets != null && !targets.Contains((type, occurrence.TargetId))) continue; + var completion = occurrence.CompletionId == null ? null : await _store.GetAsync(actor.DepartmentId, occurrence.CompletionId); + ChecklistSchedule schedule = null; + if (occurrence.ScheduleId != null && !schedules.TryGetValue(occurrence.ScheduleId, out schedule)) + schedules[occurrence.ScheduleId] = schedule = await _store.GetAsync(actor.DepartmentId, occurrence.ScheduleId); + // Completed checks retain their captured group scope; unstarted checks use the schedule scope. + // Permission to perform a check alone never grants access to its historical results. + var permissionRow = completion ?? new ChecklistCompletion { DepartmentId = actor.DepartmentId, TargetType = occurrence.TargetType, TargetId = occurrence.TargetId, TargetGroupId = schedule?.TargetGroupId }; + if (!await _authorization.CanReadAsync(actor, permissionRow)) { AddUnavailable(unavailable, "RestrictedChecklistResults"); continue; } + if (occurrence.State == (int)ChecklistOccurrenceState.Cancelled || occurrence.State == (int)ChecklistOccurrenceState.Skipped) + { + if (occurrence.UpdatedOn > asOf) { AddUnavailable(unavailable, "LifecycleHistoryUnavailable"); continue; } + if (occurrence.State == (int)ChecklistOccurrenceState.Cancelled) continue; + } + await RevealAsync(actor, occurrence); + if (!versions.TryGetValue(occurrence.VersionId, out var version)) + versions[occurrence.VersionId] = version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, occurrence.VersionId)); + if (completion != null) await RevealAsync(actor, completion); + var completed = completion?.State == (int)ChecklistRunState.Submitted && completion.SubmittedOn.HasValue + && completion.SubmittedOn <= asOf && (!completion.WitnessedOn.HasValue || completion.WitnessedOn <= asOf); + var finalOn = completed ? (completion.WitnessedOn ?? completion.SubmittedOn) : null; + var skipped = occurrence.State == (int)ChecklistOccurrenceState.Skipped; + var expected = occurrence.ScheduleId != null && occurrence.WindowEndUtc.HasValue && occurrence.WindowEndUtc <= asOf && !skipped; + var onTime = completed && finalOn <= occurrence.WindowEndUtc; + result.Add(new ChecklistReportEntry + { + DefinitionId = occurrence.ParentId, VersionId = version.Id, Version = version.Version, OccurrenceId = occurrence.Id, OccurrenceRevision = occurrence.Revision, + CompletionId = completion?.Id, CompletionRevision = completion?.Revision, Name = Decode(version.Content).Name, + Target = await ReportTargetAsync(actor, occurrence), StartUtc = occurrence.PeriodStartUtc ?? occurrence.CreatedOn, DueUtc = occurrence.WindowEndUtc, + SubmittedUtc = finalOn, Scheduled = occurrence.ScheduleId != null, Expected = expected, Completed = completed, OnTime = onTime, + Missed = expected && !onTime, Skipped = skipped, Passed = completed ? completion.Passed : null, Score = completed ? completion.Score : null + }); + } + if (page.Count < 500) break; + } + return result.OrderBy(e => e.StartUtc).ThenBy(e => e.OccurrenceId, StringComparer.Ordinal).ToList(); + } + private async Task ReportTargetAsync(ChecklistActor actor, ChecklistOccurrence occurrence) + { + var snapshot = Decode(occurrence.Content); + if (snapshot?.Id != null) return snapshot; + try { return await _authorization.TargetAsync(actor, (ChecklistTargetType)occurrence.TargetType, occurrence.TargetId); } + catch (ChecklistException ex) when (ex.StatusCode == 404) { return new ChecklistTarget { Type = (ChecklistTargetType)occurrence.TargetType, Id = occurrence.TargetId, Name = occurrence.TargetId }; } + } + + private static void AddUnavailable(List list, string value) { if (!list.Contains(value)) list.Add(value); } + public async Task GetReadinessPacketForCallAsync(ChecklistActor actor, int callId, int lookbackDays = 30) + { + await RequireWriteAsync(actor); + if (lookbackDays < 1 || lookbackDays > 93) throw new ChecklistException(400, "ReportRangeInvalid"); + if (callId <= 0 || _reportCalls == null || _reportAuthorization == null) throw new ChecklistException(404, "ReadinessCallUnavailable"); + var call = await _reportCalls.Value.GetCallByIdAsync(callId, true); + if (call == null || call.DepartmentId != actor.DepartmentId || call.IsDeleted || !await _reportAuthorization.Value.CanUserViewCallAsync(actor.UserId, callId)) + throw new ChecklistException(404, "ReadinessCallUnavailable"); + call = await _reportCalls.Value.PopulateCallData(call, false, false, false, false, true, false, false, false, false); + var at = DateTime.SpecifyKind(call.LoggedOn, DateTimeKind.Utc); + var packet = new ReadinessEvidenceManifestV1 { DepartmentId = actor.DepartmentId, CallId = callId, CallUtc = at, GeneratedUtc = _clock.GetUtcNow().UtcDateTime, + CoverageStartUtc = at.AddDays(-lookbackDays), CoverageEndUtc = at }; + // Call narrative/address/contact fields deliberately remain with Calls. + var targets = new HashSet<(ChecklistTargetType, string)>(); + foreach (var dispatch in (call.UnitDispatches ?? Array.Empty()).Where(d => d.CallId == callId).OrderBy(d => d.UnitId)) + { + if (packet.Units.Count >= 250) throw new ChecklistException(400, "ReportTooLarge"); + try + { + var target = await _authorization.TargetAsync(actor, ChecklistTargetType.Unit, dispatch.UnitId.ToString()); + packet.Units.Add(new ReadinessUnitSnapshot { UnitId = dispatch.UnitId, Name = target.Name, DispatchId = dispatch.CallDispatchUnitId, DispatchedUtc = dispatch.DispatchedOn }); + targets.Add((ChecklistTargetType.Unit, dispatch.UnitId.ToString())); + } + catch (ChecklistException ex) when (ex.StatusCode == 404) { AddUnavailable(packet.UnavailableSources, "RestrictedUnits"); } + } + // Unit names are current labels, explicitly distinguished from immutable dispatch provenance. + AddUnavailable(packet.UnavailableSources, "HistoricalUnitNamesUnavailable"); + foreach (var contractor in new[] { false, true }) + { + var assets = _historicalAssets == null ? null : await _historicalAssets.AtCallAsync(actor, callId, at, packet.Units.Select(u => u.UnitId).Distinct().ToArray(), contractor); + if (assets == null) { AddUnavailable(packet.UnavailableSources, contractor ? "ContractorEquipmentUnavailable" : "HistoricalInventoryUnavailable"); continue; } + if (assets.Count + packet.Assets.Count > 1000) throw new ChecklistException(400, "ReportTooLarge"); + foreach (var asset in assets) + { + if (asset.DepartmentId != actor.DepartmentId || (contractor ? asset.CallId != callId : !asset.UnitId.HasValue || !packet.Units.Any(u => u.UnitId == asset.UnitId)) || asset.IssuedUtc > at || asset.ReturnedUtc <= at + || string.IsNullOrWhiteSpace(asset.AssetId) || string.IsNullOrWhiteSpace(asset.SourceId) || string.IsNullOrWhiteSpace(asset.SourceVersion)) + throw new ChecklistException(409, "HistoricalAssetInvalid"); + asset.SourceSubsystem = contractor ? "ContractorBilling" : "Inventory"; + packet.Assets.Add(asset); targets.Add((ChecklistTargetType.InventoryAsset, asset.AssetId)); + } + } + AddUnavailable(packet.UnavailableSources, "WorkOrdersUnavailable"); + packet.Checklists = await ReportEntriesAsync(actor, new ChecklistReportQuery { FromUtc = packet.CoverageStartUtc, UntilUtc = at.AddTicks(1) }, at, packet.UnavailableSources, targets); + await RequireWriteAsync(actor); + if (!await _reportAuthorization.Value.CanUserViewCallAsync(actor.UserId, callId)) throw new ChecklistException(404, "ReadinessCallUnavailable"); + return packet; + } + } +} diff --git a/Core/Resgrid.Services/ChecklistScheduledReportService.cs b/Core/Resgrid.Services/ChecklistScheduledReportService.cs new file mode 100644 index 000000000..9a6004809 --- /dev/null +++ b/Core/Resgrid.Services/ChecklistScheduledReportService.cs @@ -0,0 +1,50 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public sealed class ChecklistScheduledReportService : IChecklistScheduledReportService + { + private readonly IScheduledTasksService _tasks; + private readonly IChecklistAuthorizationService _authorization; + private readonly IReadinessAccessService _access; + private readonly IUsersService _users; + private readonly IUserProfileService _profiles; + private readonly IPdfProvider _pdf; + public ChecklistScheduledReportService(IScheduledTasksService tasks, IChecklistAuthorizationService authorization, + IReadinessAccessService access, IUsersService users, IUserProfileService profiles, IPdfProvider pdf) + { _tasks = tasks; _authorization = authorization; _access = access; _users = users; _profiles = profiles; _pdf = pdf; } + public async Task BuildAsync(ScheduledTask queuedTask) + { + var task = await _tasks.GetScheduledTaskByIdAsync(queuedTask.ScheduledTaskId); + if (task == null || !task.Active || task.DepartmentId != queuedTask.DepartmentId || task.UserId != queuedTask.UserId || task.Data != queuedTask.Data + || task.TaskType != (int)TaskTypes.ReportDelivery || task.Data != "4" && task.Data != "5") throw new ChecklistException(403, "The request could not be completed."); + var actor = new ChecklistActor { DepartmentId = task.DepartmentId, UserId = task.UserId }; + await _authorization.RequireMemberAsync(actor); + if (!await _access.CanUseChecklistsAsync(task.DepartmentId)) throw new ChecklistException(404, "Checklists are disabled for this department."); + var user = _users.GetUserById(task.UserId, true); + if (string.IsNullOrWhiteSpace(user?.Email)) throw new ChecklistException(403, "The request could not be completed."); + var language = (await _profiles.GetProfileByUserIdAsync(task.UserId))?.Language; + var original = CultureInfo.CurrentCulture; var originalUi = CultureInfo.CurrentUICulture; + try + { + var culture = CultureInfo.GetCultureInfo(Resgrid.Localization.SupportedLocales.GetSupportedCultures().Contains(language) ? language : "en"); + CultureInfo.CurrentCulture = culture; CultureInfo.CurrentUICulture = culture; + // Unattended delivery carries only a static notice, never results, identities or outcomes. + // The report is opened through the authenticated link using current permissions and ADP. + var bytes = _pdf.ConvertHtmlToPdf(ChecklistReportDocuments.Locked()); + if (bytes == null || bytes.Length < 5 || System.Text.Encoding.ASCII.GetString(bytes, 0, 5) != "%PDF-") throw new InvalidOperationException("Checklist PDF generation failed."); + await _authorization.RequireMemberAsync(actor); + if (!await _access.CanUseChecklistsAsync(task.DepartmentId)) throw new ChecklistException(404, "Checklists are disabled for this department."); + return new EmailNotification { To = user.Email, Subject = ChecklistReportDocuments.Text(task.Data == "5" ? "ChecklistMissedReport" : "ChecklistComplianceReport"), AttachmentName = "checklists-" + DateTime.UtcNow.ToString("yyyy-MM-dd") + ".pdf", AttachmentData = bytes }; + } + finally { CultureInfo.CurrentCulture = original; CultureInfo.CurrentUICulture = originalUi; } + } + } +} diff --git a/Core/Resgrid.Services/ChecklistsScheduling.cs b/Core/Resgrid.Services/ChecklistsScheduling.cs index cab802e30..c049a63c5 100644 --- a/Core/Resgrid.Services/ChecklistsScheduling.cs +++ b/Core/Resgrid.Services/ChecklistsScheduling.cs @@ -69,29 +69,40 @@ public async Task SaveScheduleAsync(ChecklistActor actor, ChecklistSched return row.Id; }, accessFence: true); } - public async Task> DueAsync(ChecklistActor actor, int page = 0) + public async Task> DueAsync(ChecklistActor actor, int page = 0, bool includeNext = false) { - await _authorization.RequireMemberAsync(actor); if (page < 0 || page > 10000) throw new ChecklistException(400, "ScheduleValidation"); + await _authorization.RequireMemberAsync(actor); if (page < 0 || page > 100) throw new ChecklistException(400, "ScheduleValidation"); var enabled = await _access.CanUseChecklistsAsync(actor.DepartmentId); var manage = await CanManageAsync(actor); var views = new List(); - foreach (var occurrence in await _store.DueOccurrencesAsync(actor.DepartmentId, _clock.GetUtcNow().UtcDateTime.AddDays(7), page * 50)) + var remaining = page * 50; var take = includeNext ? 51 : 50; + var until = _clock.GetUtcNow().UtcDateTime.AddDays(7); + for (var skip = 0; ; skip += 50) { - ChecklistTarget target; - try { target = await _authorization.TargetAsync(actor, (ChecklistTargetType)occurrence.TargetType, occurrence.TargetId); } - catch (ChecklistException ex) when (ex.StatusCode == 404) { continue; } - var schedule = await _store.GetAsync(actor.DepartmentId, occurrence.ScheduleId); - if (schedule == null) continue; - var assigned = await CanPerformScheduleAsync(actor, schedule); - // The response omits stored Content/envelopes. Names come through the attended read boundary. - await RevealAsync(actor, schedule); - var metadata = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(occurrence)); metadata.Content = null; - views.Add(new ChecklistOccurrenceView { Occurrence = metadata, Name = Decode(schedule.Content).Name, Target = target, - CanStart = enabled && assigned && schedule.IsActive && !schedule.IsSuspended && occurrence.PeriodStartUtc <= _clock.GetUtcNow().UtcDateTime, CanSkip = enabled && manage && (occurrence.State == 3 || occurrence.State == 4) && await _store.GetAsync(actor.DepartmentId, occurrence.CompletionId) == null }); + var rows = await _store.DueOccurrencesAsync(actor.DepartmentId, until, skip); + foreach (var occurrence in rows) + { + ChecklistTarget target; + try { target = await _authorization.TargetAsync(actor, (ChecklistTargetType)occurrence.TargetType, occurrence.TargetId); } + catch (ChecklistException ex) when (ex.StatusCode == 404) { continue; } + var schedule = await _store.GetAsync(actor.DepartmentId, occurrence.ScheduleId); + if (schedule == null) continue; + if (remaining > 0) { remaining--; continue; } + var assigned = await CanPerformScheduleAsync(actor, schedule); + // The response omits stored Content/envelopes. Names come through the attended read boundary. + await RevealAsync(actor, schedule); + var metadata = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(occurrence)); metadata.Content = null; + views.Add(new ChecklistOccurrenceView { Occurrence = metadata, Name = Decode(schedule.Content).Name, Target = target, + CanStart = enabled && assigned && schedule.IsActive && !schedule.IsSuspended && occurrence.PeriodStartUtc <= _clock.GetUtcNow().UtcDateTime, CanSkip = enabled && manage && (occurrence.State == 3 || occurrence.State == 4) && await _store.GetAsync(actor.DepartmentId, occurrence.CompletionId) == null }); + if (views.Count == take) return views; + } + if (rows.Count < 50) return views; } - return views; } - public async Task StartOccurrenceAsync(ChecklistActor actor, string occurrenceId) + public Task StartOccurrenceAsync(ChecklistActor actor, string occurrenceId) => StartOccurrenceWithIdAsync(actor, occurrenceId, null); + public async Task StartOccurrenceWithIdAsync(ChecklistActor actor, string occurrenceId, string completionId) { Id(occurrenceId); await RequireWriteAsync(actor); + occurrenceId = Guid.Parse(occurrenceId).ToString(); + if (completionId != null) { Id(completionId); completionId = Guid.Parse(completionId).ToString(); } return await TransactionAsync(actor, async events => { var row = await _store.GetAsync(actor.DepartmentId, occurrenceId); @@ -99,7 +110,17 @@ public async Task StartOccurrenceAsync(ChecklistActor actor, string occu var existing = await _store.GetAsync(actor.DepartmentId, row.CompletionId); var schedule = await _store.GetAsync(actor.DepartmentId, row.ScheduleId); if (!await CanPerformScheduleAsync(actor, schedule)) throw new ChecklistException(403, "AssignmentPermission"); - if (existing != null) { await RequireRunReadAsync(actor, existing); return existing.Id; } + if (existing != null) + { + await RequireRunReadAsync(actor, existing); + if (completionId != null && (completionId != existing.Id || existing.CreatedBy != actor.UserId)) throw new ChecklistException(409, "Run identifier is already in use."); + await RevealAsync(actor, existing); return existing.Id; + } + if (completionId != null) + { + if (await _store.GetAsync(actor.DepartmentId, completionId) != null) throw new ChecklistException(409, "Run identifier is already in use."); + row.CompletionId = completionId; + } var definition = await _store.GetAsync(actor.DepartmentId, row.ParentId); if (schedule?.IsActive != true || schedule.IsSuspended || definition == null || definition.Retired || definition.DeletedOn.HasValue) throw new ChecklistException(409, "OccurrenceUnavailable"); var target = await _authorization.TargetAsync(actor, (ChecklistTargetType)row.TargetType, row.TargetId); @@ -145,6 +166,7 @@ private async Task WorkerWriteAsync(T row, bool insert, CancellationToken ct) var result = await _write.Value.PrepareRecordsEntityWriteAsync(row.DepartmentId, row, (T)null, row.Id, ChecklistTables.Fields(), () => row.IsProtected = true, null, null, true, ct); if (result?.Success != true || result.IsProtected && HasPlaintextFields(row)) throw new InvalidOperationException("Checklist scheduling requires an available ADP catalog and write broker."); await _store.WriteAsync(row, insert, ct); + if (row is ChecklistOccurrence) _refreshRow = row; } private async Task WorkerAuditAsync(ChecklistRow row, AuditLogTypes type, DateTime now, CancellationToken ct) { @@ -168,7 +190,7 @@ public async Task SweepSchedulesAsync(DateTime utc var schedules = await _store.ActiveSchedulesAsync(department, afterSchedule, ct); if (schedules.Count == 0) break; foreach (var candidate in schedules) { - ct.ThrowIfCancellationRequested(); var events = new List(); var generated = 0; var missed = 0; + ct.ThrowIfCancellationRequested(); var events = new List(); var generated = 0; var missed = 0; _refreshRow = null; try { await _uow.CreateOrGetConnectionAsync(ct); await _store.LockDepartmentAsync(department, ct); @@ -224,11 +246,12 @@ public async Task SweepSchedulesAsync(DateTime utc } await WorkerWriteAsync(schedule, false, ct); } + await RefreshEventAsync(events); _uow.CommitChanges(); result.Generated += generated; result.Missed += missed; await _outbox.DispatchAfterCommitAsync(events, ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { _uow.DiscardChanges(); throw; } - catch { _uow.DiscardChanges(); result.Errors++; Resgrid.Framework.Logging.LogError($"Checklist scheduling failed for department {department}, schedule {candidate.Id}."); } + catch (Exception ex) { _uow.DiscardChanges(); result.Errors++; Resgrid.Framework.Logging.LogError($"Checklist scheduling failed for department {department}, schedule {candidate.Id}: {ex.GetType().FullName}."); } } afterSchedule = schedules.Last().Id; } diff --git a/Core/Resgrid.Services/ChecklistsService.cs b/Core/Resgrid.Services/ChecklistsService.cs index 843feb8bc..9bff8c7f3 100644 --- a/Core/Resgrid.Services/ChecklistsService.cs +++ b/Core/Resgrid.Services/ChecklistsService.cs @@ -20,6 +20,7 @@ namespace Resgrid.Services public partial class ChecklistsService : IChecklistsService { private readonly IChecklistRepository _store; + private ChecklistRow _refreshRow; private readonly IChecklistAuthorizationService _authorization; private readonly IReadinessAccessService _access; private readonly IUnitOfWork _uow; @@ -31,10 +32,14 @@ public partial class ChecklistsService : IChecklistsService private readonly TimeProvider _clock; private readonly IChecklistAssignmentService _assignments; private readonly IChecklistAssetSource _assets; + private readonly Lazy _reportCalls; + private readonly Lazy _reportAuthorization; + private readonly IChecklistHistoricalAssetSource _historicalAssets; public ChecklistsService(IChecklistRepository store, IChecklistAuthorizationService authorization, IReadinessAccessService access, IUnitOfWork uow, IAuditLogsRepository audit, IDomainEventOutboxService outbox, Lazy read, - Lazy write, IRecordAttachmentScanner scanner, TimeProvider clock = null, IChecklistAssignmentService assignments = null, IChecklistAssetSource assets = null) - { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; _clock = clock ?? TimeProvider.System; _assignments = assignments; _assets = assets; } + Lazy write, IRecordAttachmentScanner scanner, TimeProvider clock = null, IChecklistAssignmentService assignments = null, IChecklistAssetSource assets = null, + Lazy reportCalls = null, Lazy reportAuthorization = null, IChecklistHistoricalAssetSource historicalAssets = null) + { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; _clock = clock ?? TimeProvider.System; _assignments = assignments; _assets = assets; _reportCalls = reportCalls; _reportAuthorization = reportAuthorization; _historicalAssets = historicalAssets; } public Task CanManageAsync(ChecklistActor actor) => _authorization.CanManageAsync(actor); private async Task RequireWriteAsync(ChecklistActor actor, bool manage = false) @@ -65,7 +70,7 @@ private async Task SealAsync(ChecklistActor actor, T row) where T : Checklist if (result?.Success != true || result.IsProtected && HasPlaintextFields(row)) throw new ChecklistException(403, "Protected data could not be saved. Unlock it and retry."); } private async Task PersistAsync(ChecklistActor actor, T row, bool insert) where T : ChecklistRow - { row.UpdatedOn = DateTime.UtcNow; await SealAsync(actor, row); await _store.WriteAsync(row, insert); } + { row.UpdatedOn = DateTime.UtcNow; await SealAsync(actor, row); await _store.WriteAsync(row, insert); _refreshRow = row; } private async Task AuditAsync(ChecklistActor actor, ChecklistRow row, AuditLogTypes type, object detail = null) { var data = JsonConvert.SerializeObject(new { row.Id, row.Revision, Detail = detail }); @@ -83,7 +88,7 @@ private async Task AuditAsync(ChecklistActor actor, ChecklistRow row, AuditLogTy private async Task TransactionAsync(ChecklistActor actor, Func, Task> action, bool accessFence = false) { if (_uow.Transaction != null) throw new InvalidOperationException("Checklist commands own their transaction."); - var events = new List(); T result; + var events = new List(); T result; _refreshRow = null; try { await _uow.CreateOrGetConnectionAsync(CancellationToken.None); @@ -91,12 +96,24 @@ private async Task TransactionAsync(ChecklistActor actor, Func, await _store.LockDepartmentAsync(actor.DepartmentId); await RequireWriteAsync(actor); result = await action(events); + await RefreshEventAsync(events); _uow.CommitChanges(); } catch { _uow.DiscardChanges(); throw; } await _outbox.DispatchAfterCommitAsync(events); return result; } + private async Task RefreshEventAsync(List events) + { + if (_refreshRow == null || events.Count > 0) return; + var entry = await _outbox.EnqueueAsync(_refreshRow.DepartmentId, "Checklists", new DomainEventEnvelope + { + EventName = "ChecklistRefreshRequested", AggregateType = _refreshRow.GetType().Name, AggregateId = _refreshRow.Id, + AggregateVersion = _refreshRow.Revision, Payload = new { Revision = _refreshRow.Revision }, OccurredOn = _clock.GetUtcNow().UtcDateTime + }); + events.Add(entry.DomainEventOutboxId); + } + public async Task> ListAsync(ChecklistActor actor, int page = 0, bool includeNext = false) { await _authorization.RequireMemberAsync(actor); @@ -181,20 +198,27 @@ await TransactionAsync(actor, async events => } public async Task> TargetsAsync(ChecklistActor actor, ChecklistTargetType type) { await RequireWriteAsync(actor); return await _authorization.TargetsAsync(actor, type); } - public async Task StartAsync(ChecklistActor actor, string definitionId, string targetId, string completionId) + public Task StartAsync(ChecklistActor actor, string definitionId, string targetId, string completionId) + => StartPinnedAsync(actor, definitionId, null, targetId, completionId); + public async Task StartPinnedAsync(ChecklistActor actor, string definitionId, string versionId, string targetId, string completionId) { Id(definitionId); Id(completionId); await RequireWriteAsync(actor); + definitionId = Guid.Parse(definitionId).ToString(); completionId = Guid.Parse(completionId).ToString(); + if (versionId != null) { Id(versionId); versionId = Guid.Parse(versionId).ToString(); } return await TransactionAsync(actor, async events => { var existing = await _store.GetAsync(actor.DepartmentId, completionId); if (existing != null) { - if (existing.CreatedBy != actor.UserId || existing.ParentId != definitionId || existing.TargetId != targetId) throw new ChecklistException(409, "Run identifier is already in use."); + if (existing.CreatedBy != actor.UserId || existing.ParentId != definitionId || existing.TargetId != targetId || versionId != null && existing.VersionId != versionId || (await _store.GetAsync(actor.DepartmentId, existing.OccurrenceId))?.ScheduleId != null) throw new ChecklistException(409, "Run identifier is already in use."); + await _authorization.TargetAsync(actor, (ChecklistTargetType)existing.TargetType, existing.TargetId); + await RevealAsync(actor, existing); return existing.Id; } var definition = await _store.GetAsync(actor.DepartmentId, definitionId); if (definition == null || definition.Retired || definition.DeletedOn.HasValue || definition.CurrentVersionId == null) throw new ChecklistException(409, "Publish an active checklist before starting a run."); - var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, definition.CurrentVersionId)); + var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, versionId ?? definition.CurrentVersionId)); + if (version.ParentId != definition.Id) throw new ChecklistException(409, "Checklist version is unavailable."); var form = Decode(version.Content); var target = await _authorization.TargetAsync(actor, form.TargetType, targetId); var occurrence = New(actor, definition.Id); @@ -236,10 +260,11 @@ public async Task GetRunAsync(ChecklistActor actor, string id) public async Task> HistoryAsync(ChecklistActor actor, string definitionId, int page = 0, bool includeNext = false) { Id(definitionId); await _authorization.RequireMemberAsync(actor); - if (page < 0 || page > 10000) throw new ChecklistException(400, "Invalid page."); + if (page < 0 || page > 100) throw new ChecklistException(400, "Invalid page."); + var visible = await _authorization.ReadFilterAsync(actor); var result = new List(); foreach (var row in await ReadPageAsync(actor.DepartmentId, definitionId, page, includeNext, - row => _authorization.CanReadAsync(actor, row))) + visible)) { await RevealAsync(actor, row); var occurrence = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.OccurrenceId)); @@ -276,6 +301,7 @@ private static string SubmissionHash(ChecklistRunInput input, IEnumerable SaveRunAsync(ChecklistActor actor, string id, ChecklistRunInput input, bool submit) { Id(id); await RequireWriteAsync(actor); + if (input == null) throw new ChecklistException(400, "The form content is invalid."); return await TransactionAsync(actor, async events => { var row = await _store.GetAsync(actor.DepartmentId, id); @@ -285,6 +311,9 @@ public async Task SaveRunAsync(ChecklistActor actor, string id, ChecklistRu var evaluation = ChecklistValidation.Evaluate(view.Form, input, Evidence(view.Files), submit); Valid(evaluation.Errors); foreach (var answer in input.Answers) answer.ItemId = Guid.Parse(answer.ItemId).ToString("D"); var hash = SubmissionHash(input, view.Files); + // A lost progress response can be retried without a new audit/revision. A different + // payload still needs the caller's original revision; never silently rebase answers. + if (!submit && row.State == (int)ChecklistRunState.InProgress && input.Revision < row.Revision && hash == SubmissionHash(view.Input, view.Files)) return row.Revision; if (row.State != (int)ChecklistRunState.InProgress) { if (submit && row.SubmissionHash == hash) return row.Revision; @@ -349,12 +378,18 @@ await TransactionAsync(actor, async events => return true; }); } - public async Task AddFileAsync(ChecklistActor actor, string id, string itemId, string fileName, string contentType, byte[] data) + public Task AddFileAsync(ChecklistActor actor, string id, string itemId, string fileName, string contentType, byte[] data) + => AddFileCoreAsync(actor, id, itemId, null, fileName, contentType, data); + public Task AddFileAtRevisionAsync(ChecklistActor actor, string id, string itemId, int revision, string fileName, string contentType, byte[] data) + => AddFileCoreAsync(actor, id, itemId, revision, fileName, contentType, data); + private async Task AddFileCoreAsync(ChecklistActor actor, string id, string itemId, int? revision, string fileName, string contentType, byte[] data) { Id(id); Id(itemId); await RequireWriteAsync(actor); // Bound and authorize before decoding or calling a scanner. The locked check repeats after scanning. var run = await _store.GetAsync(actor.DepartmentId, id); if (run?.CreatedBy != actor.UserId || run.State != (int)ChecklistRunState.InProgress) throw new ChecklistException(404, "This run cannot accept evidence."); + await RequireRunAssignmentAsync(actor, run); + await RequireRunReadAsync(actor, run); await RevealAsync(actor, run); if (data == null || data.Length == 0 || data.Length > 10 * 1024 * 1024 || string.IsNullOrWhiteSpace(fileName) || fileName.Length > 200 || contentType != "image/png" && contentType != "image/jpeg") throw new ChecklistException(400, "Evidence must be a PNG or JPEG up to 10 MB."); AttachmentHygieneResult clean; try @@ -376,7 +411,14 @@ await TransactionAsync(actor, async events => var view = await RunViewAsync(actor, row); if (!view.Form.Sections.SelectMany(s => s.Items).Any(i => i.Id == itemId)) throw new ChecklistException(404, "The evidence item does not belong to this version."); var checksum = Convert.ToHexString(SHA256.HashData(clean.Data)); - if (view.Files.Any(f => f.ItemId == itemId && f.Sha256 == checksum)) return true; + if (view.Files.Any(f => f.ItemId == itemId && f.Sha256 == checksum)) + { + // The one-step lost upload receipt is safe to replay. A later answer/evidence + // edit must not be hidden by returning its revision to an older offline client. + if (revision.HasValue && row.Revision != revision.Value + 1) Revision(row, revision.Value); + return true; + } + if (revision.HasValue) Revision(row, revision.Value); if (view.Files.Count >= 500 || view.Files.Count(f => f.ItemId == itemId) >= 3) throw new ChecklistException(400, "Use at most three evidence images per item."); var file = New(actor, row.Id); file.ItemId = itemId; file.ContentType = clean.ContentType; file.Size = clean.Data.Length; file.Sha256 = checksum; file.Data = clean.Data; file.ScanState = (int)scan.State; file.Content = clean.FileName; @@ -399,7 +441,9 @@ public async Task GetFileAsync(ChecklistActor actor, st if (Convert.ToHexString(SHA256.HashData(file.Data)) != file.Sha256) throw new ChecklistException(409, "Evidence failed its integrity check."); return file; } - public async Task DeleteFileAsync(ChecklistActor actor, string id) + public Task DeleteFileAsync(ChecklistActor actor, string id) => DeleteFileCoreAsync(actor, id, null); + public Task DeleteFileAtRevisionAsync(ChecklistActor actor, string id, int revision) => DeleteFileCoreAsync(actor, id, revision); + private async Task DeleteFileCoreAsync(ChecklistActor actor, string id, int? revision) { Id(id); await RequireWriteAsync(actor); await TransactionAsync(actor, async events => @@ -408,6 +452,7 @@ await TransactionAsync(actor, async events => if (file == null) throw new ChecklistException(404, "Evidence is unavailable."); var row = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, file.ParentId)); if (row.CreatedBy != actor.UserId || row.State != (int)ChecklistRunState.InProgress) throw new ChecklistException(409, "Submitted evidence is immutable."); + if (revision.HasValue) Revision(row, revision.Value); await RequireRunAssignmentAsync(actor, row); await _store.DeleteFileAsync(actor.DepartmentId, id); row.Revision++; await PersistAsync(actor, row, false); await AuditAsync(actor, file, AuditLogTypes.ChecklistFileRemoved, new { CompletionId = row.Id }); return true; diff --git a/Core/Resgrid.Services/FeatureFlagMutations.cs b/Core/Resgrid.Services/FeatureFlagMutations.cs index fe2b64355..01b09d37e 100644 --- a/Core/Resgrid.Services/FeatureFlagMutations.cs +++ b/Core/Resgrid.Services/FeatureFlagMutations.cs @@ -19,21 +19,30 @@ private async Task MutateFlagAsync(Func> action, CancellationToken { if (_mutationObserver == null || _mutationUnit == null) return await action(); if (_mutationActive || _mutationUnit.Transaction != null) throw new InvalidOperationException("Feature flag commands own their transaction."); + T result; try { - _mutationActive = true; await _mutationUnit.CreateOrGetConnectionAsync(ct); - await _mutationObserver.BeforeChangeAsync(null, ct); - // Observe expired/scheduled policy before replacing it, even between worker sweeps. - await _mutationObserver.AfterChangeAsync(async (key, department) => (await EvaluateFreshAsync(key, department)).IsEnabled, ct); - var result = await action(); - await _mutationObserver.AfterChangeAsync(async (key, department) => (await EvaluateFreshAsync(key, department)).IsEnabled, ct); - _mutationUnit.CommitChanges(); _mutationActive = false; - if (_invalidateFlags) await InvalidateFlagCacheAsync(); - foreach (var department in _invalidateOverrides) await InvalidateDepartmentOverrideCacheAsync(department); - foreach (var audit in _committedAudits) audit(); + try + { + _mutationActive = true; await _mutationUnit.CreateOrGetConnectionAsync(ct); + await _mutationObserver.BeforeChangeAsync(null, ct); + // Observe expired/scheduled policy before replacing it, even between worker sweeps. + await _mutationObserver.AfterChangeAsync(async (key, department) => (await EvaluateFreshAsync(key, department)).IsEnabled, ct); + result = await action(); + await _mutationObserver.AfterChangeAsync(async (key, department) => (await EvaluateFreshAsync(key, department)).IsEnabled, ct); + _mutationUnit.CommitChanges(); + } + catch { _mutationUnit.DiscardChanges(); throw; } + _mutationActive = false; + // Cache failures cannot roll back committed writes or suppress their audit publication. + try + { + if (_invalidateFlags) await InvalidateFlagCacheAsync(); + foreach (var department in _invalidateOverrides) await InvalidateDepartmentOverrideCacheAsync(department); + } + finally { foreach (var audit in _committedAudits) audit(); } return result; } - catch { _mutationUnit.DiscardChanges(); throw; } finally { _mutationActive = false; _invalidateFlags = false; _invalidateOverrides.Clear(); _committedAudits.Clear(); } } } diff --git a/Core/Resgrid.Services/FeatureToggleService.cs b/Core/Resgrid.Services/FeatureToggleService.cs index b5033dc8f..a958a38ba 100644 --- a/Core/Resgrid.Services/FeatureToggleService.cs +++ b/Core/Resgrid.Services/FeatureToggleService.cs @@ -930,7 +930,7 @@ private void PublishAudit(int departmentId, string userId, AuditLogTypes type, s } catch (Exception ex) { - Logging.LogException(ex, "Failed to publish feature flag audit event"); + Logging.LogException(ex, $"FeatureToggleService.PublishAudit failed for department {departmentId}, user {userId}, audit type {type}."); } } diff --git a/Core/Resgrid.Services/GdprDataExportService.cs b/Core/Resgrid.Services/GdprDataExportService.cs index efe55f229..3895bd22b 100644 --- a/Core/Resgrid.Services/GdprDataExportService.cs +++ b/Core/Resgrid.Services/GdprDataExportService.cs @@ -19,7 +19,7 @@ namespace Resgrid.Services { - public class GdprDataExportService : IGdprDataExportService + public partial class GdprDataExportService : IGdprDataExportService { private readonly IGdprDataExportRequestRepository _repository; private readonly IUserProfileService _userProfileService; @@ -53,9 +53,10 @@ public GdprDataExportService( ICertificationService certificationService, ITrainingService trainingService, IShiftsService shiftsService, - IEmailService emailService, IChecklistRepository checklists, Lazy checklistProtection, IChecklistReminderRepository checklistReminders) + IEmailService emailService, IChecklistRepository checklists, Lazy checklistProtection, IChecklistReminderRepository checklistReminders, IWorkOrderRepository workOrders = null) { _repository = repository; + _workOrders = workOrders; _checklistReminders = checklistReminders ?? throw new ArgumentNullException(nameof(checklistReminders)); _userProfileService = userProfileService; _memberSensitiveDataService = memberSensitiveDataService; @@ -187,6 +188,7 @@ private async Task BuildExportZipAsync(string userId, int departmentId) await AddJsonEntry(archive, "trainings.json", await BuildTrainingsDataAsync(userId), ledger); await AddJsonEntry(archive, "shifts.json", await BuildShiftsDataAsync(userId), ledger); await AddJsonEntry(archive, "checklists.json", await BuildChecklistDataAsync(userId, departmentId), ledger); + await AddJsonEntry(archive, "workorders.json", await BuildWorkOrderDataAsync(userId, departmentId), ledger); // Written last, so it can report what every other entry withheld. Only present when // something actually was: a member of an unprotected department gets the archive they @@ -243,11 +245,13 @@ async Task> Children(IReadOnlyCollection parentIds } } var records = new List(); - var relatedOccurrences = new HashSet(); + var witnesses = new List(); for (var skip = 0; ; skip += 100) { - var batch = await _checklists.ListAsync(departmentId, skip: skip); - var completions = batch.Where(c => c.CreatedBy == userId || c.WitnessUserId == userId || c.TargetType == (int)ChecklistTargetType.Personnel && c.TargetId == userId).ToList(); + var batch = await _checklists.ListForMemberAsync(departmentId, userId, skip); + var completions = batch.Where(c => c.CreatedBy == userId || c.TargetType == (int)ChecklistTargetType.Personnel && c.TargetId == userId).ToList(); + witnesses.AddRange(batch.Where(c => c.WitnessUserId == userId && !completions.Contains(c)) + .Select(c => (object)new { CompletionId = c.Id, c.WitnessUserId, c.WitnessedOn })); if (completions.Count > 0) { var ids = completions.Select(c => c.Id).ToArray(); @@ -255,24 +259,22 @@ async Task> Children(IReadOnlyCollection parentIds var files = await Children(ids); foreach (var completion in completions) { - relatedOccurrences.Add(completion.OccurrenceId); records.Add(new { Completion = await Safe(completion), Answers = answers[completion.Id], Files = files[completion.Id] }); } } if (batch.Count < 100) break; } - var schedules = new List(); var occurrences = new List(); var ownedSchedules = new HashSet(); + var schedules = new List(); var occurrences = new List(); for (var skip = 0; ; skip += 100) { - var batch = await _checklists.ListAsync(departmentId, skip: skip); - foreach (var row in batch.Where(s => s.CreatedBy == userId || s.TargetType == (int)ChecklistTargetType.Personnel && s.TargetId == userId)) - { ownedSchedules.Add(row.Id); schedules.Add(await Safe(row)); } + var batch = await _checklists.ListForMemberAsync(departmentId, userId, skip); + foreach (var row in batch) schedules.Add(await Safe(row)); if (batch.Count < 100) break; } for (var skip = 0; ; skip += 100) { - var batch = await _checklists.ListAsync(departmentId, skip: skip); - foreach (var row in batch.Where(o => relatedOccurrences.Contains(o.Id) || ownedSchedules.Contains(o.ScheduleId) || o.TargetType == (int)ChecklistTargetType.Personnel && o.TargetId == userId)) occurrences.Add(await Safe(row)); + var batch = await _checklists.ListForMemberAsync(departmentId, userId, skip); + foreach (var row in batch) occurrences.Add(await Safe(row)); if (batch.Count < 100) break; } // Mask cataloged fields even before enrollment migration finishes. The archive ledger records REDACTED values. @@ -283,7 +285,7 @@ async Task> Children(IReadOnlyCollection parentIds reminders.AddRange(batch.Select(r => (object)new { r.Id, r.OccurrenceId, r.Kind, r.Status, r.CreatedOnUtc, r.CompletedOnUtc })); if (batch.Count < 100) break; } - return new { Completions = records, Schedules = schedules, Occurrences = occurrences, Reminders = reminders }; + return new { Completions = records, Witnesses = witnesses, Schedules = schedules, Occurrences = occurrences, Reminders = reminders }; } private static async Task AddJsonEntry(ZipArchive archive, string fileName, object data, RedactionLedger ledger) diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index ddb442da5..d312f72c2 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -24,6 +24,10 @@ public class ProtectedFieldCatalog : IProtectedFieldCatalog private const string ModerationFamily = "Moderation"; private const string DocumentsFamily = "Documents"; private const string CredentialsFamily = "Credentials"; + private const int ChecklistContentCatalogVersion = 14; + private const int ChecklistOutcomeCatalogVersion = 15; + // Readiness history is version 16, owned by ReadinessHistoryFields.CatalogVersion. + private const int ChecklistScheduleCatalogVersion = 17; /// Catalog version the section 5.2 operational entries were added in. private const int OperationalCatalogVersion = 2; @@ -688,12 +692,17 @@ void Prevention(string table, string column, ProtectedFieldClassification classi ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, Resgrid.Model.Checklists.ReadinessHistoryFields.CatalogVersion)); foreach (var outcome in new[] { (Table: "ChecklistCompletions", Column: "Score"), (Table: "ChecklistCompletions", Column: "Passed"), (Table: "ChecklistCompletionItems", Column: "IsFailure") }) list.Add(new ProtectedFieldDefinition(outcome.Table.ToLowerInvariant() + "." + outcome.Column.ToLowerInvariant(), OperationalFamily, outcome.Table, outcome.Column, ProtectedFieldStorageKind.CompanionColumn, - ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 15)); + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, ChecklistOutcomeCatalogVersion)); foreach (var table in Resgrid.Model.Checklists.ChecklistTables.All.Values) list.Add(new ProtectedFieldDefinition(table.ToLowerInvariant() + ".content", OperationalFamily, table, "Content", ProtectedFieldStorageKind.Text, - ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, table == "ChecklistSchedules" ? 17 : 14)); + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, table == Resgrid.Model.Checklists.ChecklistTables.All[typeof(Resgrid.Model.Checklists.ChecklistSchedule)] ? ChecklistScheduleCatalogVersion : ChecklistContentCatalogVersion)); list.Add(new ProtectedFieldDefinition("checklistcompletionfiles.data", OperationalFamily, "ChecklistCompletionFiles", "Data", ProtectedFieldStorageKind.Binary, - ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 14)); + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, ChecklistContentCatalogVersion)); + foreach (var table in Resgrid.Model.WorkOrders.WorkOrderTables.All.Values) + list.Add(new ProtectedFieldDefinition(table.ToLowerInvariant() + ".content", OperationalFamily, table, "Content", ProtectedFieldStorageKind.Text, + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 18)); + list.Add(new ProtectedFieldDefinition("workorderfiles.data", OperationalFamily, "WorkOrderFiles", "Data", ProtectedFieldStorageKind.Binary, + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 18)); return list; } } diff --git a/Core/Resgrid.Services/ReadinessAccessService.cs b/Core/Resgrid.Services/ReadinessAccessService.cs index d37d576ed..3ee7b695d 100644 --- a/Core/Resgrid.Services/ReadinessAccessService.cs +++ b/Core/Resgrid.Services/ReadinessAccessService.cs @@ -30,7 +30,7 @@ public async Task CanUseChecklistsAsync(int departmentId) if ((await _flags.EvaluateFreshAsync(FeatureFlagKeys.ChecklistsSystem, departmentId))?.IsEnabled != true) return false; - var settings = await _settings.GetDepartmentModuleSettingsAsync(departmentId, true); + var settings = await _settings.GetDepartmentModuleSettingsAsync(departmentId, bypassCache: true); return settings != null && !settings.ChecklistsDisabled; } catch (Exception ex) @@ -47,10 +47,10 @@ public async Task CanUseMaintenanceAsync(int departmentId) try { - if (!await _flags.IsEnabledAsync(FeatureFlagKeys.MaintenanceWorkOrders, departmentId)) + if ((await _flags.EvaluateFreshAsync(FeatureFlagKeys.MaintenanceWorkOrders, departmentId))?.IsEnabled != true) return false; - var settings = await _settings.GetDepartmentModuleSettingsAsync(departmentId); + var settings = await _settings.GetDepartmentModuleSettingsAsync(departmentId, bypassCache: true); if (settings == null || settings.MaintenanceDisabled) return false; diff --git a/Core/Resgrid.Services/ReadinessHistoryProtectionService.cs b/Core/Resgrid.Services/ReadinessHistoryProtectionService.cs index 664e1d58b..6178ccd4c 100644 --- a/Core/Resgrid.Services/ReadinessHistoryProtectionService.cs +++ b/Core/Resgrid.Services/ReadinessHistoryProtectionService.cs @@ -33,7 +33,11 @@ public async Task ForDisplayAsync(int departmentId, T entity, IReadOnlyDic if (copy is WorkflowRunLog log) { log.WorkflowRun = null; log.WorkflowStep = null; } bool enforced; try { enforced = await _policy.IsProtectionEnforcedAsync(departmentId); } - catch { enforced = true; } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogError($"Readiness history policy lookup failed for department {departmentId}: {ex.GetType().FullName}."); + enforced = true; + } foreach (var field in fields.Values) if (!string.IsNullOrEmpty(field.Get(copy)) && (enforced || ProtectedDataEnvelope.HasEnvelopePrefix(field.Get(copy)))) field.Set(copy, ProtectedDataEnvelope.RedactionValue); return copy; diff --git a/Core/Resgrid.Services/ReadinessProBillingService.cs b/Core/Resgrid.Services/ReadinessProBillingService.cs new file mode 100644 index 000000000..a2ecdc814 --- /dev/null +++ b/Core/Resgrid.Services/ReadinessProBillingService.cs @@ -0,0 +1,32 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using RestSharp; +using RestSharp.Serializers.NewtonsoftJson; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// Dedicated monthly billing API. Checkout is never an entitlement and cannot use a PTT quantity endpoint. + public sealed class ReadinessProBillingService : IReadinessProBillingService + { + private async Task CallAsync(string action, int departmentId, bool post) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) || string.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) return default; + try + { + using var client = new RestClient(new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) { Timeout = TimeSpan.FromSeconds(10) }, configureSerialization: s => s.UseNewtonsoftJson()); + var request = new RestRequest("/api/ReadinessProBilling/" + action, post ? Method.Post : Method.Get); + request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); + if (post) request.AddJsonBody(new { DepartmentId = departmentId }); else request.AddQueryParameter("departmentId", departmentId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + var response = await client.ExecuteAsync(request); + return response.IsSuccessful && response.StatusCode == HttpStatusCode.OK ? response.Data : default; + } + catch { return default; } + } + public Task GetAsync(int departmentId) => CallAsync("Status", departmentId, false); + public Task BeginCheckoutAsync(int departmentId) => CallAsync("Checkout", departmentId, true); + public Task CancelRenewalAsync(int departmentId) => CallAsync("CancelRenewal", departmentId, true); + } +} diff --git a/Core/Resgrid.Services/Records/DomainEventOutboxService.cs b/Core/Resgrid.Services/Records/DomainEventOutboxService.cs index e712c4728..c9ae91cc2 100644 --- a/Core/Resgrid.Services/Records/DomainEventOutboxService.cs +++ b/Core/Resgrid.Services/Records/DomainEventOutboxService.cs @@ -29,15 +29,17 @@ public class DomainEventOutboxService : IDomainEventOutboxService private readonly IDomainEventOutboxRepository _outboxRepository; private readonly IEventAggregator _eventAggregator; + private readonly Lazy _workOrderNotifications; private readonly Lazy _protection; private readonly Lazy _history; private Task ProtectHistoryAsync(DomainEventOutboxEntry entry, CancellationToken ct) => (_history?.Value ?? throw new InvalidOperationException("Readiness history protection is unavailable.")) .ProtectAsync(entry.DepartmentId, entry.DomainEventOutboxId.ToString(System.Globalization.CultureInfo.InvariantCulture), entry, ReadinessHistoryFields.Outbox, ct); - public DomainEventOutboxService(IDomainEventOutboxRepository outboxRepository, IEventAggregator eventAggregator, Lazy protection = null, Lazy history = null) + public DomainEventOutboxService(IDomainEventOutboxRepository outboxRepository, IEventAggregator eventAggregator, Lazy protection = null, Lazy history = null, Lazy workOrderNotifications = null) { _outboxRepository = outboxRepository; _eventAggregator = eventAggregator; + _workOrderNotifications = workOrderNotifications; _protection = protection; _history = history; } @@ -61,7 +63,7 @@ public async Task EnqueueAsync(int departmentId, string AggregateVersion = envelope.AggregateVersion, Sequence = await _outboxRepository.GetNextSequenceAsync(departmentId, envelope.AggregateId), TriggerEventType = envelope.Trigger.HasValue ? (int?)envelope.Trigger.Value : null, - PayloadJson = producerSubsystem == "Checklists" + PayloadJson = ChecklistWorkflowPayload.IsReadinessProducer(producerSubsystem) ? await ChecklistWorkflowPayload.ProjectAsync(departmentId, envelope.Payload, _protection?.Value) : JsonConvert.SerializeObject(envelope.Payload ?? new object()), CorrelationId = envelope.CorrelationId, @@ -74,7 +76,7 @@ public async Task EnqueueAsync(int departmentId, string CreatedOn = now }; - if (producerSubsystem != "Checklists") return await _outboxRepository.InsertAsync(entry, cancellationToken, true); + if (!ChecklistWorkflowPayload.IsReadinessProducer(producerSubsystem)) return await _outboxRepository.InsertAsync(entry, cancellationToken, true); var payload = entry.PayloadJson; entry.ReadinessRoutingJson = ChecklistWorkflowPayload.Routing(payload, entry.AggregateId); entry.PayloadJson = "{}"; // Allocate the numeric AAD identity without writing content to SQL. @@ -153,7 +155,7 @@ private async Task DispatchOneAsync(DomainEventOutboxEntry entry, Cancella try { var dispatchPayload = entry.PayloadJson; - if (entry.ProducerSubsystem == "Checklists") + if (ChecklistWorkflowPayload.IsReadinessProducer(entry.ProducerSubsystem)) { var original = entry.PayloadJson; var oldError = entry.LastError; var source = ProtectedDataEnvelope.HasEnvelopePrefix(original) ? entry.ReadinessRoutingJson : original; @@ -183,17 +185,19 @@ private async Task DispatchOneAsync(DomainEventOutboxEntry entry, Cancella }; _eventAggregator.SendMessage(dispatched); await _eventAggregator.SendMessageAsync(dispatched); + if (entry.ProducerSubsystem == "WorkOrders") + await (_workOrderNotifications?.Value ?? throw new InvalidOperationException("Work-order notifications are unavailable.")).DispatchAsync(entry); return await _outboxRepository.MarkDispatchedAsync(entry.DomainEventOutboxId, DateTime.UtcNow, cancellationToken); } catch (Exception ex) { - var error = entry.ProducerSubsystem == "Checklists" ? "Checklist subscriber delivery failed." : ex.Message; - if (entry.ProducerSubsystem == "Checklists") + var error = ChecklistWorkflowPayload.IsReadinessProducer(entry.ProducerSubsystem) ? "Checklist subscriber delivery failed." : ex.Message; + if (ChecklistWorkflowPayload.IsReadinessProducer(entry.ProducerSubsystem)) { entry.LastError = error; await ProtectHistoryAsync(entry, cancellationToken); error = entry.LastError; } - if (entry.ProducerSubsystem == "Checklists") Logging.LogError($"Outbox dispatch failed for event {entry.EventId} (attempt {entry.Attempts})."); + if (ChecklistWorkflowPayload.IsReadinessProducer(entry.ProducerSubsystem)) Logging.LogError($"Outbox dispatch failed for event {entry.EventId} (attempt {entry.Attempts}): {ex.GetType().FullName}."); else Logging.LogException(ex, $"Outbox dispatch failed for event {entry.EventId} (attempt {entry.Attempts})."); var terminal = entry.Attempts >= MaximumAttempts; await _outboxRepository.MarkFailedAsync(entry.DomainEventOutboxId, error, terminal ? (DateTime?)null : DateTime.UtcNow.Add(BackoffFor(entry.Attempts)), terminal, cancellationToken); diff --git a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs index 69d15b58d..51b32133d 100644 --- a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs +++ b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs @@ -47,28 +47,36 @@ public static (DateTime start, DateTime end) RequireWindow(DateTime? start, Date } } - /// - /// Apparatus and equipment readiness at the time of the call — a checklist/work-order manifest with source - /// completion IDs, coverage period and checksum (plan section 4.5). - /// - /// The checklists and maintenance module this reads from is planned but not built. The adapter ships anyway, - /// because the plan requires all six and because "unavailable" and "there was no readiness evidence" are - /// different answers: an author who sees the source reported as absent knows not to draw a conclusion from - /// the empty list. When the module lands, only changes. - /// - /// + /// Captures the authorized readiness PDF and provenance as one immutable RMS artifact. public class ReadinessPacketEvidenceAdapter : IRecordEvidenceAdapter { public const string SourceSubsystem = "Checklists"; public const string UnavailableReason = "The checklists and maintenance module is not present in this build."; - + private readonly IChecklistsService _checklists; + private readonly IReadinessAccessService _access; + private readonly IProtectedGrantContext _grant; + private readonly Resgrid.Model.Providers.IPdfProvider _pdf; + public ReadinessPacketEvidenceAdapter(IChecklistsService checklists = null, IReadinessAccessService access = null, IProtectedGrantContext grant = null, Resgrid.Model.Providers.IPdfProvider pdf = null) + { _checklists = checklists; _access = access; _grant = grant; _pdf = pdf; } public RmsEvidenceKind Kind => RmsEvidenceKind.ReadinessPacket; - - public Task IsAvailableAsync(int departmentId) => Task.FromResult(false); - - public Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) + public Task IsAvailableAsync(int departmentId) => _checklists == null || _access == null || _pdf == null ? Task.FromResult(false) : _access.CanUseChecklistsAsync(departmentId); + public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) { - return Task.FromResult(RecordEvidenceCapture.Unavailable(UnavailableReason)); + if (!await IsAvailableAsync(request.DepartmentId)) return RecordEvidenceCapture.Unavailable(ChecklistReportDocuments.Text("Checklists are disabled for this department.")); + if (_grant == null || _grant.IsWorkloadCaller || _grant.UserId != request.CapturedByUserId) throw new UnauthorizedAccessException(); + if (!request.CallId.HasValue || request.CoverageStart.HasValue || request.CoverageEnd.HasValue) throw new ArgumentException(ChecklistReportDocuments.Text("PacketCaptureWindow")); + var actor = new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = request.DepartmentId, UserId = request.CapturedByUserId, GrantToken = _grant.GrantToken }; + var manifest = await _checklists.GetReadinessPacketForCallAsync(actor, request.CallId.Value); + if (manifest.Checklists.Count > EvidenceLimits.MaxItems) throw new ArgumentException(ChecklistReportDocuments.Text("ReportTooLarge")); + cancellationToken.ThrowIfCancellationRequested(); + var package = ChecklistReportDocuments.Package(manifest, _pdf); + // Repeat source authorization after PDF conversion, before Records retains the snapshot. + var current = await _checklists.GetReadinessPacketForCallAsync(actor, request.CallId.Value); + ChecklistReportDocuments.EnsureStillAuthorized(manifest, current); + return new RecordEvidenceCapture { Title = ChecklistReportDocuments.Text("ReadinessPacketReport"), SourceSubsystem = SourceSubsystem, + SourceEntityType = "ReadinessEvidenceManifestV1", SourceEntityId = EvidenceLimits.SelectionIdentity(new { request.CallId, lookback_days = 30 }), + IdentifierScheme = "resgrid:readiness-packet", SourceVersion = manifest.GeneratorVersion, CoverageStart = manifest.CoverageStartUtc, + CoverageEnd = manifest.CoverageEndUtc, SourceItemCount = manifest.Checklists.Count, Classification = RmsEvidenceClassification.Restricted, Manifest = package }; } } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index f3b495d63..a02e3ea9c 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -17,7 +17,12 @@ protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().PreserveExistingDefaults().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/SubscriptionsService.cs b/Core/Resgrid.Services/SubscriptionsService.cs index 56aec2164..2bacdcd71 100644 --- a/Core/Resgrid.Services/SubscriptionsService.cs +++ b/Core/Resgrid.Services/SubscriptionsService.cs @@ -776,9 +776,9 @@ public async Task> GetCurrentPaymentAddonsForDepartmentAsync( return paymentAddons; } - public Task> GetAllAddonPlansByTypeAsync(PlanAddonTypes planAddonType) + public Task> GetAllAddonPlansByTypeAsync(PlanAddonTypes planAddonType, bool bypassCache = false) { - if (planAddonType == PlanAddonTypes.ReadinessPro && Config.SystemBehaviorConfig.CacheEnabled && + if (!bypassCache && planAddonType == PlanAddonTypes.ReadinessPro && Config.SystemBehaviorConfig.CacheEnabled && !string.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !string.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) return _cacheProvider.RetrieveAsync($"AddonPlansByType_{(int)planAddonType}", () => LoadAddonPlansByTypeAsync(planAddonType), TimeSpan.FromMinutes(5)); diff --git a/Core/Resgrid.Services/WorkOrderAuthorizationService.cs b/Core/Resgrid.Services/WorkOrderAuthorizationService.cs new file mode 100644 index 000000000..3b455ecf5 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderAuthorizationService.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed class WorkOrderAuthorizationService : IWorkOrderAuthorizationService + { + private readonly IDepartmentsService _departments; + private readonly IDepartmentGroupsService _groups; + private readonly IPersonnelRolesService _roles; + private readonly IPermissionsService _permissions; + private readonly IUnitsService _units; + private readonly IAuthorizationService _resources; + private readonly IChecklistAssignmentService _assignments; + private readonly IChecklistAssetSource _assets; + public WorkOrderAuthorizationService(IDepartmentsService departments, IDepartmentGroupsService groups, IPersonnelRolesService roles, IPermissionsService permissions, + IUnitsService units, IAuthorizationService resources, IChecklistAssignmentService assignments, IChecklistAssetSource assets = null) + { _departments = departments; _groups = groups; _roles = roles; _permissions = permissions; _units = units; _resources = resources; _assignments = assignments; _assets = assets; } + public async Task RequireMemberAsync(ChecklistActor actor) + { + if (actor == null || actor.DepartmentId <= 0 || string.IsNullOrWhiteSpace(actor.UserId)) throw new WorkOrderException(403, "MembershipRequired"); + var m = await _departments.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true); + if (m?.DepartmentId != actor.DepartmentId || m.IsDeleted || m.IsDisabled == true) throw new WorkOrderException(403, "MembershipRequired"); + } + private async Task AllowedAsync(ChecklistActor actor, PermissionTypes type, int? groupId) + { + await RequireMemberAsync(actor); + var member = await _departments.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true); + var department = await _departments.GetDepartmentByIdAsync(actor.DepartmentId, true); + var admin = member.IsAdmin == true || department?.ManagingUserId == actor.UserId; + var group = await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId); + var permission = await _permissions.GetPermissionByDepartmentTypeAsync(actor.DepartmentId, type); + var fallback = type == PermissionTypes.ManageWorkOrders ? PermissionActions.DepartmentAdminsOnly : PermissionActions.DepartmentAndGroupAdmins; + if (!RecordPermissionEvaluation.IsSatisfied(permission?.Action ?? (int)fallback, permission?.Data, admin, group?.IsUserGroupAdmin(actor.UserId) == true, await _roles.GetRolesForUserAsync(actor.UserId, actor.DepartmentId))) return false; + return admin || !(permission?.LockToGroup ?? type == PermissionTypes.ViewAllWorkOrders) || groupId.HasValue && groupId == group?.DepartmentGroupId; + } + public Task CanManageAsync(ChecklistActor actor, int? groupId) => AllowedAsync(actor, PermissionTypes.ManageWorkOrders, groupId); + public async Task ScopeAsync(ChecklistActor actor) + { + await RequireMemberAsync(actor); + var group = await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId); + var all = await AllowedAsync(actor, PermissionTypes.ViewAllWorkOrders, null) || await CanManageAsync(actor, null); + var groupAllowed = group != null && (await AllowedAsync(actor, PermissionTypes.ViewAllWorkOrders, group.DepartmentGroupId) || await CanManageAsync(actor, group.DepartmentGroupId)); + return new WorkOrderReadScope { UserId = actor.UserId, All = all, GroupId = groupAllowed ? group.DepartmentGroupId : null, + RoleIds = (await _roles.GetRolesForUserAsync(actor.UserId, actor.DepartmentId)).Where(r => r.DepartmentId == actor.DepartmentId).Select(r => r.PersonnelRoleId).ToArray() }; + } + public async Task CanContributeAsync(ChecklistActor actor, WorkOrder row) + { + await RequireMemberAsync(actor); + if (row?.DepartmentId != actor.DepartmentId) return false; + if (await CanManageAsync(actor, row.TargetGroupId)) return true; + if (row.AssignedToUserId != null) return row.AssignedToUserId == actor.UserId; + return row.AssignedToRoleId.HasValue && (await _assignments.MembersAsync(actor.DepartmentId, 2, row.AssignedToRoleId.Value.ToString())).Contains(actor.UserId); + } + public async Task ValidateTargetAsync(ChecklistActor actor, WorkOrderInput input) + { + await RequireMemberAsync(actor); + if (input.TargetUnitId.HasValue) + { + var unit = await _units.GetUnitByIdAsync(input.TargetUnitId.Value); + if (unit?.DepartmentId != actor.DepartmentId || !await _resources.CanUserViewUnitAsync(actor.UserId, unit.UnitId)) throw new WorkOrderException(404, "TargetUnavailable"); + if (input.TargetGroupId.HasValue && input.TargetGroupId != unit.StationGroupId) throw new WorkOrderException(400, "TargetUnavailable"); + input.TargetGroupId = unit.StationGroupId; + } + if (input.TargetGroupId.HasValue) + { + var group = await _groups.GetGroupByIdAsync(input.TargetGroupId.Value, true); + var own = await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId); + if (group?.DepartmentId != actor.DepartmentId || own?.DepartmentGroupId != group.DepartmentGroupId && !await CanManageAsync(actor, group.DepartmentGroupId)) throw new WorkOrderException(404, "TargetUnavailable"); + } + if (!string.IsNullOrEmpty(input.InventoryAssetId)) + { + if (!Guid.TryParseExact(input.InventoryAssetId, "D", out _) || _assets == null || !await _assets.IsAvailableAsync(actor.DepartmentId)) throw new WorkOrderException(404, "TargetUnavailable"); + var asset = await _assets.GetAsync(actor, input.InventoryAssetId); + if (asset?.DepartmentId != actor.DepartmentId || asset.Id != input.InventoryAssetId || input.TargetUnitId.HasValue && input.TargetUnitId != asset.UnitId || input.TargetGroupId.HasValue && input.TargetGroupId != asset.GroupId) throw new WorkOrderException(404, "TargetUnavailable"); + input.TargetUnitId = asset.UnitId; input.TargetGroupId = asset.GroupId; + } + } + public async Task ValidateAssignmentAsync(ChecklistActor actor, WorkOrder row, string userId, int? roleId) + { + await RequireMemberAsync(actor); + if ((string.IsNullOrEmpty(userId) ? 0 : 1) + (roleId.HasValue ? 1 : 0) != 1) throw new WorkOrderException(400, "AssignmentRequired"); + try { await _assignments.ValidateAsync(actor.DepartmentId, roleId.HasValue ? 2 : 1, roleId?.ToString() ?? userId); } + catch (ChecklistException) { throw new WorkOrderException(400, "AssignmentRequired"); } + if (userId != null && !await _resources.CanUserViewPersonAsync(actor.UserId, userId, actor.DepartmentId)) throw new WorkOrderException(403, "PermissionRequired"); + } + public async Task ChoicesAsync(ChecklistActor actor) + { + await RequireMemberAsync(actor); var result = new WorkOrderChoices(); + foreach (var c in await _assignments.ChoicesAsync(actor)) + { + var choice = new WorkOrderChoice { Id = c.Id, Name = c.Name }; + if (c.Type == 1 && await _resources.CanUserViewPersonAsync(actor.UserId, c.Id, actor.DepartmentId)) result.Users.Add(choice); + if (c.Type == 2) result.Roles.Add(choice); + if (c.Type == 3 && ((await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId))?.DepartmentGroupId.ToString() == c.Id || await CanManageAsync(actor, int.Parse(c.Id)))) result.Groups.Add(choice); + if (c.Type == 4 && await _resources.CanUserViewUnitAsync(actor.UserId, int.Parse(c.Id))) result.Units.Add(choice); + } + if (_assets != null && await _assets.IsAvailableAsync(actor.DepartmentId)) result.Assets = (await _assets.ListAsync(actor)).Where(a => a.DepartmentId == actor.DepartmentId).Select(a => new WorkOrderChoice { Id = a.Id, Name = a.Name }).ToList(); + return result; + } + public async Task> RecipientsAsync(int departmentId, WorkOrder row) + { + if (row?.DepartmentId != departmentId) return new List(); + var members = row.AssignedToUserId != null ? await _assignments.MembersAsync(departmentId, 1, row.AssignedToUserId) : row.AssignedToRoleId.HasValue ? await _assignments.MembersAsync(departmentId, 2, row.AssignedToRoleId.Value.ToString()) : new HashSet(); + return members.OrderBy(id => id, StringComparer.Ordinal).ToList(); + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderFiles.cs b/Core/Resgrid.Services/WorkOrderFiles.cs new file mode 100644 index 000000000..528f3b6f3 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderFiles.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.WorkOrders; +using Resgrid.Services.Records; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + public async Task AddFileAsync(ChecklistActor actor, int id, int revision, string filename, string contentType, byte[] data) + { + await RequireWriteAsync(actor); await FileWriteOrderAsync(actor, id, revision); + Text(filename, 200, true); + if (data == null || data.Length == 0 || data.Length > 10 * 1024 * 1024 || contentType is not ("image/png" or "image/jpeg" or "application/pdf")) throw new WorkOrderException(400, "InvalidFile"); + AttachmentHygieneResult clean; + try + { + if (contentType.StartsWith("image/", StringComparison.Ordinal)) + { + var info = SixLabors.ImageSharp.Image.Identify(data); + if (info == null || (long)info.Width * info.Height > RecordAttachmentHygiene.MaxPixels) throw new WorkOrderException(400, "InvalidFile"); + } + else if (data.Length < 5 || System.Text.Encoding.ASCII.GetString(data, 0, 5) != "%PDF-") throw new WorkOrderException(400, "InvalidFile"); + clean = RecordAttachmentHygiene.Sanitize(filename, contentType, data); + } + catch (Exception ex) when (ex is ArgumentException or SixLabors.ImageSharp.UnknownImageFormatException or SixLabors.ImageSharp.InvalidImageContentException or NotSupportedException) { throw new WorkOrderException(400, "InvalidFile"); } + if (clean.Data.Length > 10 * 1024 * 1024 || clean.ContentType != contentType) throw new WorkOrderException(400, "InvalidFile"); + var scan = await _scanner.ScanAsync(clean.FileName, clean.ContentType, clean.Data); + if (scan?.State != RmsAttachmentScanState.Clean) throw new WorkOrderException(409, "ScanRequired"); + await TransactionAsync(actor, async events => + { + var row = await FileWriteOrderAsync(actor, id, revision); + if ((await _store.ChildrenAsync(actor.DepartmentId, id)).Count >= 100) throw new WorkOrderException(400, "FileLimit"); + var file = New(actor, id); file.ContentType = clean.ContentType; file.Size = clean.Data.Length; file.Sha256 = Convert.ToHexString(SHA256.HashData(clean.Data)); file.ScanState = (int)scan.State; + await _store.AllocateAsync(file); file.Content = clean.FileName; file.Data = clean.Data; + var result = await _write.Value.PrepareRecordsBinaryWriteAsync(actor.DepartmentId, "workorderfiles.data", Key(file), file.Data, bytes => file.Data = bytes, () => file.IsProtected = true, actor.GrantToken, actor.UserId, false); + if (result?.Success != true || result.IsProtected && !ProtectedReadService.IsBinaryEnveloped(file.Data)) throw new WorkOrderException(403, "ProtectedDataRequired"); + await SaveAsync(actor, file); await ChangedAsync(actor, row, WorkOrderActivityType.FileAdded, events); return true; + }); + } + private async Task FileWriteOrderAsync(ChecklistActor actor, int id, int revision) + { + var row = await ReadOrderAsync(actor, id); Revision(row, revision); + if (Terminal(row) || row.Status == 5 || row.CreatedBy != actor.UserId && !await _authorization.CanContributeAsync(actor, row)) throw new WorkOrderException(403, "PermissionRequired"); + return row; + } + public async Task GetFileAsync(ChecklistActor actor, int id) + { + await _authorization.RequireMemberAsync(actor); var metadata = await _store.GetAsync(actor.DepartmentId, id, false); + if (metadata?.WorkOrderId == null) throw new WorkOrderException(404, "Unavailable"); + await ReadOrderAsync(actor, metadata.WorkOrderId.Value); + var file = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, id)); + if (file.ScanState != (int)RmsAttachmentScanState.Clean) throw new WorkOrderException(404, "Unavailable"); + var enveloped = ProtectedReadService.IsBinaryEnveloped(file.Data); + var result = await _read.Value.ResolveRecordsBinaryForReadAsync(actor.DepartmentId, "workorderfiles.data", Key(file), file.Data, bytes => file.Data = bytes, actor.GrantToken, actor.UserId); + if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && !enveloped || file.Data == null) throw new WorkOrderException(403, "ProtectedDataRequired"); + if (Convert.ToHexString(SHA256.HashData(file.Data)) != file.Sha256) throw new WorkOrderException(409, "IntegrityFailed"); return file; + } + public async Task WithdrawFileAsync(ChecklistActor actor, int id, int fileId, int revision, string reason) + { + Text(reason, 4000, true); + await TransactionAsync(actor, async events => + { + var row = await FileWriteOrderAsync(actor, id, revision); var file = await _store.GetAsync(actor.DepartmentId, fileId); + if (file?.WorkOrderId != id || file.WithdrawnOn.HasValue) throw new WorkOrderException(404, "Unavailable"); + await RevealAsync(actor, file); file.WithdrawnOn = Now; file.Revision++; await SaveAsync(actor, file); + await ChangedAsync(actor, row, WorkOrderActivityType.FileWithdrawn, events, reason); return true; + }); + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderGdprExport.cs b/Core/Resgrid.Services/WorkOrderGdprExport.cs new file mode 100644 index 000000000..1bdef0ab5 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderGdprExport.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model.Repositories; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public partial class GdprDataExportService + { + private readonly IWorkOrderRepository _workOrders; + private async Task BuildWorkOrderDataAsync(string userId, int departmentId) + { + if (_workOrders == null) throw new InvalidOperationException("Work-order export storage is unavailable."); + async Task Safe(T row) where T : WorkOrderRow => await _checklistProtection.Value.ForDisplayAsync(departmentId, row, WorkOrderTables.Fields()); + async Task> Children(int id) where T : WorkOrderRow + { + var result = new List(); + for (var skip = 0; ; skip += 500) { var batch = await _workOrders.ChildrenAsync(departmentId, id, skip); foreach (var row in batch) result.Add(await Safe(row)); if (batch.Count < 500) return result; } + } + var orders = new List(); + for (var page = 0; page <= 10000; page++) + { + var rows = await _workOrders.ListAsync(departmentId, new WorkOrderReadScope { All = true, UserId = userId }, new WorkOrderFilter { Page = page }); + foreach (var row in rows.Take(50)) + { + var activity = await Children(row.Id); var labor = await Children(row.Id); + if (row.CreatedBy != userId && row.AssignedToUserId != userId && row.CompletedBy != userId && row.VerifiedBy != userId && !activity.Any(a => a.CreatedBy == userId) && !labor.Any(l => l.UserId == userId || l.CreatedBy == userId)) continue; + orders.Add(new { Order = await Safe(row), Activity = activity, Labor = labor, Parts = await Children(row.Id), Files = (await Children(row.Id)).Select(f => new { f.Id, f.DepartmentId, f.WorkOrderId, f.Content, f.ContentType, f.Size, f.Sha256, f.ScanState, f.WithdrawnOn, f.CreatedOn, f.CreatedBy }) }); + } + if (rows.Count <= 50) return orders; + } + throw new InvalidOperationException("Work-order export exceeds the supported department size."); + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderNotificationService.cs b/Core/Resgrid.Services/WorkOrderNotificationService.cs new file mode 100644 index 000000000..b2f9e7651 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderNotificationService.cs @@ -0,0 +1,86 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Resources; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Localization; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed class WorkOrderNotificationService : IWorkOrderNotificationService + { + private readonly IWorkOrderRepository _orders; + private readonly IWorkOrderAuthorizationService _authorization; + private readonly IReadinessAccessService _access; + private readonly IUnitOfWork _uow; + private readonly ICommunicationService _communication; + private readonly IDepartmentsService _departments; + private readonly IDepartmentSettingsService _settings; + private readonly IUserProfileService _profiles; + private static readonly ResourceManager Strings = new ResourceManager("Resgrid.Localization.Areas.User.WorkOrders.WorkOrders", typeof(SupportedLocales).Assembly); + public WorkOrderNotificationService(IWorkOrderRepository orders, IWorkOrderAuthorizationService authorization, IReadinessAccessService access, IUnitOfWork uow, + ICommunicationService communication, IDepartmentsService departments, IDepartmentSettingsService settings, IUserProfileService profiles) + { _orders = orders; _authorization = authorization; _access = access; _uow = uow; _communication = communication; _departments = departments; _settings = settings; _profiles = profiles; } + private async Task TransactionAsync(int departmentId, Func> action) + { + if (_uow.Transaction != null) throw new InvalidOperationException("Work-order notification claims own their transaction."); + try { await _uow.CreateOrGetConnectionAsync(CancellationToken.None); await _orders.LockDepartmentAsync(departmentId); var result = await action(); _uow.CommitChanges(); return result; } + catch { _uow.DiscardChanges(); throw; } + } + public async Task DispatchAsync(DomainEventOutboxEntry entry) + { + if (entry?.ProducerSubsystem != "WorkOrders" || entry.TriggerEventType is not (70 or 71 or 72) || !Guid.TryParseExact(entry.EventId, "D", out _) || !int.TryParse(entry.AggregateId, out var id)) return; + if (!await _access.CanUseMaintenanceAsync(entry.DepartmentId)) return; + var row = await _orders.GetAsync(entry.DepartmentId, id); + if (row == null) return; + foreach (var user in await RecipientsAsync(entry.DepartmentId, row)) + { + var notice = new WorkOrderNotification { DepartmentId = entry.DepartmentId, WorkOrderId = id, EventId = entry.EventId, UserId = user, LeaseOwner = Guid.NewGuid().ToString("D") }; + var state = await TransactionAsync(entry.DepartmentId, () => _orders.ClaimNotificationAsync(notice, DateTime.UtcNow)); + if (state == 2 || state == 3) continue; + if (state != 1) throw new InvalidOperationException("Work-order notification is leased by another delivery attempt."); + try + { + var profile = await _profiles.GetProfileByUserIdAsync(user); + var department = await _departments.GetDepartmentByIdAsync(entry.DepartmentId, true); + var number = await _settings.GetTextToCallNumberForDepartmentAsync(entry.DepartmentId); + var current = await _orders.GetAsync(entry.DepartmentId, id); + if (department == null || profile == null || current == null || !await _access.CanUseMaintenanceAsync(entry.DepartmentId) || !(await RecipientsAsync(entry.DepartmentId, current)).Contains(user)) + { await FinishAsync(notice, 3); continue; } + CultureInfo culture; + try { culture = CultureInfo.GetCultureInfo(profile.Language ?? "en"); if (!SupportedLocales.GetSupportedCultures().Contains(culture.TwoLetterISOLanguageName)) culture = CultureInfo.GetCultureInfo("en"); } + catch (CultureNotFoundException) { culture = CultureInfo.GetCultureInfo("en"); } + // Never decrypt, copy a title, embed an actor/grant or treat handoff as a delivery receipt. + var handedOff = await _communication.SendNotificationAsync(user, entry.DepartmentId, + Strings.GetString("NotificationMessage", culture) + " " + (Config.SystemBehaviorConfig.ResgridBaseUrl ?? "").TrimEnd('/') + "/User/WorkOrders/Detail/" + id, + number, department, Strings.GetString("NotificationTitle", culture), profile); + await FinishAsync(notice, handedOff ? 2 : 3); + } + catch { await FinishAsync(notice, 0); throw new InvalidOperationException("Work-order notification handoff failed."); } + } + } + private async Task> RecipientsAsync(int departmentId, WorkOrder row) + { + var result = new System.Collections.Generic.HashSet(await _authorization.RecipientsAsync(departmentId, row)); + var members = await _departments.GetAllMembersForDepartmentUnlimitedAsync(departmentId, true); + foreach (var member in members.Where(m => m.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true)) + { + var actor = new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = departmentId, UserId = member.UserId }; + if (member.UserId == row.CreatedBy || row.Status is 0 or 1 && await _authorization.CanManageAsync(actor, row.TargetGroupId)) + result.Add(member.UserId); + } + return result.OrderBy(id => id, StringComparer.Ordinal).ToList(); + } + private async Task FinishAsync(WorkOrderNotification row, int state) + { + if (!await TransactionAsync(row.DepartmentId, () => _orders.FinishNotificationAsync(row, state, DateTime.UtcNow))) + throw new InvalidOperationException("Work-order notification lease was lost."); + } + } +} diff --git a/Core/Resgrid.Services/WorkOrdersService.cs b/Core/Resgrid.Services/WorkOrdersService.cs new file mode 100644 index 000000000..0527449b6 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrdersService.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService : IWorkOrdersService + { + private readonly IWorkOrderRepository _store; + private readonly IWorkOrderAuthorizationService _authorization; + private readonly IReadinessAccessService _access; + private readonly IUnitOfWork _uow; + private readonly IAuditLogsRepository _audit; + private readonly IDomainEventOutboxService _outbox; + private readonly Lazy _read; + private readonly Lazy _write; + private readonly IRecordAttachmentScanner _scanner; + private readonly TimeProvider _clock; + public WorkOrdersService(IWorkOrderRepository store, IWorkOrderAuthorizationService authorization, IReadinessAccessService access, IUnitOfWork uow, + IAuditLogsRepository audit, IDomainEventOutboxService outbox, Lazy read, Lazy write, IRecordAttachmentScanner scanner, TimeProvider clock = null) + { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; _clock = clock ?? TimeProvider.System; } + private DateTime Now => _clock.GetUtcNow().UtcDateTime; + private sealed class StoredContent { public WorkOrderContent Fields { get; set; } public string RequestHash { get; set; } } + private static T Decode(string value) => JsonConvert.DeserializeObject(value ?? "{}"); + private static string Key(WorkOrderRow row) => row.Id.ToString(CultureInfo.InvariantCulture); + private static void Revision(WorkOrderRow row, int revision) { if (row.Revision != revision) throw new WorkOrderException(409, "Conflict"); } + private static void Text(string value, int limit, bool required = false) { if (value?.Length > limit || required && string.IsNullOrWhiteSpace(value)) throw new WorkOrderException(400, "InvalidInput"); } + private static void Money(decimal? value) { if (value < 0 || value > 100000000m || value.HasValue && decimal.Round(value.Value, 2) != value.Value) throw new WorkOrderException(400, "InvalidInput"); } + private static bool Terminal(WorkOrder row) => row.Status is 6 or 7 or 8 or 9; + private async Task RequireWriteAsync(ChecklistActor actor) + { + await _authorization.RequireMemberAsync(actor); + if (!await _access.CanUseMaintenanceAsync(actor.DepartmentId)) throw new WorkOrderException(402, "ReadinessProRequired"); + } + private async Task ReadOrderAsync(ChecklistActor actor, int id) + { + await _authorization.RequireMemberAsync(actor); + var row = id > 0 ? await _store.GetAsync(actor.DepartmentId, id) : null; + if (row == null || !(await _authorization.ScopeAsync(actor)).Allows(row)) throw new WorkOrderException(404, "Unavailable"); + return await RevealAsync(actor, row); + } + private async Task RevealAsync(ChecklistActor actor, T row) where T : WorkOrderRow + { + if (row == null || row.DepartmentId != actor.DepartmentId) throw new WorkOrderException(404, "Unavailable"); + var plain = !string.IsNullOrEmpty(row.Content) && !ProtectedDataEnvelope.HasEnvelopePrefix(row.Content); + var result = await _read.Value.ResolveRecordsEntitiesForReadAsync(actor.DepartmentId, new[] { (row, Key(row)) }, WorkOrderTables.Fields(), actor.GrantToken, actor.UserId); + if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && plain) throw new WorkOrderException(403, "ProtectedDataRequired"); + return row; + } + private async Task SaveAsync(ChecklistActor actor, T row, bool insert = false) where T : WorkOrderRow + { + row.UpdatedOn = Now; + if (insert) { var content = row.Content; row.Content = null; await _store.AllocateAsync(row); row.Content = content; } + var result = await _write.Value.PrepareRecordsEntityWriteAsync(actor.DepartmentId, row, (T)null, Key(row), WorkOrderTables.Fields(), () => row.IsProtected = true, actor.GrantToken, actor.UserId, false); + if (result?.Success != true || result.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(row.Content)) throw new WorkOrderException(403, "ProtectedDataRequired"); + await _store.WriteAsync(row); + } + private T New(ChecklistActor actor, int? orderId = null) where T : WorkOrderRow, new() => new T { DepartmentId = actor.DepartmentId, WorkOrderId = orderId, CreatedBy = actor.UserId, CreatedOn = Now, UpdatedOn = Now }; + private async Task TransactionAsync(ChecklistActor actor, Func, Task> command) + { + await RequireWriteAsync(actor); + if (_uow.Transaction != null) throw new InvalidOperationException("Work-order commands own their transaction."); + var events = new List(); T result; + try + { + await _uow.CreateOrGetConnectionAsync(CancellationToken.None); await _store.LockDepartmentAsync(actor.DepartmentId); + await RequireWriteAsync(actor); result = await command(events); _uow.CommitChanges(); + } + catch { _uow.DiscardChanges(); throw; } + await _outbox.DispatchAfterCommitAsync(events); return result; + } + private async Task ActivityAsync(ChecklistActor actor, WorkOrder row, WorkOrderActivityType type, string note = null, int? oldStatus = null, int? newStatus = null, WorkOrderContent snapshot = null) + { + var activity = New(actor, row.Id); activity.ActivityType = (int)type; activity.OldStatus = oldStatus; activity.NewStatus = newStatus; + activity.Content = JsonConvert.SerializeObject(new { Note = note, Snapshot = snapshot, AssignedToUserId = type == WorkOrderActivityType.Assigned ? row.AssignedToUserId : null, AssignedToRoleId = type == WorkOrderActivityType.Assigned ? row.AssignedToRoleId : null }); await SaveAsync(actor, activity, true); + var audit = await _audit.InsertAsync(new AuditLog { DepartmentId = actor.DepartmentId, ObjectDepartmentId = actor.DepartmentId, UserId = actor.UserId, ObjectId = Key(row), + LogType = (int)AuditLogTypes.WorkOrderChanged, LoggedOn = Now, Successful = true, Message = "WorkOrderChanged", ServerName = Environment.MachineName }, CancellationToken.None); + audit.Data = JsonConvert.SerializeObject(new { row.Id, row.Revision, ActivityId = activity.Id, ActivityType = (int)type, OldStatus = oldStatus, NewStatus = newStatus }); + var protection = await _write.Value.PrepareRecordsEntityWriteAsync(actor.DepartmentId, audit, null, audit.AuditLogId.ToString(CultureInfo.InvariantCulture), ReadinessHistoryFields.Audits, null, actor.GrantToken, actor.UserId, false); + if (protection?.Success != true || protection.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(audit.Data)) throw new WorkOrderException(403, "ProtectedDataRequired"); + await _audit.UpdateAsync(audit, CancellationToken.None); + } + private async Task EventAsync(WorkOrder row, WorkflowTriggerEventType? trigger, List events) + { + var entry = await _outbox.EnqueueAsync(row.DepartmentId, "WorkOrders", new DomainEventEnvelope { EventName = trigger?.ToString() ?? "WorkOrderUpdated", AggregateType = "WorkOrder", AggregateId = Key(row), AggregateVersion = row.Revision, + Trigger = trigger, OccurredOn = Now, CorrelationId = row.RequestId, + Payload = new { WorkOrderId = row.Id, row.Revision, row.Status, row.Priority, row.TargetUnitId, row.TargetGroupId, row.InventoryAssetId, row.AssignedToRoleId, row.DueOn } }); + events.Add(entry.DomainEventOutboxId); + } + private async Task ChangedAsync(ChecklistActor actor, WorkOrder row, WorkOrderActivityType type, List events, string note = null, int? previous = null, WorkflowTriggerEventType? trigger = null) + { + var snapshot = type is WorkOrderActivityType.Updated or WorkOrderActivityType.StatusChanged ? Decode(row.Content).Fields : null; + row.Revision++; await SaveAsync(actor, row); await ActivityAsync(actor, row, type, note, previous, previous.HasValue ? row.Status : null, snapshot); await EventAsync(row, trigger, events); + } + private static void Validate(WorkOrderInput input) + { + if (input?.Content == null || !Guid.TryParseExact(input.RequestId, "D", out _) || !Enum.IsDefined(input.Type) || !Enum.IsDefined(input.Priority)) throw new WorkOrderException(400, "InvalidInput"); + var c = input.Content; Text(c.Title, 200, true); Text(c.Description, 20000); Text(c.LocationText, 1000); Text(c.CostCenter, 200); + foreach (var v in new[] { c.VendorDetails, c.WarrantyReference, c.ProcedureReference, c.ProcedureVersion, c.PermitReference, c.IsolationReference, c.QualifiedPersonnel, c.Resolution, c.Cause, c.VerificationEvidence }) Text(v, 4000); + if (c.Currency == null || c.Currency.Length != 3 || c.Currency.Any(ch => ch < 'A' || ch > 'Z')) throw new WorkOrderException(400, "InvalidInput"); + Money(c.EstimatedCost); Money(c.ApprovedCost); + if (c.Steps == null || c.Steps.Count > 100 || c.Steps.Any(s => s == null || string.IsNullOrWhiteSpace(s.Text) || s.Text.Length > 1000)) throw new WorkOrderException(400, "InvalidInput"); + if (input.DueOn.HasValue && (input.DueOn.Value.Year < 2000 || input.DueOn.Value.Year > 2200)) throw new WorkOrderException(400, "InvalidInput"); + if (input.DueOn.HasValue) input.DueOn = DateTime.SpecifyKind(input.DueOn.Value, DateTimeKind.Utc); + } + private static void Apply(WorkOrder row, WorkOrderInput input) { row.Type = (int)input.Type; row.Priority = (int)input.Priority; row.TargetUnitId = input.TargetUnitId; row.TargetGroupId = input.TargetGroupId; row.InventoryAssetId = input.InventoryAssetId; row.DueOn = input.DueOn; } + private static string Fingerprint(WorkOrderInput input) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { input.Type, input.Priority, input.TargetUnitId, input.TargetGroupId, input.InventoryAssetId, input.DueOn, input.Content })))); + public async Task CreateAsync(ChecklistActor actor, WorkOrderInput input) + { + Validate(input); await RequireWriteAsync(actor); await _authorization.ValidateTargetAsync(actor, input); + var id = await TransactionAsync(actor, async events => + { + await _authorization.ValidateTargetAsync(actor, input); + if ((input.Content.ApprovedCost.HasValue || !string.IsNullOrEmpty(input.Content.VerificationEvidence) || !string.IsNullOrEmpty(input.Content.Resolution)) && !await _authorization.CanManageAsync(actor, input.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + var hash = Fingerprint(input); var existing = await _store.RequestAsync(actor.DepartmentId, input.RequestId); + if (existing != null) + { + if (existing.CreatedBy != actor.UserId) throw new WorkOrderException(409, "Conflict"); + existing = await RevealAsync(actor, existing); + if (Decode(existing.Content).RequestHash != hash) throw new WorkOrderException(409, "Conflict"); + return existing.Id; + } + var row = New(actor); row.RequestId = input.RequestId; row.NumberYear = Now.Year; row.NumberSequence = await _store.NextNumberAsync(actor.DepartmentId, row.NumberYear); Apply(row, input); + row.Content = JsonConvert.SerializeObject(new StoredContent { Fields = input.Content, RequestHash = hash }); + await SaveAsync(actor, row, true); await ActivityAsync(actor, row, WorkOrderActivityType.Created, snapshot: input.Content); await EventAsync(row, WorkflowTriggerEventType.WorkOrderCreated, events); return row.Id; + }); + return await GetAsync(actor, id); + } + public async Task UpdateAsync(ChecklistActor actor, int id, WorkOrderInput input) + { + Validate(input); + await TransactionAsync(actor, async events => + { + var row = await ReadOrderAsync(actor, id); Revision(row, input.Revision); var manage = await _authorization.CanManageAsync(actor, row.TargetGroupId); + if (Terminal(row) || row.Status == (int)WorkOrderStatus.Completed || !manage && (row.Status != 0 || row.CreatedBy != actor.UserId)) throw new WorkOrderException(403, "PermissionRequired"); + await _authorization.ValidateTargetAsync(actor, input); + if (manage && !await _authorization.CanManageAsync(actor, input.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + var document = Decode(row.Content); + if (row.StartedOn.HasValue && (document.Fields.SafetyCritical && !input.Content.SafetyCritical || document.Fields.HazardousWork && !input.Content.HazardousWork)) throw new WorkOrderException(409, "SafetyRequirements"); + if (!manage && (input.Content.ApprovedCost.HasValue || input.Content.Resolution != document.Fields.Resolution || input.Content.VerificationEvidence != document.Fields.VerificationEvidence)) throw new WorkOrderException(403, "PermissionRequired"); + document.Fields = input.Content; row.Content = JsonConvert.SerializeObject(document); Apply(row, input); + await ChangedAsync(actor, row, WorkOrderActivityType.Updated, events); return true; + }); + } + public static WorkOrderStatus[] NextStatuses(WorkOrderStatus current) => current switch + { + WorkOrderStatus.Requested => new[] { WorkOrderStatus.Accepted, WorkOrderStatus.Rejected, WorkOrderStatus.Duplicate, WorkOrderStatus.Cancelled }, + WorkOrderStatus.Accepted => new[] { WorkOrderStatus.Cancelled }, + WorkOrderStatus.Assigned => new[] { WorkOrderStatus.InProgress, WorkOrderStatus.OnHold, WorkOrderStatus.Cancelled }, + WorkOrderStatus.InProgress => new[] { WorkOrderStatus.OnHold, WorkOrderStatus.Completed, WorkOrderStatus.Cancelled }, + WorkOrderStatus.OnHold => new[] { WorkOrderStatus.InProgress, WorkOrderStatus.Cancelled }, + WorkOrderStatus.Completed => new[] { WorkOrderStatus.Closed, WorkOrderStatus.InProgress }, + WorkOrderStatus.Closed or WorkOrderStatus.Rejected or WorkOrderStatus.Duplicate or WorkOrderStatus.Cancelled => new[] { WorkOrderStatus.Accepted }, + _ => Array.Empty() + }; + public async Task TransitionAsync(ChecklistActor actor, int id, WorkOrderTransition input) + { + if (input == null || !Enum.IsDefined(input.Status)) throw new WorkOrderException(400, "InvalidInput"); + Text(input.Reason, 4000); Text(input.Resolution, 4000); Text(input.Cause, 4000); Text(input.VerificationEvidence, 4000); + await TransactionAsync(actor, async events => + { + var row = await ReadOrderAsync(actor, id); Revision(row, input.Revision); var old = row.Status; + if (!NextStatuses((WorkOrderStatus)old).Contains(input.Status)) throw new WorkOrderException(409, "InvalidTransition"); + var manage = await _authorization.CanManageAsync(actor, row.TargetGroupId); var contribute = await _authorization.CanContributeAsync(actor, row); + var technicianStep = input.Status is WorkOrderStatus.InProgress or WorkOrderStatus.OnHold or WorkOrderStatus.Completed; + if (!manage && (!contribute || !technicianStep || old == (int)WorkOrderStatus.Completed)) throw new WorkOrderException(403, "PermissionRequired"); + var document = Decode(row.Content); var c = document.Fields; + if (input.Status is WorkOrderStatus.OnHold or WorkOrderStatus.Cancelled or WorkOrderStatus.Rejected or WorkOrderStatus.Duplicate || Terminal(row) || old == (int)WorkOrderStatus.Completed && input.Status == WorkOrderStatus.InProgress) Text(input.Reason, 4000, true); + if (input.Status == WorkOrderStatus.Duplicate) + { + if (!input.DuplicateOfId.HasValue || input.DuplicateOfId == id) throw new WorkOrderException(400, "InvalidInput"); + var canonical = await ReadOrderAsync(actor, input.DuplicateOfId.Value); + if (canonical.Status == (int)WorkOrderStatus.Duplicate) throw new WorkOrderException(409, "InvalidTransition"); + row.DuplicateOfId = canonical.Id; + } + if (input.Status == WorkOrderStatus.Accepted) { row.TriagedOn = Now; row.ClosedOn = null; row.CompletedOn = null; row.CompletedBy = null; row.VerifiedBy = null; row.DuplicateOfId = null; foreach (var step in c.Steps) step.Completed = false; row.AssignmentAcceptedOn = null; row.AssignmentAcceptedBy = null; c.Resolution = null; c.Cause = null; c.VerificationEvidence = null; } + if (input.Status == WorkOrderStatus.InProgress) + { + if (!row.AssignmentAcceptedOn.HasValue) throw new WorkOrderException(409, "AcceptAssignmentFirst"); + if (c.HazardousWork && new[] { c.ProcedureReference, c.ProcedureVersion, c.PermitReference, c.IsolationReference, c.QualifiedPersonnel }.Any(string.IsNullOrWhiteSpace)) throw new WorkOrderException(409, "SafetyRequirements"); + if (old == (int)WorkOrderStatus.Completed) { foreach (var step in c.Steps) step.Completed = false; c.Resolution = null; c.Cause = null; } + row.StartedOn ??= Now; row.CompletedOn = null; row.CompletedBy = null; row.ClosedOn = null; row.VerifiedBy = null; c.VerificationEvidence = null; + } + if (input.Status == WorkOrderStatus.Completed) + { + Text(input.Resolution, 4000, true); Text(input.Cause, 4000, true); + if (c.Steps.Any(s => !s.Completed) && !input.ConfirmTasksComplete) throw new WorkOrderException(409, "TasksIncomplete"); + foreach (var step in c.Steps) step.Completed = true; + c.Resolution = input.Resolution; c.Cause = input.Cause; row.CompletedOn = Now; row.CompletedBy = actor.UserId; + } + if (input.Status == WorkOrderStatus.Closed) + { + Text(input.VerificationEvidence, 4000, true); + if ((c.SafetyCritical || c.HazardousWork) && row.CompletedBy == actor.UserId) throw new WorkOrderException(403, "IndependentVerificationRequired"); + c.VerificationEvidence = input.VerificationEvidence; row.VerifiedBy = actor.UserId; row.ClosedOn = Now; + } + row.Status = (int)input.Status; row.Content = JsonConvert.SerializeObject(document); + await ChangedAsync(actor, row, WorkOrderActivityType.StatusChanged, events, input.Reason, old, WorkflowTriggerEventType.WorkOrderStatusChanged); return true; + }); + } + public async Task AssignAsync(ChecklistActor actor, int id, WorkOrderAssignment input) + { + if (input == null) throw new WorkOrderException(400, "InvalidInput"); + await TransactionAsync(actor, async events => + { + var row = await ReadOrderAsync(actor, id); Revision(row, input.Revision); + if (!await _authorization.CanManageAsync(actor, row.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + if (row.Status is not (1 or 2 or 3 or 4)) throw new WorkOrderException(409, "InvalidTransition"); + await _authorization.ValidateAssignmentAsync(actor, row, input.UserId, input.RoleId); + if (row.AssignedToUserId == input.UserId && row.AssignedToRoleId == input.RoleId && row.AssignedOn.HasValue) return true; + var old = row.Status; row.AssignedToUserId = string.IsNullOrEmpty(input.UserId) ? null : input.UserId; row.AssignedToRoleId = input.RoleId; row.AssignedOn = Now; row.AssignmentAcceptedOn = null; row.AssignmentAcceptedBy = null; row.Status = (int)WorkOrderStatus.Assigned; + await ChangedAsync(actor, row, WorkOrderActivityType.Assigned, events, previous: old, trigger: WorkflowTriggerEventType.WorkOrderAssigned); + if (old != row.Status) await EventAsync(row, WorkflowTriggerEventType.WorkOrderStatusChanged, events); return true; + }); + } + public async Task AcceptAssignmentAsync(ChecklistActor actor, int id, int revision) + { + await TransactionAsync(actor, async events => + { + var row = await ReadOrderAsync(actor, id); Revision(row, revision); + if (row.Status != (int)WorkOrderStatus.Assigned || !(await _authorization.RecipientsAsync(actor.DepartmentId, row)).Contains(actor.UserId)) throw new WorkOrderException(403, "PermissionRequired"); + if (row.AssignmentAcceptedOn.HasValue) return true; + row.AssignmentAcceptedOn = Now; row.AssignmentAcceptedBy = actor.UserId; await ChangedAsync(actor, row, WorkOrderActivityType.AssignmentAccepted, events); return true; + }); + } + private async Task ContributionAsync(ChecklistActor actor, int id, int revision) + { + var row = await ReadOrderAsync(actor, id); Revision(row, revision); + if (Terminal(row) || row.Status == (int)WorkOrderStatus.Completed || !await _authorization.CanContributeAsync(actor, row)) throw new WorkOrderException(403, "PermissionRequired"); + return row; + } + public async Task CommentAsync(ChecklistActor actor, int id, int revision, string note) + { + Text(note, 10000, true); + await TransactionAsync(actor, async events => { var row = await ReadOrderAsync(actor, id); Revision(row, revision); if (Terminal(row) || row.CreatedBy != actor.UserId && !await _authorization.CanContributeAsync(actor, row)) throw new WorkOrderException(403, "PermissionRequired"); await ChangedAsync(actor, row, WorkOrderActivityType.Comment, events, note); return true; }); + } + public async Task AddLaborAsync(ChecklistActor actor, int id, WorkOrderLaborInput input) + { + if (input?.Content == null || input.Content.Hours <= 0 || input.Content.Hours > 24 || decimal.Round(input.Content.Hours, 2) != input.Content.Hours || input.WorkDate.Year < 2000 || input.WorkDate > Now.AddDays(1)) throw new WorkOrderException(400, "InvalidInput"); + Money(input.Content.RatePerHour); Text(input.Content.Note, 4000); + await TransactionAsync(actor, async events => + { + var row = await ContributionAsync(actor, id, input.Revision); var user = string.IsNullOrEmpty(input.UserId) ? actor.UserId : input.UserId; + await _authorization.RequireMemberAsync(new ChecklistActor { DepartmentId = actor.DepartmentId, UserId = user }); + if (user != actor.UserId && !await _authorization.CanManageAsync(actor, row.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + var labor = New(actor, id); labor.UserId = user; labor.WorkDate = DateTime.SpecifyKind(input.WorkDate, DateTimeKind.Utc); labor.Content = JsonConvert.SerializeObject(input.Content); await SaveAsync(actor, labor, true); + await ChangedAsync(actor, row, WorkOrderActivityType.LaborAdded, events); return true; + }); + } + public async Task AddPartAsync(ChecklistActor actor, int id, WorkOrderPartInput input) + { + if (input?.Content == null || input.Content.Quantity <= 0 || input.Content.Quantity > 100000 || decimal.Round(input.Content.Quantity, 3) != input.Content.Quantity) throw new WorkOrderException(400, "InvalidInput"); + Text(input.Content.Description, 2000, true); Money(input.Content.UnitCost); input.Content.VoidReason = null; + await TransactionAsync(actor, async events => { var row = await ContributionAsync(actor, id, input.Revision); var part = New(actor, id); part.Content = JsonConvert.SerializeObject(input.Content); await SaveAsync(actor, part, true); await ChangedAsync(actor, row, WorkOrderActivityType.PartAdded, events); return true; }); + } + public async Task VoidPartAsync(ChecklistActor actor, int id, int partId, int revision, string reason) + { + Text(reason, 4000, true); + await TransactionAsync(actor, async events => + { + var row = await ContributionAsync(actor, id, revision); var part = await _store.GetAsync(actor.DepartmentId, partId); + if (part?.WorkOrderId != id || part.VoidedOn.HasValue || part.InventoryTransactionId != null) throw new WorkOrderException(409, "Unavailable"); + await RevealAsync(actor, part); var c = Decode(part.Content); c.VoidReason = reason; part.Content = JsonConvert.SerializeObject(c); part.VoidedOn = Now; part.Revision++; await SaveAsync(actor, part); + await ChangedAsync(actor, row, WorkOrderActivityType.PartVoided, events, reason); return true; + }); + } + public Task ChoicesAsync(ChecklistActor actor) => _authorization.ChoicesAsync(actor); + private static WorkOrderSummary Summary(WorkOrder row, WorkOrderContent c) => new WorkOrderSummary { Id = row.Id, Number = $"WO-{row.NumberYear}-{row.NumberSequence:D6}", Title = c.Title, Status = (WorkOrderStatus)row.Status, Priority = (WorkOrderPriority)row.Priority, + Revision = row.Revision, UpdatedOn = row.UpdatedOn, CreatedOn = row.CreatedOn, DueOn = row.DueOn, AssignedToUserId = row.AssignedToUserId, AssignedToRoleId = row.AssignedToRoleId, UnitId = row.TargetUnitId, GroupId = row.TargetGroupId, AssetId = row.InventoryAssetId }; + public async Task ListAsync(ChecklistActor actor, WorkOrderFilter filter) + { + if (filter == null || filter.Page < 0 || filter.Page > 10000 || filter.Status.HasValue && !Enum.IsDefined(filter.Status.Value) || filter.Priority.HasValue && !Enum.IsDefined(filter.Priority.Value) || filter.AssetId != null && !Guid.TryParseExact(filter.AssetId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); + var scope = await _authorization.ScopeAsync(actor); var rows = await _store.ListAsync(actor.DepartmentId, scope, filter); + var page = new WorkOrderPage { HasMore = rows.Count > 50, CanWrite = await _access.CanUseMaintenanceAsync(actor.DepartmentId) }; + foreach (var row in rows.Take(50)) + { + await RevealAsync(actor, row); var item = Summary(row, Decode(row.Content).Fields); + if (page.CanWrite && row.Status == 0 && await _authorization.CanManageAsync(actor, row.TargetGroupId)) item.QuickStatus = WorkOrderStatus.Accepted; + if (page.CanWrite && row.Status == 2 && row.AssignmentAcceptedOn.HasValue && await _authorization.CanContributeAsync(actor, row)) item.QuickStatus = WorkOrderStatus.InProgress; + page.Items.Add(item); + } + return page; + } + private async Task> ChildrenAsync(ChecklistActor actor, int id) where T : WorkOrderRow + { + var result = new List(); + for (var skip = 0; ; skip += 500) { var page = await _store.ChildrenAsync(actor.DepartmentId, id, skip); foreach (var row in page) result.Add(await RevealAsync(actor, row)); if (page.Count < 500) return result; if (skip >= 9500) throw new WorkOrderException(400, "HistoryLimit"); } + } + public async Task GetAsync(ChecklistActor actor, int id) + { + var row = await ReadOrderAsync(actor, id); var c = Decode(row.Content).Fields; var manage = await _authorization.CanManageAsync(actor, row.TargetGroupId); var contribute = await _authorization.CanContributeAsync(actor, row); + var write = await _access.CanUseMaintenanceAsync(actor.DepartmentId); + var detail = new WorkOrderDetail { Order = Summary(row, c), Input = new WorkOrderInput { RequestId = row.RequestId, Revision = row.Revision, Type = (WorkOrderType)row.Type, Priority = (WorkOrderPriority)row.Priority, TargetUnitId = row.TargetUnitId, TargetGroupId = row.TargetGroupId, InventoryAssetId = row.InventoryAssetId, DueOn = row.DueOn, Content = c }, + CanWrite = write, CanManage = manage && write, CanEdit = write && !Terminal(row) && row.Status != 5 && (manage || row.Status == 0 && row.CreatedBy == actor.UserId), CanContribute = write && contribute && !Terminal(row) && row.Status != 5, + CanAccept = write && row.Status == 2 && !row.AssignmentAcceptedOn.HasValue && (await _authorization.RecipientsAsync(actor.DepartmentId, row)).Contains(actor.UserId), ReportedBy = row.CreatedBy, VerifiedBy = row.VerifiedBy, CompletedOn = row.CompletedOn, ClosedOn = row.ClosedOn }; + if (write) detail.Transitions = NextStatuses((WorkOrderStatus)row.Status).Where(s => manage || contribute && row.Status != 5 && s is WorkOrderStatus.InProgress or WorkOrderStatus.OnHold or WorkOrderStatus.Completed).ToArray(); + detail.Activities = (await ChildrenAsync(actor, id)).Select(a => { var activity = Decode(a.Content); activity.Id = a.Id; activity.Type = (WorkOrderActivityType)a.ActivityType; activity.UserId = a.CreatedBy; activity.CreatedOn = a.CreatedOn; activity.OldStatus = a.OldStatus; activity.NewStatus = a.NewStatus; return activity; }).ToList(); + detail.Labor = (await ChildrenAsync(actor, id)).Select(l => new WorkOrderLaborView { Id = l.Id, UserId = l.UserId, WorkDate = l.WorkDate, Content = Decode(l.Content) }).ToList(); + detail.Parts = (await ChildrenAsync(actor, id)).Select(p => new WorkOrderPartView { Id = p.Id, VoidedOn = p.VoidedOn, Content = Decode(p.Content) }).ToList(); + detail.Files = (await ChildrenAsync(actor, id)).Select(f => new WorkOrderFileView { Id = f.Id, Name = f.Content, ContentType = f.ContentType, Size = f.Size, WithdrawnOn = f.WithdrawnOn }).ToList(); + return detail; + } + } +} diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 003ad310a..d3c6a63e7 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -78,6 +78,12 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve { switch (eventType) { + case WorkflowTriggerEventType.WorkOrderCreated: + case WorkflowTriggerEventType.WorkOrderStatusChanged: + case WorkflowTriggerEventType.WorkOrderAssigned: + obj["work_order"] = new ScriptObject { ["id"] = 123, ["revision"] = 2, ["status"] = 2, ["priority"] = 1, ["unit_id"] = 12, ["group_id"] = 3, ["role_id"] = 4, ["asset_id"] = null, ["due_on"] = "2026-09-09T08:00:00Z", ["title"] = ProtectedDataEnvelope.RedactionValue, ["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/WorkOrders/Detail/123" }; + obj["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = new ScriptArray { "Title" }, ["catalog_version"] = 18 }; + break; case WorkflowTriggerEventType.ChecklistCompleted: case WorkflowTriggerEventType.ChecklistFailed: case WorkflowTriggerEventType.ChecklistMissed: diff --git a/Core/Resgrid.Services/WorkflowService.cs b/Core/Resgrid.Services/WorkflowService.cs index 2b6c13700..a5ca8fda5 100644 --- a/Core/Resgrid.Services/WorkflowService.cs +++ b/Core/Resgrid.Services/WorkflowService.cs @@ -378,7 +378,7 @@ public async Task ExecuteWorkflowAsync( } catch (Exception ex) { - if (checklist) Logging.LogError($"Checklist workflow failed for run {run.WorkflowRunId}."); else Logging.LogException(ex); + if (checklist) Logging.LogError($"Checklist workflow failed for run {run.WorkflowRunId}: {ex.GetType().FullName}."); else Logging.LogException(ex); run.Status = (int)WorkflowRunStatus.Failed; run.ErrorMessage = $"Failed to build template context: {ex.Message}"; run.CompletedOn = DateTime.UtcNow; @@ -670,7 +670,7 @@ public async Task ExecuteWorkflowAsync( logEntry.DurationMs = sw.ElapsedMilliseconds; logEntry.CompletedOn = DateTime.UtcNow; anyFailure = true; - if (checklist) Logging.LogError($"Checklist workflow failed for run {run.WorkflowRunId}."); else Logging.LogException(ex); + if (checklist) Logging.LogError($"Checklist workflow failed for run {run.WorkflowRunId}: {ex.GetType().FullName}."); else Logging.LogException(ex); } await InsertLogAsync(departmentId, checklist, logEntry, cancellationToken); @@ -815,12 +815,22 @@ private async Task DisplayRunAsync(WorkflowRun run) if (run == null || !ChecklistWorkflowPayload.IsChecklist(run.TriggerEventType)) return run; var copy = await History.ForDisplayAsync(run.DepartmentId, run, ReadinessHistoryFields.Runs); if (copy.Logs != null) - copy.Logs = await Task.WhenAll(copy.Logs.Select(log => History.ForDisplayAsync(run.DepartmentId, log, ReadinessHistoryFields.Logs))); + { + var logs = new List(); + foreach (var log in copy.Logs) + logs.Add(await History.ForDisplayAsync(run.DepartmentId, log, ReadinessHistoryFields.Logs)); + copy.Logs = logs; + } return copy; } private async Task> DisplayRunsAsync(IEnumerable runs) - => (await Task.WhenAll((runs ?? Enumerable.Empty()).Select(DisplayRunAsync))).ToList(); + { + var display = new List(); + foreach (var run in runs ?? Enumerable.Empty()) + display.Add(await DisplayRunAsync(run)); + return display; + } public async Task ClearPendingRunsAsync(int departmentId, CancellationToken cancellationToken = default) { diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index c28e9c716..a2c45fa25 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -400,6 +400,18 @@ public async Task BuildContextAsync( } break; } + case WorkflowTriggerEventType.WorkOrderCreated: + case WorkflowTriggerEventType.WorkOrderStatusChanged: + case WorkflowTriggerEventType.WorkOrderAssigned: + { + var orderEvent = TryDeserialize(eventPayloadJson); + var payload = orderEvent?.Payload ?? new JObject(); var order = new ScriptObject(); + foreach (var pair in Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Variables) order[pair.Variable] = ToScriptValue(payload[pair.Property]); + order["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/WorkOrders/Detail/{payload["WorkOrderId"]?.Value()}"; + scriptObject["work_order"] = order; + scriptObject["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = ToScriptValue(new JArray("Title")), ["catalog_version"] = 18 }; + break; + } case WorkflowTriggerEventType.ChecklistCompleted: case WorkflowTriggerEventType.ChecklistFailed: case WorkflowTriggerEventType.ChecklistMissed: diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs index a27ce34d1..4feba313e 100644 --- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs @@ -62,7 +62,7 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro _eventAggregator.AddListener(chatEventTopicHandler); _eventAggregator.AddAsyncListener(async message => { - if (message.ProducerSubsystem != "Checklists" || message.IsReplay) return; + if (message.ProducerSubsystem != "Checklists") return; if (_rabbitTopicProvider == null) _rabbitTopicProvider = new RabbitTopicProvider(); if (!await _rabbitTopicProvider.ChecklistUpdated(message.DepartmentId, message.AggregateId)) throw new InvalidOperationException("Checklist event delivery failed; the outbox will retry."); diff --git a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs index 4a3c3c0d4..dc31d5791 100644 --- a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs @@ -147,7 +147,7 @@ private static async Task HandleDomainEventAsync(DomainEventDispatchedEvent disp return; // Replays (activation, legacy indexing, operator re-drives) must never flood workflows. - if (dispatched.IsReplay && dispatched.ProducerSubsystem != "Checklists") + if (dispatched.IsReplay && !ChecklistWorkflowPayload.IsReadinessProducer(dispatched.ProducerSubsystem)) return; var trigger = (WorkflowTriggerEventType)dispatched.TriggerEventType.Value; @@ -202,17 +202,18 @@ private static async Task HandleEventAsync(int departmentId, WorkflowTriggerEven if (workflows == null || workflows.Count == 0) return; - payloadJson = envelope.ProducerSubsystem == "Checklists" + payloadJson = ChecklistWorkflowPayload.IsReadinessProducer(envelope.ProducerSubsystem) ? await ChecklistWorkflowPayload.ProjectAsync(departmentId, eventObj, _protectedProjectionService, wrapped: true) : await _protectedProjectionService.BuildSafeWorkflowPayloadAsync(departmentId, eventObj); - if (envelope.ProducerSubsystem == "Checklists") + if (ChecklistWorkflowPayload.IsReadinessProducer(envelope.ProducerSubsystem)) { // Retry a run whose database insert succeeded but whose queue send did not. // Its stable run ID is claimed atomically by the worker before executing actions. + var existingRuns = (await _runRepository.GetByWorkflowsAndEventAsync(departmentId, workflows.Select(w => w.WorkflowId).ToArray(), envelope.EventId)) + .GroupBy(r => r.WorkflowId).ToDictionary(g => g.Key, g => g.OrderBy(r => r.StartedOn).First()); foreach (var workflow in workflows.ToList()) { - var existing = await _runRepository.GetByWorkflowAndEventAsync(workflow.WorkflowId, envelope.EventId); - if (existing == null) continue; + if (!existingRuns.TryGetValue(workflow.WorkflowId, out var existing)) continue; if (existing.DepartmentId != departmentId) throw new InvalidOperationException("Workflow event tenant mismatch."); if (existing.Status == (int)WorkflowRunStatus.Pending) await RequeueChecklistRunAsync(existing, payloadJson); workflows.Remove(workflow); @@ -268,7 +269,7 @@ private static async Task HandleEventAsync(int departmentId, WorkflowTriggerEven foreach (var workflow in workflows) { - if (envelope?.ProducerSubsystem != "Checklists" && await IsDuplicateAsync(workflow.WorkflowId, envelope)) continue; + if (!ChecklistWorkflowPayload.IsReadinessProducer(envelope?.ProducerSubsystem) && await IsDuplicateAsync(workflow.WorkflowId, envelope)) continue; var run = WorkflowRunEnvelope.Apply(new WorkflowRun { @@ -285,12 +286,12 @@ private static async Task HandleEventAsync(int departmentId, WorkflowTriggerEven try { - if (envelope?.ProducerSubsystem == "Checklists") await ProtectChecklistRunAsync(run); + if (ChecklistWorkflowPayload.IsReadinessProducer(envelope?.ProducerSubsystem)) await ProtectChecklistRunAsync(run); run = await _runRepository.InsertAsync(run, CancellationToken.None); } catch (Exception ex) when (envelope != null && WorkflowRunEnvelope.IsDuplicateKeyViolation(ex)) { - if (envelope.ProducerSubsystem == "Checklists") + if (ChecklistWorkflowPayload.IsReadinessProducer(envelope.ProducerSubsystem)) { var existing = await _runRepository.GetByWorkflowAndEventAsync(workflow.WorkflowId, envelope.EventId); if (existing == null || existing.DepartmentId != departmentId) throw; @@ -382,7 +383,7 @@ private static async Task RecordSkippedAsync(System.Collections.Generic.List { using var command = connection.CreateCommand(); command.Transaction = transaction; - command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistReminders")}) + (SELECT COUNT(*) FROM {Q("DepartmentChecklistSettings")})"; + command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistReminders")}) + (SELECT COUNT(*) FROM {Q("DepartmentChecklistSettings")} WHERE {Q("RemindersEnabled")}=1 OR {Q("NotifyBeforeMinutes")}<>60 OR {Q("NotifyMissed")}=0 OR {Q("DigestMode")}=0 OR {Q("EscalateAfterMinutes")} IS NOT NULL OR {Q("RemindersActiveFromUtc")} IS NOT NULL)"; if (Convert.ToInt64(command.ExecuteScalar()) > 0) throw new InvalidOperationException("Reminder settings and delivery history must be preserved; populated reminder storage cannot be rolled back."); }); Delete.Table(N("ChecklistReminders")); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0197_AddWorkOrders.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0197_AddWorkOrders.cs new file mode 100644 index 000000000..d112438be --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0197_AddWorkOrders.cs @@ -0,0 +1,97 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(197)] + public class M0197_AddWorkOrders : Migration + { + private static string N(string value) => value; + public override void Up() + { + foreach (var name in new[] { "WorkOrders", "WorkOrderActivities", "WorkOrderLabors", "WorkOrderParts", "WorkOrderFiles" }) + { + var table = Create.Table(N(name)).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() + .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false); + switch (name) + { + case "WorkOrders": + table.WithColumn(N("RequestId")).AsString(36).NotNullable() + .WithColumn(N("NumberYear")).AsInt32().NotNullable().WithColumn(N("NumberSequence")).AsInt32().NotNullable() + .WithColumn(N("Type")).AsInt32().NotNullable().WithColumn(N("Priority")).AsInt32().NotNullable().WithColumn(N("Status")).AsInt32().NotNullable() + .WithColumn(N("SourceType")).AsInt32().NotNullable() + .WithColumn(N("SourceChecklistCompletionId")).AsString(36).Nullable().WithColumn(N("SourceChecklistItemId")).AsString(36).Nullable().WithColumn(N("SourceOccurrenceId")).AsString(36).Nullable() + .WithColumn(N("TargetUnitId")).AsInt32().Nullable().WithColumn(N("TargetGroupId")).AsInt32().Nullable().WithColumn(N("InventoryAssetId")).AsString(36).Nullable() + .WithColumn(N("AssignedToUserId")).AsString(128).Nullable().WithColumn(N("AssignedToRoleId")).AsInt32().Nullable() + .WithColumn(N("DueOn")).AsDateTime2().Nullable().WithColumn(N("TriagedOn")).AsDateTime2().Nullable().WithColumn(N("AssignedOn")).AsDateTime2().Nullable() + .WithColumn(N("AssignmentAcceptedOn")).AsDateTime2().Nullable().WithColumn(N("AssignmentAcceptedBy")).AsString(128).Nullable() + .WithColumn(N("StartedOn")).AsDateTime2().Nullable().WithColumn(N("CompletedOn")).AsDateTime2().Nullable().WithColumn(N("CompletedBy")).AsString(128).Nullable() + .WithColumn(N("ClosedOn")).AsDateTime2().Nullable().WithColumn(N("VerifiedBy")).AsString(128).Nullable().WithColumn(N("DuplicateOfId")).AsInt32().Nullable() + .WithColumn(N("SetUnitOutOfService")).AsBoolean().NotNullable().WithDefaultValue(false).WithColumn(N("RestoreUnitStateOnClose")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("PreviousUnitStateType")).AsInt32().Nullable().WithColumn(N("WorkOrderRecurrenceId")).AsString(36).Nullable() + .WithColumn(N("IsDeleted")).AsBoolean().NotNullable().WithDefaultValue(false); break; + case "WorkOrderActivities": table.WithColumn(N("ActivityType")).AsInt32().NotNullable().WithColumn(N("OldStatus")).AsInt32().Nullable().WithColumn(N("NewStatus")).AsInt32().Nullable(); break; + case "WorkOrderLabors": table.WithColumn(N("UserId")).AsString(128).NotNullable().WithColumn(N("WorkDate")).AsDateTime2().NotNullable(); break; + case "WorkOrderParts": table.WithColumn(N("InventoryItemId")).AsString(36).Nullable().WithColumn(N("InventoryTransactionId")).AsString(36).Nullable().WithColumn(N("VoidedOn")).AsDateTime2().Nullable(); break; + case "WorkOrderFiles": table.WithColumn(N("ContentType")).AsString(100).NotNullable().WithColumn(N("Size")).AsInt32().NotNullable().WithColumn(N("Sha256")).AsString(64).NotNullable() + .WithColumn(N("Data")).AsBinary(int.MaxValue).Nullable().WithColumn(N("ScanState")).AsInt32().NotNullable().WithColumn(N("WithdrawnOn")).AsDateTime2().Nullable(); break; + } + if (name != "WorkOrders") Alter.Column(N("WorkOrderId")).OnTable(N(name)).AsInt32().NotNullable(); + Index(name, "TenantId", true, "DepartmentId", "Id"); + Index(name, "ParentDate", false, "DepartmentId", "WorkOrderId", "CreatedOn"); + if (name != "WorkOrders") Create.ForeignKey(N("FK_" + name + "_Order")).FromTable(N(name)).ForeignColumns(N("DepartmentId"), N("WorkOrderId")).ToTable(N("WorkOrders")).PrimaryColumns(N("DepartmentId"), N("Id")); + } + Create.Table(N("ReadinessProBillingAccounts")) + .WithColumn(N("DepartmentId")).AsInt32().NotNullable().PrimaryKey() + .WithColumn(N("Provider")).AsString(20).NotNullable() + .WithColumn(N("CustomerId")).AsString(100).NotNullable() + .WithColumn(N("PlanAddonId")).AsString(36).NotNullable() + .WithColumn(N("PriceId")).AsString(100).NotNullable() + .WithColumn(N("SubscriptionId")).AsString(100).Nullable() + .WithColumn(N("CheckoutId")).AsString(100).Nullable() + .WithColumn(N("CheckoutUrl")).AsString(2048).Nullable() + .WithColumn(N("CheckoutExpiresOn")).AsDateTime2().Nullable() + .WithColumn(N("CheckoutAttempt")).AsString(36).Nullable() + .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable(); + Create.Table(N("WorkOrderNotifications")) + .WithColumn(N("DepartmentId")).AsInt32().NotNullable().PrimaryKey() + .WithColumn(N("EventId")).AsString(36).NotNullable().PrimaryKey() + .WithColumn(N("UserId")).AsString(128).NotNullable().PrimaryKey() + .WithColumn(N("WorkOrderId")).AsInt32().NotNullable() + .WithColumn(N("State")).AsInt32().NotNullable() + .WithColumn(N("LeaseOwner")).AsString(36).Nullable() + .WithColumn(N("LeaseExpiresOn")).AsDateTime2().Nullable() + .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable(); + Create.ForeignKey(N("FK_WorkOrderNotifications_Order")).FromTable(N("WorkOrderNotifications")).ForeignColumns(N("DepartmentId"), N("WorkOrderId")).ToTable(N("WorkOrders")).PrimaryColumns(N("DepartmentId"), N("Id")); + Index("WorkOrders", "Number", true, "DepartmentId", "NumberYear", "NumberSequence"); + Index("WorkOrders", "Request", true, "DepartmentId", "RequestId"); + Index("WorkOrders", "StatusPriority", false, "DepartmentId", "Status", "Priority", "Id"); + Index("WorkOrders", "Assignee", false, "DepartmentId", "AssignedToUserId", "Status"); + Index("WorkOrders", "Role", false, "DepartmentId", "AssignedToRoleId", "Status"); + Index("WorkOrders", "Unit", false, "DepartmentId", "TargetUnitId", "Id"); + Index("WorkOrders", "Group", false, "DepartmentId", "TargetGroupId", "Id"); + Index("WorkOrders", "Asset", false, "DepartmentId", "InventoryAssetId", "Id"); + Index("WorkOrders", "Source", false, "DepartmentId", "SourceOccurrenceId", "SourceChecklistItemId"); + Create.ForeignKey(N("FK_WorkOrders_Duplicate")).FromTable(N("WorkOrders")).ForeignColumns(N("DepartmentId"), N("DuplicateOfId")).ToTable(N("WorkOrders")).PrimaryColumns(N("DepartmentId"), N("Id")); + } + private void Index(string table, string suffix, bool unique, params string[] columns) + { + var index = Create.Index(N((unique ? "UX_" : "IX_") + table + "_" + suffix)).OnTable(N(table)).OnColumn(N(columns[0])).Ascending(); + for (var i = 1; i < columns.Length; i++) index = index.OnColumn(N(columns[i])).Ascending(); + if (unique) index.WithOptions().Unique(); + } + public override void Down() + { + // Never discard maintenance evidence or encrypted envelopes during a schema rollback. + Execute.Sql("IF EXISTS (SELECT 1 FROM WorkOrders) OR EXISTS (SELECT 1 FROM ReadinessProBillingAccounts) THROW 51000, 'Work-order evidence must be exported and removed through the authorized retention process before rollback.', 1;"); + Delete.ForeignKey(N("FK_WorkOrders_Duplicate")).OnTable(N("WorkOrders")); + foreach (var table in new[] { "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders" }) Delete.Table(N(table)); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0195_AddChecklistRemindersPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0195_AddChecklistRemindersPg.cs index 1dd907b4d..05e99e126 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0195_AddChecklistRemindersPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0195_AddChecklistRemindersPg.cs @@ -39,7 +39,7 @@ public override void Down() Execute.WithConnection((connection, transaction) => { using var command = connection.CreateCommand(); command.Transaction = transaction; - command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistReminders")}) + (SELECT COUNT(*) FROM {Q("DepartmentChecklistSettings")})"; + command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistReminders")}) + (SELECT COUNT(*) FROM {Q("DepartmentChecklistSettings")} WHERE {Q("RemindersEnabled")}=TRUE OR {Q("NotifyBeforeMinutes")}<>60 OR {Q("NotifyMissed")}=FALSE OR {Q("DigestMode")}=FALSE OR {Q("EscalateAfterMinutes")} IS NOT NULL OR {Q("RemindersActiveFromUtc")} IS NOT NULL)"; if (Convert.ToInt64(command.ExecuteScalar()) > 0) throw new InvalidOperationException("Reminder settings and delivery history must be preserved; populated reminder storage cannot be rolled back."); }); Delete.Table(N("ChecklistReminders")); diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0197_AddWorkOrdersPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0197_AddWorkOrdersPg.cs new file mode 100644 index 000000000..ae9d7ce87 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0197_AddWorkOrdersPg.cs @@ -0,0 +1,97 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(197)] + public class M0197_AddWorkOrdersPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + public override void Up() + { + foreach (var name in new[] { "WorkOrders", "WorkOrderActivities", "WorkOrderLabors", "WorkOrderParts", "WorkOrderFiles" }) + { + var table = Create.Table(N(name)).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() + .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false); + switch (name) + { + case "WorkOrders": + table.WithColumn(N("RequestId")).AsString(36).NotNullable() + .WithColumn(N("NumberYear")).AsInt32().NotNullable().WithColumn(N("NumberSequence")).AsInt32().NotNullable() + .WithColumn(N("Type")).AsInt32().NotNullable().WithColumn(N("Priority")).AsInt32().NotNullable().WithColumn(N("Status")).AsInt32().NotNullable() + .WithColumn(N("SourceType")).AsInt32().NotNullable() + .WithColumn(N("SourceChecklistCompletionId")).AsString(36).Nullable().WithColumn(N("SourceChecklistItemId")).AsString(36).Nullable().WithColumn(N("SourceOccurrenceId")).AsString(36).Nullable() + .WithColumn(N("TargetUnitId")).AsInt32().Nullable().WithColumn(N("TargetGroupId")).AsInt32().Nullable().WithColumn(N("InventoryAssetId")).AsString(36).Nullable() + .WithColumn(N("AssignedToUserId")).AsString(128).Nullable().WithColumn(N("AssignedToRoleId")).AsInt32().Nullable() + .WithColumn(N("DueOn")).AsDateTime2().Nullable().WithColumn(N("TriagedOn")).AsDateTime2().Nullable().WithColumn(N("AssignedOn")).AsDateTime2().Nullable() + .WithColumn(N("AssignmentAcceptedOn")).AsDateTime2().Nullable().WithColumn(N("AssignmentAcceptedBy")).AsString(128).Nullable() + .WithColumn(N("StartedOn")).AsDateTime2().Nullable().WithColumn(N("CompletedOn")).AsDateTime2().Nullable().WithColumn(N("CompletedBy")).AsString(128).Nullable() + .WithColumn(N("ClosedOn")).AsDateTime2().Nullable().WithColumn(N("VerifiedBy")).AsString(128).Nullable().WithColumn(N("DuplicateOfId")).AsInt32().Nullable() + .WithColumn(N("SetUnitOutOfService")).AsBoolean().NotNullable().WithDefaultValue(false).WithColumn(N("RestoreUnitStateOnClose")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("PreviousUnitStateType")).AsInt32().Nullable().WithColumn(N("WorkOrderRecurrenceId")).AsString(36).Nullable() + .WithColumn(N("IsDeleted")).AsBoolean().NotNullable().WithDefaultValue(false); break; + case "WorkOrderActivities": table.WithColumn(N("ActivityType")).AsInt32().NotNullable().WithColumn(N("OldStatus")).AsInt32().Nullable().WithColumn(N("NewStatus")).AsInt32().Nullable(); break; + case "WorkOrderLabors": table.WithColumn(N("UserId")).AsString(128).NotNullable().WithColumn(N("WorkDate")).AsDateTime2().NotNullable(); break; + case "WorkOrderParts": table.WithColumn(N("InventoryItemId")).AsString(36).Nullable().WithColumn(N("InventoryTransactionId")).AsString(36).Nullable().WithColumn(N("VoidedOn")).AsDateTime2().Nullable(); break; + case "WorkOrderFiles": table.WithColumn(N("ContentType")).AsString(100).NotNullable().WithColumn(N("Size")).AsInt32().NotNullable().WithColumn(N("Sha256")).AsString(64).NotNullable() + .WithColumn(N("Data")).AsBinary(int.MaxValue).Nullable().WithColumn(N("ScanState")).AsInt32().NotNullable().WithColumn(N("WithdrawnOn")).AsDateTime2().Nullable(); break; + } + if (name != "WorkOrders") Alter.Column(N("WorkOrderId")).OnTable(N(name)).AsInt32().NotNullable(); + Index(name, "TenantId", true, "DepartmentId", "Id"); + Index(name, "ParentDate", false, "DepartmentId", "WorkOrderId", "CreatedOn"); + if (name != "WorkOrders") Create.ForeignKey(N("FK_" + name + "_Order")).FromTable(N(name)).ForeignColumns(N("DepartmentId"), N("WorkOrderId")).ToTable(N("WorkOrders")).PrimaryColumns(N("DepartmentId"), N("Id")); + } + Create.Table(N("ReadinessProBillingAccounts")) + .WithColumn(N("DepartmentId")).AsInt32().NotNullable().PrimaryKey() + .WithColumn(N("Provider")).AsString(20).NotNullable() + .WithColumn(N("CustomerId")).AsString(100).NotNullable() + .WithColumn(N("PlanAddonId")).AsString(36).NotNullable() + .WithColumn(N("PriceId")).AsString(100).NotNullable() + .WithColumn(N("SubscriptionId")).AsString(100).Nullable() + .WithColumn(N("CheckoutId")).AsString(100).Nullable() + .WithColumn(N("CheckoutUrl")).AsString(2048).Nullable() + .WithColumn(N("CheckoutExpiresOn")).AsDateTime2().Nullable() + .WithColumn(N("CheckoutAttempt")).AsString(36).Nullable() + .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable(); + Create.Table(N("WorkOrderNotifications")) + .WithColumn(N("DepartmentId")).AsInt32().NotNullable().PrimaryKey() + .WithColumn(N("EventId")).AsString(36).NotNullable().PrimaryKey() + .WithColumn(N("UserId")).AsString(128).NotNullable().PrimaryKey() + .WithColumn(N("WorkOrderId")).AsInt32().NotNullable() + .WithColumn(N("State")).AsInt32().NotNullable() + .WithColumn(N("LeaseOwner")).AsString(36).Nullable() + .WithColumn(N("LeaseExpiresOn")).AsDateTime2().Nullable() + .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable(); + Create.ForeignKey(N("FK_WorkOrderNotifications_Order")).FromTable(N("WorkOrderNotifications")).ForeignColumns(N("DepartmentId"), N("WorkOrderId")).ToTable(N("WorkOrders")).PrimaryColumns(N("DepartmentId"), N("Id")); + Index("WorkOrders", "Number", true, "DepartmentId", "NumberYear", "NumberSequence"); + Index("WorkOrders", "Request", true, "DepartmentId", "RequestId"); + Index("WorkOrders", "StatusPriority", false, "DepartmentId", "Status", "Priority", "Id"); + Index("WorkOrders", "Assignee", false, "DepartmentId", "AssignedToUserId", "Status"); + Index("WorkOrders", "Role", false, "DepartmentId", "AssignedToRoleId", "Status"); + Index("WorkOrders", "Unit", false, "DepartmentId", "TargetUnitId", "Id"); + Index("WorkOrders", "Group", false, "DepartmentId", "TargetGroupId", "Id"); + Index("WorkOrders", "Asset", false, "DepartmentId", "InventoryAssetId", "Id"); + Index("WorkOrders", "Source", false, "DepartmentId", "SourceOccurrenceId", "SourceChecklistItemId"); + Create.ForeignKey(N("FK_WorkOrders_Duplicate")).FromTable(N("WorkOrders")).ForeignColumns(N("DepartmentId"), N("DuplicateOfId")).ToTable(N("WorkOrders")).PrimaryColumns(N("DepartmentId"), N("Id")); + } + private void Index(string table, string suffix, bool unique, params string[] columns) + { + var index = Create.Index(N((unique ? "UX_" : "IX_") + table + "_" + suffix)).OnTable(N(table)).OnColumn(N(columns[0])).Ascending(); + for (var i = 1; i < columns.Length; i++) index = index.OnColumn(N(columns[i])).Ascending(); + if (unique) index.WithOptions().Unique(); + } + public override void Down() + { + // Never discard maintenance evidence or encrypted envelopes during a schema rollback. + Execute.Sql("DO $guard$ BEGIN IF EXISTS (SELECT 1 FROM workorders) OR EXISTS (SELECT 1 FROM readinessprobillingaccounts) THEN RAISE EXCEPTION 'Work-order evidence must be exported and removed through the authorized retention process before rollback.'; END IF; END $guard$;"); + Delete.ForeignKey(N("FK_WorkOrders_Duplicate")).OnTable(N("WorkOrders")); + foreach (var table in new[] { "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders" }) Delete.Table(N(table)); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs index 4f9a2f331..e4f426128 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs @@ -32,7 +32,7 @@ async Task Exists(string table) // conservatively when any active hold exists, even if its content is encrypted. if (await Exists("RmsRecordLegalHolds") && await connection.ExecuteScalarAsync(new CommandDefinition($"SELECT COUNT(*) FROM {Q("RmsRecordLegalHolds")} WHERE {Q("DepartmentId")}=@DepartmentId AND {Q("ReleasedOn")} IS NULL", new { DepartmentId = departmentId }, transaction, cancellationToken: ct)) > 0) throw new InvalidOperationException("Department readiness evidence is retained under an active legal hold."); - var triggers = new[] { WorkflowTriggerEventType.ChecklistCompleted, WorkflowTriggerEventType.ChecklistFailed, WorkflowTriggerEventType.ChecklistMissed, WorkflowTriggerEventType.ChecklistScheduleChanged, WorkflowTriggerEventType.ChecklistOccurrenceSkipped }.Select(t => (int)t).ToArray(); + var triggers = ChecklistWorkflowPayload.Triggers.ToArray(); var triggerPredicate = Q("TriggerEventType") + (pg ? "=ANY(@Triggers)" : " IN @Triggers"); var auditPredicate = Q("LogType") + (pg ? "=ANY(@AuditTypes)" : " IN @AuditTypes"); var parameters = new { DepartmentId = departmentId, Triggers = triggers, AuditTypes = ReadinessHistoryFields.AuditTypes }; @@ -41,9 +41,9 @@ async Task Exists(string table) if (await Exists("WorkflowRunLogs")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("WorkflowRunLogs")} WHERE {Q("WorkflowRunId")} IN (SELECT {Q("WorkflowRunId")} FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=@DepartmentId AND {triggerPredicate})", parameters, transaction, cancellationToken: ct)); await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=@DepartmentId AND {triggerPredicate}", parameters, transaction, cancellationToken: ct)); } - if (await Exists("DomainEventOutbox")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("DomainEventOutbox")} WHERE {Q("DepartmentId")}=@DepartmentId AND {Q("ProducerSubsystem")}='Checklists'", parameters, transaction, cancellationToken: ct)); + if (await Exists("DomainEventOutbox")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("DomainEventOutbox")} WHERE {Q("DepartmentId")}=@DepartmentId AND {Q("ProducerSubsystem")} IN ('Checklists','WorkOrders')", parameters, transaction, cancellationToken: ct)); if (await Exists("AuditLogs")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("AuditLogs")} WHERE {Q("DepartmentId")}=@DepartmentId AND {auditPredicate}", parameters, transaction, cancellationToken: ct)); - foreach (var table in new[] { "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) + foreach (var table in new[] { "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders", "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) if (await Exists(table)) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q(table)} WHERE {Q("DepartmentId")}=@DepartmentId", parameters, transaction, cancellationToken: ct)); } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs index 6aa97fef0..0f1cef853 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs @@ -18,6 +18,8 @@ public partial class ChecklistRepository : RmsRepositoryBase LockRecordsDepartmentAsync(departmentId, ct); + public async Task> ReportOccurrencesAsync(int departmentId, DateTime fromUtc, DateTime untilUtc, int skip, CancellationToken ct = default) => (await QueryAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("ChecklistOccurrences")} WHERE {Col("DepartmentId")}={P}DepartmentId AND COALESCE({Col("PeriodStartUtc")},{Col("CreatedOn")})>={P}From AND COALESCE({Col("PeriodStartUtc")},{Col("CreatedOn")})<{P}Until ORDER BY {Col("CreatedOn")},{Col("Id")} {Paging()}", new { DepartmentId = departmentId, From = fromUtc, Until = untilUtc, Skip = skip, Take = 500 }, ct)).ToList(); private static string Table() where T : ChecklistRow => ChecklistTables.All[typeof(T)]; private static class ColumnCache where T : ChecklistRow { @@ -36,6 +38,23 @@ public async Task> ListAsync(int departmentId, string parentId = null var parent = parentId == null ? "" : $" AND {Col("ParentId")}={P}ParentId"; return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId{parent} ORDER BY {Col("CreatedOn")} DESC, {Col("Id")} {Paging()}", new { DepartmentId = departmentId, ParentId = parentId, Skip = skip, Take = take }, ct)).ToList(); } + public async Task> ListForMemberAsync(int departmentId, string userId, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow + { + if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentException("A member is required.", nameof(userId)); + if (skip < 0) throw new ArgumentOutOfRangeException(nameof(skip)); + if (take < 1 || take > 500) throw new ArgumentOutOfRangeException(nameof(take)); + var target = $"({Col("TargetType")}={P}Personnel AND {Col("TargetId")}={P}UserId)"; + var owned = $"({Col("CreatedBy")}={P}UserId OR {target})"; + string member; + if (typeof(T) == typeof(ChecklistCompletion)) member = $"({owned} OR {Col("WitnessUserId")}={P}UserId)"; + else if (typeof(T) == typeof(ChecklistSchedule)) member = owned; + else if (typeof(T) == typeof(ChecklistOccurrence)) + // Witnessing alone does not grant an export of another member's occurrence metadata. + member = $"({target} OR {Col("Id")} IN (SELECT {Col("OccurrenceId")} FROM {Tbl("ChecklistCompletions")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {owned}) OR {Col("ScheduleId")} IN (SELECT {Col("Id")} FROM {Tbl("ChecklistSchedules")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {owned}))"; + else throw new InvalidOperationException("This checklist table does not support member exports."); + return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {member} ORDER BY {Col("CreatedOn")} DESC, {Col("Id")} {Paging()}", + new { DepartmentId = departmentId, UserId = userId, Personnel = (int)ChecklistTargetType.Personnel, Skip = skip, Take = take }, ct)).ToList(); + } public async Task> ListChildrenAsync(int departmentId, IReadOnlyCollection parentIds, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow { if (parentIds == null || parentIds.Count > 100) throw new ArgumentOutOfRangeException(nameof(parentIds)); diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistSchedulingRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistSchedulingRepository.cs index 6bad7e1c2..c2435483e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChecklistSchedulingRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistSchedulingRepository.cs @@ -13,10 +13,10 @@ public async Task LockAccessFenceAsync(CancellationToken ct = default) { if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist access changes require a transaction."); var sql = Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres - ? "SELECT id FROM checklistaccessfence WHERE id=1 FOR UPDATE" : "SELECT [Id] FROM [ChecklistAccessFence] WITH (UPDLOCK,HOLDLOCK) WHERE [Id]=1"; + ? $"SELECT id FROM {Tbl("ChecklistAccessFence")} WHERE id=1 FOR UPDATE" : $"SELECT [Id] FROM {Tbl("ChecklistAccessFence")} WITH (UPDLOCK,HOLDLOCK) WHERE [Id]=1"; if (await ScalarAsync(sql, null, ct) != 1) throw new InvalidOperationException("Checklist access fence is unavailable."); } - public async Task ApplyAccessStateAsync(int departmentId, bool enabled, DateTime nowUtc, CancellationToken ct = default, bool inventoryEnabled = true) + public async Task ApplyAccessStateAsync(int departmentId, bool enabled, DateTime nowUtc, bool inventoryEnabled = true, CancellationToken ct = default) { if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist access changes require a transaction."); foreach (var schedule in await QueryAsync($"SELECT {Col("Id")},{Col("IsSuspended")},{Col("TargetType")} FROM {Tbl("ChecklistSchedules")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("IsActive")}={P}Active", new { DepartmentId = departmentId, Active = true }, ct)) diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs index d1f857682..65fd96d71 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs @@ -304,7 +304,15 @@ private string Scope(AdpTableBinding binding) var filter = binding.Discriminator; // Discriminators are code-owned constants. Integers and escaped string literals keep the same // scope on reads, verification and counts without broadening the shared table to other features. - var discriminator = filter == null ? "" : filter.Text != null + if (filter?.OnParent == true && !string.IsNullOrEmpty(binding.DepartmentColumn)) + throw new InvalidOperationException("A parent-scoped ADP discriminator requires a parent-keyed binding."); + if (filter?.Texts != null && (filter.Texts.Count == 0 || filter.Texts.Any(t => t == null))) + throw new InvalidOperationException("Missing ADP discriminator values."); + if (filter != null && filter.Texts == null && filter.Text == null && !(filter.Integers?.Count > 0)) + throw new InvalidOperationException("Missing ADP discriminator values."); + var discriminator = filter == null ? "" : filter.Texts != null + ? $"{Ident(filter.Column)} IN ({string.Join(",", filter.Texts.Select(t => "\'" + t.Replace("\'", "\'\'") + "\'"))})" + : filter.Text != null ? $"{Ident(filter.Column)} = '{filter.Text.Replace("'", "''")}'" : $"{Ident(filter.Column)} IN ({string.Join(",", filter.Integers ?? throw new InvalidOperationException("Missing ADP discriminator values."))})"; var scope = !string.IsNullOrEmpty(binding.DepartmentColumn) diff --git a/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs index db1fe6804..d1e4228b3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs @@ -10,15 +10,16 @@ public class FeatureFlagRepository : RepositoryBase, IFeatureFlagRe { private readonly IConnectionProvider _connections; private readonly IUnitOfWork _unit; + private readonly SqlConfiguration _sqlConfiguration; public FeatureFlagRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { - _connections = connectionProvider; _unit = unitOfWork; + _connections = connectionProvider; _unit = unitOfWork; _sqlConfiguration = sqlConfiguration; } public async System.Threading.Tasks.Task TouchEvaluationAsync(int flagId, System.DateTime evaluatedOn, System.Threading.CancellationToken ct = default) { // Never re-save a cached definition for telemetry: that could restore an old flag state. - const string sql = "UPDATE FeatureFlags SET LastEvaluatedOn=@evaluatedOn WHERE FeatureFlagId=@flagId AND (LastEvaluatedOn IS NULL OR LastEvaluatedOn<@evaluatedOn)"; + var sql = $"UPDATE {_sqlConfiguration.SchemaName}.FeatureFlags SET LastEvaluatedOn=@evaluatedOn WHERE FeatureFlagId=@flagId AND (LastEvaluatedOn IS NULL OR LastEvaluatedOn<@evaluatedOn)"; if (_unit.Connection != null) await Dapper.SqlMapper.ExecuteAsync(_unit.Connection, new Dapper.CommandDefinition(sql, new { flagId, evaluatedOn }, _unit.Transaction, cancellationToken: ct)); else diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index de9fb1ffa..6fde3fc10 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -15,6 +15,8 @@ public class ApiDataModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index fac1c5324..c1b7adc5d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -15,6 +15,8 @@ public class DataModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index 89065a094..a8f3a1b6d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -15,6 +15,8 @@ public class NonWebDataModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index d0361c8a9..83ef07911 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -15,6 +15,8 @@ public class TestingDataModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/ReadinessProBillingRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ReadinessProBillingRepository.cs new file mode 100644 index 000000000..77ac046d8 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ReadinessProBillingRepository.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed class ReadinessProBillingRepository : RmsRepositoryBase, IReadinessProBillingRepository + { + public ReadinessProBillingRepository(IConnectionProvider connection, SqlConfiguration config, IUnitOfWork uow, IQueryFactory queries) : base(connection, config, uow, queries) { } + public Task LockDepartmentAsync(int departmentId) => LockRecordsDepartmentAsync(departmentId, default); + public Task GetAsync(int departmentId) => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("ReadinessProBillingAccounts")} WHERE {Col("DepartmentId")}={P}DepartmentId", new { DepartmentId = departmentId }, default); + + public async Task FindAsync(string provider, string customerId, string subscriptionId, string checkoutId) + { + customerId = string.IsNullOrWhiteSpace(customerId) ? null : customerId; + subscriptionId = string.IsNullOrWhiteSpace(subscriptionId) ? null : subscriptionId; + checkoutId = string.IsNullOrWhiteSpace(checkoutId) ? null : checkoutId; + if (customerId == null && subscriptionId == null && checkoutId == null) return null; + var matches = (await QueryAsync($"SELECT * FROM {Tbl("ReadinessProBillingAccounts")} WHERE {Col("Provider")}={P}Provider AND ({Col("CustomerId")}={P}CustomerId OR {Col("SubscriptionId")}={P}SubscriptionId OR {Col("CheckoutId")}={P}CheckoutId)", + new { Provider = provider, CustomerId = customerId, SubscriptionId = subscriptionId, CheckoutId = checkoutId }, default)).ToList(); + if (matches.Count > 1) throw new InvalidOperationException("Readiness billing ownership is ambiguous."); + return matches.SingleOrDefault(); + } + public async Task SaveAsync(ReadinessProBillingAccount account) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Readiness billing writes require the department transaction."); + var columns = typeof(ReadinessProBillingAccount).GetProperties().Select(p => p.Name).ToArray(); + var existing = await GetAsync(account.DepartmentId); + await ExecuteAsync(existing == null ? $"INSERT INTO {Tbl("ReadinessProBillingAccounts")} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})" + : $"UPDATE {Tbl("ReadinessProBillingAccounts")} SET {string.Join(",", columns.Where(c => c != "DepartmentId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId", account, default); + } + public async Task> PaymentsAsync(int departmentId, string planAddonId) => (await QueryAsync($"SELECT * FROM {Tbl("PaymentAddons")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("PlanAddonId")}={P}PlanAddonId", new { DepartmentId = departmentId, PlanAddonId = planAddonId }, default)).ToList(); + public async Task SavePaymentAsync(PaymentAddon payment, bool insert) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Readiness billing writes require the department transaction."); + var columns = typeof(PaymentAddon).GetProperties().Where(p => p.CanWrite && !payment.IgnoredProperties.Contains(p.Name)).Select(p => p.Name).ToArray(); + await ExecuteAsync(insert ? $"INSERT INTO {Tbl("PaymentAddons")} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})" + : $"UPDATE {Tbl("PaymentAddons")} SET {string.Join(",", columns.Where(c => c != "DepartmentId" && c != "PaymentAddonId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("PaymentAddonId")}={P}PaymentAddonId AND {Col("PlanAddonId")}={P}PlanAddonId", payment, default); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index 65a4841ba..a05e961b6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -106,6 +106,11 @@ protected RmsRepositoryBase(IConnectionProvider connectionProvider, SqlConfigura protected static bool IsPostgres => DataConfig.DatabaseType == DatabaseTypes.Postgres; + // Records store UTC clock values in PostgreSQL timestamp columns without a time zone. + // DateTime2 preserves precision on both providers, but Npgsql requires an Unspecified kind. + protected static DateTime DatabaseTimestamp(DateTime value) + => IsPostgres ? DateTime.SpecifyKind(value, DateTimeKind.Unspecified) : value; + protected string P => SqlConfiguration.ParameterNotation; /// Schema-qualified table reference in the dialect's identifier style. @@ -677,12 +682,12 @@ public class DomainEventOutboxRepository : RmsRepositoryBase InitializeChecklistPayloadAsync(DomainEventOutboxEntry entry, CancellationToken cancellationToken = default) { if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist event initialization requires the producer transaction."); - return await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Payload WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("EventId")} = {P}EventId AND {Col("ProducerSubsystem")} = 'Checklists' AND {Col("State")} = {P}Pending AND {Col("LeaseOwner")} IS NULL AND {Col("PayloadJson")} = '{{}}'", + return await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Payload WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("EventId")} = {P}EventId AND {Col("ProducerSubsystem")} IN ('Checklists','WorkOrders') AND {Col("State")} = {P}Pending AND {Col("LeaseOwner")} IS NULL AND {Col("PayloadJson")} = '{{}}'", new { Id = entry.DomainEventOutboxId, entry.DepartmentId, entry.EventId, Payload = entry.PayloadJson, Pending = (int)DomainEventOutboxState.Pending }, cancellationToken) == 1; } public async Task ReplaceChecklistPayloadAsync(DomainEventOutboxEntry entry, string safePayload, CancellationToken cancellationToken = default) { - return await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Payload, {Col("LastError")} = {P}LastError WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("ProducerSubsystem")} = 'Checklists' AND {Col("State")} = {P}Pending AND {Col("LeaseOwner")} = {P}Owner AND {Col("Attempts")} = {P}Attempts AND {Col("LeaseExpiresOn")} > {P}Now", + return await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Payload, {Col("LastError")} = {P}LastError WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("ProducerSubsystem")} IN ('Checklists','WorkOrders') AND {Col("State")} = {P}Pending AND {Col("LeaseOwner")} = {P}Owner AND {Col("Attempts")} = {P}Attempts AND {Col("LeaseExpiresOn")} > {P}Now", new { Id = entry.DomainEventOutboxId, entry.DepartmentId, Payload = safePayload, entry.LastError, Pending = (int)DomainEventOutboxState.Pending, Owner = entry.LeaseOwner, entry.Attempts, Now = DateTime.UtcNow }, cancellationToken) == 1; } public DomainEventOutboxRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) @@ -990,7 +995,7 @@ public Task> GetModifiedSinceAsync(int de parameters.Add("SinceId", sinceId); } - parameters.Add("Since", since.Value, System.Data.DbType.DateTime2); + parameters.Add("Since", DatabaseTimestamp(since.Value), System.Data.DbType.DateTime2); } return QueryAsync( @@ -1049,7 +1054,7 @@ public async Task> GetByIdsAsync(int depa if (query.OccurredSince.HasValue) { sb.Append($" AND COALESCE(p.{Col("FinalizedOn")}, p.{Col("OccurredOn")}, p.{Col("RecordCreatedOn")}) >= {P}OccurredSince"); - parameters.Add("OccurredSince", query.OccurredSince.Value, System.Data.DbType.DateTime2); + parameters.Add("OccurredSince", DatabaseTimestamp(query.OccurredSince.Value), System.Data.DbType.DateTime2); } if (query.CallId.HasValue) diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs index 291da2fa4..8a35cf884 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs @@ -56,8 +56,8 @@ public async Task CompleteSearchErasureAsync(RmsSearchErasureTarget target // PurgedOn is datetime2 on SQL Server. Binding it as datetime rounds the // database value on replay and can make an otherwise identical acknowledgement fail. var parameters = new DynamicParameters(new { target.DepartmentId, target.RecordId }); - parameters.Add("PurgedOn", target.PurgedOn, DbType.DateTime2); - parameters.Add("CompletedOn", completedOn, DbType.DateTime2); + parameters.Add("PurgedOn", DatabaseTimestamp(target.PurgedOn), DbType.DateTime2); + parameters.Add("CompletedOn", DatabaseTimestamp(completedOn), DbType.DateTime2); return await ExecuteAsync($"UPDATE {Tbl(table)} SET {Col("SearchErasedOn")}={P}CompletedOn WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col(id)}={P}RecordId AND {Col("PurgedOn")}={P}PurgedOn AND {Col("SearchErasedOn")} IS NULL", parameters, cancellationToken) == 1; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs new file mode 100644 index 000000000..5a4b4eb54 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.WorkOrders; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed class WorkOrderRepository : RmsRepositoryBase, IWorkOrderRepository + { + public WorkOrderRepository(IConnectionProvider connection, SqlConfiguration config, IUnitOfWork uow, IQueryFactory queries) : base(connection, config, uow, queries) { } + private static string Table() where T : WorkOrderRow => WorkOrderTables.All[typeof(T)]; + private static string[] Columns(bool data = true) where T : WorkOrderRow => typeof(T).GetProperties().Where(p => p.CanWrite && !Attribute.IsDefined(p, typeof(NotMappedAttribute)) && (data || p.Name != "Data")).Select(p => p.Name).ToArray(); + public async Task ClaimNotificationAsync(WorkOrderNotification row, DateTime now) + { + Transaction(); + var key = new { row.DepartmentId, row.EventId, row.UserId }; + var existing = await QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkOrderNotifications")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("EventId")}={P}EventId AND {Col("UserId")}={P}UserId", key, default); + if (existing?.State is 2 or 3) return existing.State; + if (existing?.State == 1 && existing.LeaseExpiresOn > now) return -1; + row.State = 1; row.LeaseExpiresOn = now.AddMinutes(30); row.UpdatedOn = now; + if (existing == null) + await ExecuteAsync($"INSERT INTO {Tbl("WorkOrderNotifications")} ({Cols("DepartmentId","WorkOrderId","EventId","UserId","State","LeaseOwner","LeaseExpiresOn","UpdatedOn")}) VALUES ({P}DepartmentId,{P}WorkOrderId,{P}EventId,{P}UserId,{P}State,{P}LeaseOwner,{P}LeaseExpiresOn,{P}UpdatedOn)", row, default); + else await ExecuteAsync($"UPDATE {Tbl("WorkOrderNotifications")} SET {Col("State")}=1,{Col("LeaseOwner")}={P}LeaseOwner,{Col("LeaseExpiresOn")}={P}LeaseExpiresOn,{Col("UpdatedOn")}={P}UpdatedOn WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("EventId")}={P}EventId AND {Col("UserId")}={P}UserId", row, default); + return 1; + } + public async Task FinishNotificationAsync(WorkOrderNotification row, int state, DateTime now) + { + Transaction(); + return await ExecuteAsync($"UPDATE {Tbl("WorkOrderNotifications")} SET {Col("State")}={P}State,{Col("LeaseExpiresOn")}=NULL,{Col("UpdatedOn")}={P}Now WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("EventId")}={P}EventId AND {Col("UserId")}={P}UserId AND {Col("State")}=1 AND {Col("LeaseOwner")}={P}LeaseOwner AND {Col("LeaseExpiresOn")}>{P}Now", new { row.DepartmentId, row.EventId, row.UserId, row.LeaseOwner, State = state, Now = now }, default) == 1; + } + public Task LockDepartmentAsync(int departmentId) => LockRecordsDepartmentAsync(departmentId, default); + private void Transaction() { if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Work-order writes require a transaction."); } + public Task GetAsync(int departmentId, int id, bool includeData = true) where T : WorkOrderRow => QueryFirstOrDefaultAsync($"SELECT {Cols(Columns(includeData))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }, default); + public Task RequestAsync(int departmentId, string requestId) => QueryFirstOrDefaultAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("RequestId")}={P}RequestId", new { DepartmentId = departmentId, RequestId = requestId }, default); + public async Task> ListAsync(int departmentId, WorkOrderReadScope scope, WorkOrderFilter filter) + { + if (scope == null || string.IsNullOrWhiteSpace(scope.UserId) || filter.Page < 0 || filter.Page > 10000) throw new ArgumentException("Invalid work-order scope."); + var parameters = new DynamicParameters(new { DepartmentId = departmentId, UserId = scope.UserId, AllowedGroup = scope.GroupId, Status = (int?)filter.Status, Priority = (int?)filter.Priority, UnitId = filter.UnitId, GroupId = filter.GroupId, AssetId = filter.AssetId, Skip = filter.Page * 50, Take = 51 }); + parameters.Add("Roles", InListValue(scope.RoleIds.Length == 0 ? new[] { -1 } : scope.RoleIds)); + var own = $"({Col("CreatedBy")}={P}UserId OR {Col("AssignedToUserId")}={P}UserId OR {InList("AssignedToRoleId", "Roles")} OR {Col("TargetGroupId")}={P}AllowedGroup)"; + var conditions = new List { $"{Col("DepartmentId")}={P}DepartmentId" }; + if (!scope.All) conditions.Add(own); + if (filter.AssignedToMe) conditions.Add($"({Col("AssignedToUserId")}={P}UserId OR {InList("AssignedToRoleId", "Roles")})"); + foreach (var item in new[] { (filter.Status.HasValue, "Status", "Status"), (filter.Priority.HasValue, "Priority", "Priority"), (filter.UnitId.HasValue, "TargetUnitId", "UnitId"), (filter.GroupId.HasValue, "TargetGroupId", "GroupId"), (filter.AssetId != null, "InventoryAssetId", "AssetId") }) + if (item.Item1) conditions.Add(Col(item.Item2) + "=" + P + item.Item3); + return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {string.Join(" AND ", conditions)} ORDER BY {Col("Id")} DESC {Paging()}", parameters, default)).ToList(); + } + public async Task> ChildrenAsync(int departmentId, int orderId, int skip = 0) where T : WorkOrderRow + { + if (skip < 0 || typeof(T) == typeof(WorkOrder)) throw new ArgumentException("Invalid child page."); + return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("WorkOrderId")}={P}OrderId ORDER BY {Col("Id")} {Paging()}", new { DepartmentId = departmentId, OrderId = orderId, Skip = skip, Take = 500 }, default)).ToList(); + } + public Task NextNumberAsync(int departmentId, int year) + { + Transaction(); + return ScalarAsync($"SELECT COALESCE(MAX({Col("NumberSequence")}),0)+1 FROM {Tbl("WorkOrders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("NumberYear")}={P}Year", new { DepartmentId = departmentId, Year = year }, default); + } + public async Task AllocateAsync(T row) where T : WorkOrderRow + { + Transaction(); + if (row.Id != 0 || row.Content != null || row is WorkOrderFile file && file.Data != null) throw new InvalidOperationException("Allocate identities without sensitive content."); + var columns = Columns().Where(c => c != "Id").ToArray(); + var sql = $"INSERT INTO {Tbl(Table())} ({Cols(columns)}) {(IsPostgres ? "" : "OUTPUT INSERTED.[Id]")} VALUES ({string.Join(",", columns.Select(c => P + c))}) {(IsPostgres ? "RETURNING id" : "")}"; + row.Id = await ScalarAsync(sql, row, default); + } + public async Task WriteAsync(T row) where T : WorkOrderRow + { + Transaction(); + var columns = Columns().Where(c => c != "Id" && c != "DepartmentId"); + var immutable = typeof(T) == typeof(WorkOrderActivity) || typeof(T) == typeof(WorkOrderLabor) ? $" AND {Col("Content")} IS NULL" : ""; + if (await ExecuteAsync($"UPDATE {Tbl(Table())} SET {string.Join(",", columns.Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id{immutable}", row, default) != 1) throw new InvalidOperationException("Work-order evidence could not be saved."); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs index 74302c206..e1b601839 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Data.Common; using System.Threading.Tasks; using Dapper; @@ -17,8 +18,8 @@ public class WorkflowRunRepository : RmsRepositoryBase, IWorkflowRu { public async Task TryStartChecklistRunAsync(string runId, string workflowId, int departmentId, int attemptNumber, string safePayload) { - return await ExecuteAsync($"UPDATE {Tbl("WorkflowRuns")} SET {Col("Status")} = {P}Running, {Col("AttemptNumber")} = {P}Attempt, {Col("InputPayload")} = {P}Payload WHERE {Col("WorkflowRunId")} = {P}Id AND {Col("WorkflowId")} = {P}WorkflowId AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("TriggerEventType")} IN (67,68,69,164,165) AND (({P}Attempt = 1 AND {Col("Status")} = {P}Pending) OR ({P}Attempt > 1 AND {Col("Status")} = {P}Retrying AND {Col("AttemptNumber")} = {P}Previous))", - new { Id = runId, WorkflowId = workflowId, DepartmentId = departmentId, Attempt = attemptNumber, Previous = attemptNumber - 1, Payload = safePayload, Running = (int)WorkflowRunStatus.Running, Pending = (int)WorkflowRunStatus.Pending, Retrying = (int)WorkflowRunStatus.Retrying }) == 1; + return await ExecuteAsync($"UPDATE {Tbl("WorkflowRuns")} SET {Col("Status")} = {P}Running, {Col("AttemptNumber")} = {P}Attempt, {Col("InputPayload")} = {P}Payload WHERE {Col("WorkflowRunId")} = {P}Id AND {Col("WorkflowId")} = {P}WorkflowId AND {Col("DepartmentId")} = {P}DepartmentId AND {InList("TriggerEventType", "Triggers")} AND (({P}Attempt = 1 AND {Col("Status")} = {P}Pending) OR ({P}Attempt > 1 AND {Col("Status")} = {P}Retrying AND {Col("AttemptNumber")} = {P}Previous))", + new { Triggers = Resgrid.Model.Checklists.ChecklistWorkflowPayload.Triggers.ToArray(), Id = runId, WorkflowId = workflowId, DepartmentId = departmentId, Attempt = attemptNumber, Previous = attemptNumber - 1, Payload = safePayload, Running = (int)WorkflowRunStatus.Running, Pending = (int)WorkflowRunStatus.Pending, Retrying = (int)WorkflowRunStatus.Retrying }) == 1; } public override Task InsertAsync(WorkflowRun entity, System.Threading.CancellationToken cancellationToken, bool firstLevelOnly = false) => WriteRunAsync(entity, true, cancellationToken, firstLevelOnly); @@ -110,6 +111,16 @@ public async Task GetByWorkflowAndEventAsync(string workflowId, str catch (Exception ex) { Logging.LogException(ex); throw; } } + public async Task> GetByWorkflowsAndEventAsync(int departmentId, IReadOnlyCollection workflowIds, string eventId) + { + var result = new List(); + if (workflowIds == null || workflowIds.Count == 0 || string.IsNullOrWhiteSpace(eventId)) return result; + foreach (var ids in workflowIds.Distinct().Chunk(500)) + result.AddRange(await QueryAsync($"SELECT * FROM {Tbl("WorkflowRuns")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {InList("WorkflowId", "Ids")} AND {Col("EventId")}={P}EventId ORDER BY {Col("StartedOn")}", + new { DepartmentId = departmentId, Ids = ids, EventId = eventId })); + return result; + } + public async Task> GetPendingAndRunningByDepartmentIdAsync(int departmentId) { try @@ -207,4 +218,3 @@ public async Task DeleteAllByWorkflowIdAsync(string workflowId) } } - diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index cb16ad9f4..55c1f8618 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -81,5 +81,10 @@ "RecordPermitExpiring": 163, "ChecklistMissed": 69, "ChecklistScheduleChanged": 164, - "ChecklistOccurrenceSkipped": 165 + "ChecklistOccurrenceSkipped": 165, + "ChecklistCompleted": 67, + "ChecklistFailed": 68, + "WorkOrderCreated": 70, + "WorkOrderStatusChanged": 71, + "WorkOrderAssigned": 72 } diff --git a/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs b/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs new file mode 100644 index 000000000..cf39c1121 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Services.Records; +using Resgrid.Services.Records.Evidence; + +namespace Resgrid.Tests.Rms +{ + public partial class RecordsEvidenceServiceTests + { + [Test] + public async Task Readiness_PDF_and_manifest_are_immutable_classified_evidence_after_source_edit_purge_and_revision_binding() + { + var source = new ReadinessEvidenceManifestV1 { DepartmentId = Dept, CallId = 501, CallUtc = DateTime.UtcNow.AddHours(-2), GeneratedUtc = DateTime.UtcNow, + CoverageStartUtc = DateTime.UtcNow.AddDays(-30), CoverageEndUtc = DateTime.UtcNow.AddHours(-2), + Checklists = new List { new() { OccurrenceId = "source-occurrence", VersionId = "pinned-version", Version = 3, Name = "Original evidence", Target = new ChecklistTarget { Id = "1", Type = ChecklistTargetType.Unit, Name = "Engine 1" } } } }; + var checklists = new Mock(); checklists.Setup(c => c.GetReadinessPacketForCallAsync(It.IsAny(), 501, 30)).ReturnsAsync(() => source); + var access = new Mock(); access.Setup(a => a.CanUseChecklistsAsync(Dept)).ReturnsAsync(true); + var grant = Mock.Of(g => g.UserId == "author" && g.GrantToken == "synthetic-grant" && !g.IsWorkloadCaller); + var pdf = new Mock(); pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny())).Returns(Encoding.ASCII.GetBytes("%PDF-1.4 synthetic evidence")); + var adapter = new ReadinessPacketEvidenceAdapter(checklists.Object, access.Object, grant, pdf.Object); + var service = new RecordsEvidenceService(_store.EvidenceRepo.Object, _store.RecordsRepo.Object, _incidents.ReportsRepo.Object, + _store.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { adapter }, _authorization.Object, Mock.Of(), _references.Object, + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of())); + var artifact = await service.CaptureAsync(Request(RmsEvidenceKind.ReadinessPacket)); + artifact.Classification.Should().Be((int)RmsEvidenceClassification.Restricted); + artifact.ManifestJson.Should().Contain("Original evidence").And.Contain("PdfSha256").And.Contain("ManifestSha256"); + var json = artifact.ManifestJson; var checksum = artifact.Checksum; + await service.BindToRevisionAsync(Dept, _record.RmsOperationalRecordId, "finalized-revision"); + source.Checklists[0].Name = "Later edited source"; source.Checklists.Clear(); + var retained = (await service.GetForRecordAsync(Dept, _record.RmsOperationalRecordId, "finalized-revision"))[0]; + retained.ManifestJson.Should().Be(json); retained.Checksum.Should().Be(checksum); (await service.VerifyAsync(Dept, retained.RmsEvidenceArtifactId)).Should().BeTrue(); + checklists.Verify(c => c.GetReadinessPacketForCallAsync(It.Is(a => a.UserId == "author" && a.GrantToken == "synthetic-grant"), 501, 30), Times.Exactly(2)); + var request = Request(RmsEvidenceKind.ReadinessPacket); request.CapturedByUserId = "another-user"; + await FluentActions.Awaiting(() => adapter.CaptureAsync(request)).Should().ThrowAsync(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs index 2ab2409d8..1dbb618e8 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs @@ -21,7 +21,7 @@ namespace Resgrid.Tests.Rms /// artifact worth storing instead of a link. /// [TestFixture] - public class RecordsEvidenceServiceTests + public partial class RecordsEvidenceServiceTests { private const int Dept = 9; @@ -353,7 +353,7 @@ public async Task The_readiness_adapter_ships_and_explains_why_it_has_nothing() (await adapter.IsAvailableAsync(Dept)).Should().BeFalse(); var capture = await adapter.CaptureAsync(Request(RmsEvidenceKind.ReadinessPacket)); capture.Available.Should().BeFalse(); - capture.UnavailableReason.Should().Be(ReadinessPacketEvidenceAdapter.UnavailableReason); + capture.UnavailableReason.Should().Be(Resgrid.Services.ChecklistReportDocuments.Text("Checklists are disabled for this department.")); } } } diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index 6fcb0bda5..c01a2399f 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -90,7 +90,10 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() ((int)WorkflowTriggerEventType.ChecklistCompleted).Should().Be(67); ((int)WorkflowTriggerEventType.ChecklistFailed).Should().Be(68); - foreach (var value in Enumerable.Range(52, 48).Except(new[] { 67, 68, 69 })) + ((int)WorkflowTriggerEventType.WorkOrderCreated).Should().Be(70); + ((int)WorkflowTriggerEventType.WorkOrderStatusChanged).Should().Be(71); + ((int)WorkflowTriggerEventType.WorkOrderAssigned).Should().Be(72); + foreach (var value in Enumerable.Range(52, 48).Except(new[] { 67, 68, 69, 70, 71, 72 })) Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for another plan"); } diff --git a/Tests/Resgrid.Tests/Rms/RmsPostgreSqlDatabaseTests.cs b/Tests/Resgrid.Tests/Rms/RmsPostgreSqlDatabaseTests.cs index 96e803774..49a93b8f9 100644 --- a/Tests/Resgrid.Tests/Rms/RmsPostgreSqlDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsPostgreSqlDatabaseTests.cs @@ -41,12 +41,17 @@ public async Task MigrateOnlyAnIsolatedTestDatabase() await master.ExecuteAsync($"CREATE DATABASE {_database}"); _connection = new NpgsqlConnectionStringBuilder(_master) { Database = _database }.ConnectionString; await using (var db = new NpgsqlConnection(_connection)) + { + await db.OpenAsync(); await db.ExecuteAsync(@"CREATE EXTENSION citext; CREATE TABLE departments (departmentid integer PRIMARY KEY); INSERT INTO departments VALUES (11),(12); CREATE TABLE departmentsettings (departmentsettingid integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,departmentid integer NOT NULL,settingtype integer NOT NULL,setting text NOT NULL); CREATE TABLE workflowruns (workflowrunid citext PRIMARY KEY,workflowid citext NOT NULL,departmentid integer NOT NULL,status integer NOT NULL,completedon timestamp,inputpayload text,errormessage text); CREATE TABLE workflowrunlogs (workflowrunlogid citext PRIMARY KEY,workflowrunid citext NOT NULL,renderedoutput text,actionresult text,errormessage text);"); - var versions = new HashSet { 48, 49, 124, 142, 143, 144, 145, 150, 151, 153, 154, 155, 156, 157, 160, 164, 165, 166, 167, 168, 169, 170, 171, 173 }; + // The pool connected before citext existed; refresh its type map before querying migrated rows. + await db.ReloadTypesAsync(); + } + var versions = new HashSet { 48, 49, 124, 142, 143, 144, 145, 150, 151, 153, 154, 155, 156, 157, 160, 164, 165, 166, 167, 168, 169, 170, 171, 173, 176, 180, 182, 193 }; var migrations = typeof(M0150_AddRmsRecordsCorePg).Assembly.GetTypes().Where(t => typeof(IMigration).IsAssignableFrom(t) && !t.IsAbstract) .Where(t => t.GetCustomAttributes(typeof(MigrationAttribute), false).Cast().Any(a => versions.Contains(a.Version))) .Select(t => (IMigration)Activator.CreateInstance(t)).ToList(); @@ -68,9 +73,10 @@ public async Task RemoveOnlyTheDatabaseCreatedByThisFixture() await master.ExecuteAsync($"DROP DATABASE {_database} WITH (FORCE)"); } - private IConnectionProvider Connections() + private IConnectionProvider Connections(string timeZone = null) { - var provider = new Mock(); provider.Setup(p => p.Create()).Returns(() => new NpgsqlConnection(_connection)); return provider.Object; + var connection = timeZone == null ? _connection : new NpgsqlConnectionStringBuilder(_connection) { Timezone = timeZone }.ConnectionString; + var provider = new Mock(); provider.Setup(p => p.Create()).Returns(() => new NpgsqlConnection(connection)); return provider.Object; } private static IQueryFactory WriteQueries() @@ -147,19 +153,30 @@ public async Task Expired_shares_do_not_grant_scope_or_counts() (await records.CountVisibleAsync(11, new[] { (int)RmsRecordState.Finalized }, new List { 808 }, "reader")).Should().Be(0); } - [Test] - public async Task Precise_projection_paging_does_not_repeat_the_cursor_row() + [TestCase("UTC")] + [TestCase("Pacific/Auckland")] + public async Task Precise_projection_paging_does_not_repeat_the_cursor_row(string timeZone) { var first = Guid.NewGuid().ToString(); var second = Guid.NewGuid().ToString(); var ids = new[] { first, second }.OrderBy(s => s, StringComparer.Ordinal).ToArray(); await using var db = new NpgsqlConnection(_connection); - foreach (var id in ids) await db.ExecuteAsync("INSERT INTO rmsrecordsearchprojections (rmsrecordsearchprojectionid,departmentid,protectionid,sourcetype,sourceid,definitionkey,state,recordcreatedon,createdon,modifiedon,rowversion) VALUES (@Id,11,@Id,1,@Id,'system.training',4,now(),now(),timestamp '2050-01-01 12:34:56.123456',1)", new { Id = id }); - var connections = Connections(); using var unit = new UnitOfWork(connections); - var repository = new RmsRecordSearchProjectionsRepository(connections, new PostgreSqlConfiguration(), unit, WriteQueries()); - var page = (await repository.GetModifiedSinceAsync(11, new DateTime(2050, 1, 1, 0, 0, 0, DateTimeKind.Utc), 1)).Single(); - page.RmsRecordSearchProjectionId.Should().Be(ids[0]); - var next = (await repository.GetModifiedSinceAsync(11, page.ModifiedOn, 1, page.RmsRecordSearchProjectionId)).Single(); - next.RmsRecordSearchProjectionId.Should().Be(ids[1]); - (await repository.GetModifiedSinceAsync(11, next.ModifiedOn, 1, next.RmsRecordSearchProjectionId)).Should().BeEmpty(); + try + { + foreach (var id in ids) await db.ExecuteAsync("INSERT INTO rmsrecordsearchprojections (rmsrecordsearchprojectionid,departmentid,protectionid,sourcetype,sourceid,definitionkey,state,recordcreatedon,createdon,modifiedon,rowversion) VALUES (@Id,11,@Id,1,@Id,'system.training',4,timestamp '2050-01-01 12:34:56.123456',now(),timestamp '2050-01-01 12:34:56.123456',1)", new { Id = id }); + var connections = Connections(timeZone); using var unit = new UnitOfWork(connections); + var repository = new RmsRecordSearchProjectionsRepository(connections, new PostgreSqlConfiguration(), unit, WriteQueries()); + var page = (await repository.GetModifiedSinceAsync(11, new DateTime(2050, 1, 1, 0, 0, 0, DateTimeKind.Utc), 1)).Single(); + page.RmsRecordSearchProjectionId.Should().Be(ids[0]); + var next = (await repository.GetModifiedSinceAsync(11, page.ModifiedOn, 1, page.RmsRecordSearchProjectionId)).Single(); + next.RmsRecordSearchProjectionId.Should().Be(ids[1]); + (await repository.GetModifiedSinceAsync(11, next.ModifiedOn, 1, next.RmsRecordSearchProjectionId)).Should().BeEmpty(); + var cutoff = DateTime.SpecifyKind(page.ModifiedOn, DateTimeKind.Utc); + (await repository.CountAsync(11, new RmsRecordQuery { OccurredSince = cutoff })).Should().Be(2); + (await repository.CountAsync(11, new RmsRecordQuery { OccurredSince = cutoff.AddTicks(10) })).Should().Be(0); + } + finally + { + await db.ExecuteAsync("DELETE FROM rmsrecordsearchprojections WHERE rmsrecordsearchprojectionid = ANY(@Ids)", new { Ids = ids }); + } } } } diff --git a/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs b/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs index 1c812f894..d4118603a 100644 --- a/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs @@ -46,7 +46,7 @@ public async Task CreateIsolatedDatabase() _connection = new SqlConnectionStringBuilder(Master) { InitialCatalog = _database }.ConnectionString; using (var db = new SqlConnection(_connection)) await db.ExecuteAsync("CREATE TABLE Departments (DepartmentId int NOT NULL PRIMARY KEY); INSERT Departments VALUES (11),(12); CREATE TABLE DepartmentSettings (DepartmentSettingId int IDENTITY PRIMARY KEY, DepartmentId int NOT NULL, SettingType int NOT NULL, Setting nvarchar(max) NOT NULL); CREATE TABLE WorkflowRuns (WorkflowRunId nvarchar(36) NOT NULL PRIMARY KEY, WorkflowId nvarchar(36) NOT NULL, DepartmentId int NOT NULL, Status int NOT NULL, CompletedOn datetime2 NULL, InputPayload nvarchar(max) NULL, ErrorMessage nvarchar(max) NULL); CREATE TABLE WorkflowRunLogs (WorkflowRunLogId nvarchar(36) NOT NULL PRIMARY KEY, WorkflowRunId nvarchar(36) NOT NULL, RenderedOutput nvarchar(max) NULL, ActionResult nvarchar(max) NULL, ErrorMessage nvarchar(max) NULL);"); - var versions = new HashSet { 48, 49, 124, 142, 143, 144, 145, 150, 151, 153, 154, 155, 156, 157, 160, 164, 165, 166, 167, 168, 169, 170, 171, 173 }; + var versions = new HashSet { 48, 49, 124, 142, 143, 144, 145, 150, 151, 153, 154, 155, 156, 157, 160, 164, 165, 166, 167, 168, 169, 170, 171, 173, 176, 180, 182, 193 }; var migrations = typeof(M0150_AddRmsRecordsCore).Assembly.GetTypes().Where(t => typeof(IMigration).IsAssignableFrom(t) && !t.IsAbstract) .Where(t => t.GetCustomAttributes(typeof(MigrationAttribute), false).Cast().Any(a => versions.Contains(a.Version))) .Select(t => (IMigration)Activator.CreateInstance(t)).ToList(); diff --git a/Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs b/Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs index 4800ced47..f9deae30d 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs @@ -75,6 +75,39 @@ public async Task Unit_and_group_target_ids_cannot_cross_departments() Func target = () => _service.TargetAsync(_actor, ChecklistTargetType.Unit, "9"); (await target.Should().ThrowAsync()).Which.StatusCode.Should().Be(404); target = () => _service.TargetAsync(_actor, ChecklistTargetType.Group, "9"); (await target.Should().ThrowAsync()).Which.StatusCode.Should().Be(404); } + [Test] + public async Task Read_filter_preserves_owner_witness_group_and_tenant_boundaries_with_one_permission_lookup() + { + var filter = await _service.ReadFilterAsync(_actor); + (await filter(new ChecklistCompletion { DepartmentId = 77, CreatedBy = "member" })).Should().BeTrue(); + (await filter(new ChecklistCompletion { DepartmentId = 77, WitnessUserId = "member" })).Should().BeTrue(); + (await filter(new ChecklistCompletion { DepartmentId = 88, CreatedBy = "member", WitnessUserId = "member" })).Should().BeFalse(); + (await filter(null)).Should().BeFalse(); + _permissions.Verify(p => p.GetPermissionByDepartmentTypeAsync(77, PermissionTypes.ViewChecklistResults), Times.Never); + for (var i = 0; i < 105; i++) + { + (await filter(new ChecklistCompletion { DepartmentId = 77, TargetType = (int)ChecklistTargetType.Group, TargetId = "10" })).Should().BeTrue(); + (await filter(new ChecklistCompletion { DepartmentId = 77, TargetType = (int)ChecklistTargetType.Group, TargetId = "20" })).Should().BeFalse(); + (await filter(new ChecklistCompletion { DepartmentId = 77, TargetType = (int)ChecklistTargetType.Unit, TargetId = "9", TargetGroupId = 10 })).Should().BeTrue(); + } + _permissions.Verify(p => p.GetPermissionByDepartmentTypeAsync(77, PermissionTypes.ViewChecklistResults), Times.Once); + _groups.Verify(g => g.GetGroupForUserAsync("member", 77), Times.Once); + } + [Test] + public async Task A_new_read_filter_rechecks_permissions_groups_and_membership_after_revocation() + { + var row = new ChecklistCompletion { DepartmentId = 77, TargetType = (int)ChecklistTargetType.Group, TargetId = "10" }; + (await (await _service.ReadFilterAsync(_actor))(row)).Should().BeTrue(); + _groups.Setup(g => g.GetGroupForUserAsync("member", 77)).ReturnsAsync(new DepartmentGroup { DepartmentId = 77, DepartmentGroupId = 20, Members = new List { new DepartmentGroupMember { UserId = "member", IsAdmin = true } } }); + (await (await _service.ReadFilterAsync(_actor))(row)).Should().BeFalse(); + _member.IsAdmin = true; + var admin = await _service.ReadFilterAsync(_actor); + (await admin(row)).Should().BeTrue(); + (await admin(new ChecklistCompletion { DepartmentId = 88, TargetType = row.TargetType, TargetId = row.TargetId })).Should().BeFalse(); + _member.IsDisabled = true; + Func disabled = () => _service.ReadFilterAsync(_actor); + (await disabled.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + } [TestCase(false, false, false, false)] [TestCase(false, true, false, true)] [TestCase(true, false, true, true)] diff --git a/Tests/Resgrid.Tests/Services/ChecklistCalendarApiTests.cs b/Tests/Resgrid.Tests/Services/ChecklistCalendarApiTests.cs index 4ea8d7ad2..991c9de48 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistCalendarApiTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistCalendarApiTests.cs @@ -42,6 +42,9 @@ public async Task V4_calendar_inclusion_is_opt_in_preserves_ordinary_ids_and_use var combined = (await controller.GetDepartmentCalendarItemsInRange(now.Date, now.Date, true)).Value; combined.PageSize.Should().Be(2); var readiness = combined.Data.Single(i => i.IsVirtual); readiness.CalendarItemId.Should().Be("checklist:" + id); readiness.SourceId.Should().Be(id); readiness.LockEditing.Should().BeTrue(); readiness.IsRedacted.Should().BeTrue(); readiness.ChecklistState.Should().Be(4); readiness.TypeColor.Should().Be("#c0392b"); readiness.DeepLinkUrl.Should().EndWith(id); readiness.StartUtc.Should().Be(now); context.Response.Headers.CacheControl.ToString().Should().Be("no-store"); + checklists.Setup(c => c.CalendarAsync(It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new ChecklistException(403, "Unavailable")); + var partial = (await controller.GetDepartmentCalendarItemsInRange(now.Date, now.Date, true)).Value; + partial.Data.Should().ContainSingle(i => i.CalendarItemId == "42" && !i.IsVirtual); partial.PageSize.Should().Be(1); (await controller.GetDepartmentCalendarItemsInRange(now, now.AddDays(94), true)).Result.Should().BeOfType(); } finally { ClaimsHelper._httpContextAccessor = previous; } diff --git a/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs b/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs index c27af224e..69108bc13 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs @@ -25,7 +25,7 @@ namespace Resgrid.Tests.Services { [TestFixture, NonParallelizable] - public class ChecklistEventDeliveryTests + public partial class ChecklistEventDeliveryTests { private FakeRmsStore _store; private EventAggregator _bus; @@ -54,7 +54,7 @@ public void SetUp() public async Task Projection_precedes_persistence_and_is_rechecked_after_enrollment() { var entry = await _outbox.EnqueueAsync(42, "Checklists", Event()); - entry.PayloadJson.Should().NotContain("CANARY").And.NotContain("Data"); + entry.PayloadJson.Should().NotContain("CANARY").And.NotContain("Data").And.NotContain("person-42"); JObject.Parse(entry.PayloadJson)["Score"].Value().Should().Be(87.25m); _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); DomainEventDispatchedEvent delivered = null; @@ -62,9 +62,9 @@ public async Task Projection_precedes_persistence_and_is_rechecked_after_enrollm (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(1); var payload = JObject.Parse(delivered.PayloadJson); payload["Score"].Value().Should().Be("REDACTED"); payload["Passed"].Value().Should().Be("REDACTED"); payload["TargetId"].Value().Should().Be("REDACTED"); - payload["is_redacted"].Value().Should().BeTrue(); payload["catalog_version"].Value().Should().Be(17); + payload["is_redacted"].Value().Should().BeTrue(); payload["catalog_version"].Value().Should().Be(18); entry.PayloadJson.Should().StartWith("rgdp:").And.NotContain("87.25").And.NotContain("person-42"); - _history.Decrypt(42, "domaineventoutbox.payloadjson", entry.DomainEventOutboxId.ToString(), entry.PayloadJson).Should().Contain("87.25").And.Contain("person-42"); + _history.Decrypt(42, "domaineventoutbox.payloadjson", entry.DomainEventOutboxId.ToString(), entry.PayloadJson).Should().Contain("87.25").And.NotContain("person-42"); } [Test] public async Task Protected_or_unknown_policy_never_persists_plaintext_outcomes() @@ -114,7 +114,7 @@ public async Task Queue_rejection_after_run_insert_retries_the_same_workflow_run var workflow = new Workflow { WorkflowId = Guid.NewGuid().ToString(), DepartmentId = 42, TriggerEventType = trigger }; workflows.Setup(s => s.GetAllActiveByDepartmentAndEventTypeAsync(42, trigger)).ReturnsAsync(new[] { workflow }); WorkflowRun stored = null; - runs.Setup(s => s.GetByWorkflowAndEventAsync(workflow.WorkflowId, It.IsAny())).ReturnsAsync(() => stored); + runs.Setup(s => s.GetByWorkflowsAndEventAsync(42, It.IsAny>(), It.IsAny())).ReturnsAsync(() => stored == null ? new List() : new List { stored }); runs.Setup(s => s.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((WorkflowRun r, CancellationToken ct, bool first) => stored = r); var attempts = new List(); queue.Setup(s => s.EnqueueWorkflow(It.IsAny())).ReturnsAsync((WorkflowQueueItem item) => { attempts.Add(item); return attempts.Count > 1; }); diff --git a/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs b/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs index 0150cb711..8c06610c4 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs @@ -45,7 +45,7 @@ public async Task Checklist_export_pages_all_owned_data_and_masks_plaintext_duri _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, _emergencyContactService.Object, _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, store, - new Lazy(() => protection), reminders.Object); + new Lazy(() => protection), reminders.Object, EmptyWorkOrders()); var files = await RunExportAsync(); var json = files["checklists.json"]; json.Should().NotContain("CANARY").And.NotContain("23.5").And.NotContain("rgdp:").And.Contain("REDACTED"); var exported = JObject.Parse(json); diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M3ApiTests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M3ApiTests.cs new file mode 100644 index 000000000..72db68c26 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M3ApiTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.Checklists; +using ApiClaims = Resgrid.Web.ServicesCore.Helpers.ClaimsAuthorizationHelper; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + [Test, NonParallelizable] + public async Task V4_HTTP_contract_binds_requests_enforces_authorization_and_replays_one_completion_with_safe_ADP_errors() + { + var setup = await Start(); var version = (await _service.GetRunAsync(_actor, setup.Run)).Completion.VersionId; + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" }); + builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddHttpContextAccessor(); builder.Services.AddLocalization(); + const string scheme = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + builder.Services.AddAuthentication(scheme).AddScheme(scheme, _ => { }); + builder.Services.AddAuthorization(o => o.AddPolicy(ResgridResources.Checklist_Update, p => p.RequireRole("manager"))); + builder.Services.AddApiVersioning(); + builder.Services.AddControllers().AddApplicationPart(typeof(ChecklistRunsController).Assembly).AddNewtonsoftJson(o => o.SerializerSettings.ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver()); + builder.Services.AddSingleton(_service); builder.Services.AddSingleton(_access.Object); + builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); + await using var app = builder.Build(); var previous = ApiClaims._httpContextAccessor; + ApiClaims._httpContextAccessor = app.Services.GetRequiredService(); + app.UseRequestLocalization(new RequestLocalizationOptions().SetDefaultCulture("fr").AddSupportedCultures("fr").AddSupportedUICultures("fr")); + app.UseDeveloperExceptionPage(); + app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); + try + { + await app.StartAsync(); using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; + const string runs = "/api/v4/ChecklistRuns/"; + (await client.GetAsync(runs + "GetChecklistAccess")).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + client.DefaultRequestHeaders.Add("Test-Member", "crew"); + (await client.PostAsync("/api/v4/Checklists/NewChecklist", Json(new ChecklistDefinitionInput { Form = Form() }))).StatusCode.Should().Be(HttpStatusCode.Forbidden); + client.DefaultRequestHeaders.Remove("Test-Member"); client.DefaultRequestHeaders.Add("Test-Member", "author"); + client.DefaultRequestHeaders.Add(DataProtectionController.GrantHeader, "synthetic-current-grant"); + var id = Guid.NewGuid().ToString(); var start = new ChecklistStartInput { DefinitionId = setup.Definition, VersionId = version, TargetId = "77", CompletionId = id }; + var response = await client.PostAsync(runs + "StartChecklistRun", Json(start)); + response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); response.Headers.CacheControl.NoStore.Should().BeTrue(); + var run = JObject.Parse(await response.Content.ReadAsStringAsync())["Data"]; run["Id"].Value().Should().Be(id); run["Revision"].Value().Should().Be(1); + _authorization.Verify(a => a.RequireMemberAsync(It.Is(x => x.DepartmentId == 77 && x.UserId == "author" && x.GrantToken == "synthetic-current-grant")), Times.AtLeastOnce); + (await client.PostAsync(runs + "StartChecklistRun", Json(start))).StatusCode.Should().Be(HttpStatusCode.OK); + (await client.PostAsync(runs + "SaveChecklistRunProgress", Json(new { Id = id, Input = (object)null }))).StatusCode.Should().Be(HttpStatusCode.BadRequest); + response = await client.PostAsync(runs + "SaveChecklistRunProgress", Json(new { Id = id, Input = new { Revision = "SYNTHETIC PRIVATE VALUE" } })); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await response.Content.ReadAsStringAsync()).Should().NotContain("SYNTHETIC PRIVATE VALUE").And.Contain("checklist_validation"); + var input = Answers(setup.Form); var command = new ChecklistProgressInput { Id = id, Input = input }; + (await client.PostAsync(runs + "SaveChecklistRunProgress", Json(command))).StatusCode.Should().Be(HttpStatusCode.OK); + (await client.PostAsync(runs + "SaveChecklistRunProgress", Json(command))).StatusCode.Should().Be(HttpStatusCode.OK); + input.Note = "changed"; response = await client.PostAsync(runs + "SaveChecklistRunProgress", Json(command)); response.StatusCode.Should().Be(HttpStatusCode.Conflict); + JObject.Parse(await response.Content.ReadAsStringAsync())["type"].Value().Should().Be("checklist_conflict"); + input.Revision = 2; + (await client.PostAsync(runs + "CompleteChecklistRun", Json(command))).StatusCode.Should().Be(HttpStatusCode.OK); + (await client.PostAsync(runs + "CompleteChecklistRun", Json(command))).StatusCode.Should().Be(HttpStatusCode.OK); + _events.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.ChecklistCompleted); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { RedactedFields = { "checklistcompletions.content" } })); + response = await client.GetAsync(runs + "GetChecklistRun?id=" + id); response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var problem = await response.Content.ReadAsStringAsync(); problem.Should().Contain("protected_data_required").And.NotContain("changed").And.NotContain("Unlock protected").And.NotContain("rgdp:"); + (await _store.ListAsync(77)).Should().HaveCount(2); + } + finally { ApiClaims._httpContextAccessor = previous; await app.StopAsync(); } + } + private static StringContent Json(object data) => new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"); + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M3DatabaseTests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M3DatabaseTests.cs new file mode 100644 index 000000000..d3f8f8b65 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M3DatabaseTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Repositories.DataRepository.Transactions; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistDatabaseTests + { + [Test, Order(98)] + public async Task Concurrent_offline_start_and_submit_replays_create_one_completion_and_one_event_in_both_databases() + { + var actor = new ChecklistActor { DepartmentId = 77, UserId = "offline-author" }; var events = new ConcurrentBag(); + var auth = new Mock(); auth.Setup(a => a.CanManageAsync(It.IsAny())).ReturnsAsync(true); + auth.Setup(a => a.CanReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + auth.Setup(a => a.TargetAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new ChecklistTarget { Type = ChecklistTargetType.Department, Id = "77", Name = "Synthetic department" }); + var access = new Mock(); access.Setup(a => a.CanUseChecklistsAsync(77)).ReturnsAsync(true); + var read = new Mock(); read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult())); + var write = new Mock(); write.SetReturnsDefault(Task.FromResult(ProtectedWriteResult.Allowed())); + var audit = new Mock(); audit.Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((AuditLog a, CancellationToken c, bool f) => { a.AuditLogId = 1; return a; }); + var outbox = new Mock(); outbox.Setup(o => o.EnqueueAsync(77, "Checklists", It.IsAny(), It.IsAny())).ReturnsAsync((int d, string p, DomainEventEnvelope e, CancellationToken c) => { events.Add(e); return new DomainEventOutboxEntry { DomainEventOutboxId = 1 }; }); + var connections = Connections(); using var first = new UnitOfWork(connections); using var second = new UnitOfWork(connections); + ChecklistsService Service(UnitOfWork unit) => new ChecklistsService(Repository(connections, unit), auth.Object, access.Object, unit, audit.Object, outbox.Object, + new Lazy(() => read.Object), new Lazy(() => write.Object), Mock.Of()); + var a = Service(first); var b = Service(second); var form = new ChecklistForm { Name = "Concurrent offline test", Sections = { new ChecklistSection { Name = "Checks", Items = { new ChecklistItem { Name = "Ready" } } } } }; + var definition = await a.SaveDefinitionAsync(actor, null, 0, form); await a.PublishAsync(actor, definition, 1); + var version = (await a.GetDefinitionAsync(actor, definition)).Definition.CurrentVersionId; var id = Guid.NewGuid().ToString(); + (await Task.WhenAll(a.StartPinnedAsync(actor, definition, version, "77", id), b.StartPinnedAsync(actor, definition, version, "77", id))).Should().OnlyContain(v => v == id); + ChecklistRunInput Input() => new ChecklistRunInput { Revision = 1, Answers = { new ChecklistAnswer { ItemId = form.Sections[0].Items[0].Id, Status = ChecklistAnswerStatus.Answered, Value = "pass" } } }; + (await Task.WhenAll(a.SaveRunAsync(actor, id, Input(), true), b.SaveRunAsync(actor, id, Input(), true))).Should().OnlyContain(v => v == 2); + (await Repository(connections, first).ListAsync(77, definition)).Should().ContainSingle(); + (await Repository(connections, first).ListAsync(77, definition)).Should().ContainSingle(); + events.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.ChecklistCompleted); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M3Tests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M3Tests.cs new file mode 100644 index 000000000..9d1cd3d96 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M3Tests.cs @@ -0,0 +1,122 @@ +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.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Web.Services.Models.v4.Checklists; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + [Test] + public async Task Offline_start_preserves_its_published_version_and_rejects_a_version_from_another_definition() + { + var first = await Start(); var version = (await _service.GetRunAsync(_actor, first.Run)).Completion.VersionId; + var changed = Form(); changed.Name = "New published instructions"; + await _service.SaveDefinitionAsync(_actor, first.Definition, 2, changed); await _service.PublishAsync(_actor, first.Definition, 3); + var id = Guid.NewGuid().ToString(); await _service.StartPinnedAsync(_actor, first.Definition, version, "77", id); + (await _service.GetRunAsync(_actor, id)).Form.Name.Should().Be(first.Form.Name); + (await _service.StartPinnedAsync(_actor, first.Definition, version, "77", id.ToUpperInvariant())).Should().Be(id); + var other = await Start(); var otherVersion = (await _service.GetRunAsync(_actor, other.Run)).Completion.VersionId; + Func invalid = () => _service.StartPinnedAsync(_actor, first.Definition, otherVersion, "77", Guid.NewGuid().ToString()); + (await invalid.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + await _service.RetireAsync(_actor, first.Definition, 4); + Func retired = () => _service.StartPinnedAsync(_actor, first.Definition, version, "77", Guid.NewGuid().ToString()); + (await retired.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + } + [Test] + public async Task Lost_progress_and_final_responses_replay_without_duplicate_revisions_or_events_but_changed_payload_conflicts() + { + var setup = await Start(); var input = Answers(setup.Form); + (await _service.SaveRunAsync(_actor, setup.Run, input, false)).Should().Be(2); + var audits = _audits.Invocations.Count; + (await _service.SaveRunAsync(_actor, setup.Run, input, false)).Should().Be(2); _audits.Invocations.Count.Should().Be(audits); + input.Note = "Changed while offline"; + Func stale = () => _service.SaveRunAsync(_actor, setup.Run, input, false); + (await stale.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + input.Revision = 2; input.ClientCompletedOn = DateTime.UtcNow; + (await _service.SaveRunAsync(_actor, setup.Run, input, true)).Should().Be(3); + (await _service.SaveRunAsync(_actor, setup.Run, input, true)).Should().Be(3); + _events.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.ChecklistCompleted); + input.Note = "Different final content"; (await stale.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + } + [Test] + public async Task Occurrence_preview_is_read_only_and_the_client_GUID_wins_once() + { + var setup = await Scheduled(); await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); setup.Clock.Now = setup.Clock.Now.AddHours(1); + var occurrence = (await _store.ListAsync(77)).OrderBy(o => o.PeriodStartUtc).First(); + var preview = await _service.PreviewOccurrenceAsync(_actor, occurrence.Id); preview.Completion.Revision.Should().Be(0); preview.Form.Sections.Should().NotBeEmpty(); + (await _store.ListAsync(77)).Should().BeEmpty(); + var id = Guid.NewGuid().ToString(); (await _service.StartOccurrenceWithIdAsync(_actor, occurrence.Id, id)).Should().Be(id); + (await _service.StartOccurrenceWithIdAsync(_actor, occurrence.Id, id)).Should().Be(id); + Func competitor = () => _service.StartOccurrenceWithIdAsync(_actor, occurrence.Id, Guid.NewGuid().ToString()); + (await competitor.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + (await _store.ListAsync(77)).Should().ContainSingle(); + (await _service.PreviewOccurrenceAsync(_actor, occurrence.Id)).Completion.Revision.Should().Be(1); + } + [Test] + public async Task Upload_retry_is_deduplicated_but_cannot_rebase_over_a_later_answer_edit() + { + var setup = await Start(); byte[] bytes; + using (var image = new SixLabors.ImageSharp.Image(2, 2)) + using (var stream = new System.IO.MemoryStream()) { image.Save(stream, new SixLabors.ImageSharp.Formats.Png.PngEncoder()); bytes = stream.ToArray(); } + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Clean }); + var item = setup.Form.Sections[0].Items[0].Id; + await _service.AddFileAtRevisionAsync(_actor, setup.Run, item, 1, "photo.png", "image/png", bytes); + await _service.AddFileAtRevisionAsync(_actor, setup.Run, item, 1, "photo.png", "image/png", bytes); + var view = await _service.GetRunAsync(_actor, setup.Run); view.Files.Should().ContainSingle(); view.Completion.Revision.Should().Be(2); + var input = Answers(setup.Form); input.Revision = 2; await _service.SaveRunAsync(_actor, setup.Run, input, false); + Func retry = () => _service.AddFileAtRevisionAsync(_actor, setup.Run, item, 1, "photo.png", "image/png", bytes); + (await retry.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + Func remove = () => _service.DeleteFileAtRevisionAsync(_actor, view.Files.Single().Id, 2); + (await remove.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + await _service.DeleteFileAtRevisionAsync(_actor, view.Files.Single().Id, 3); + (await _service.GetRunAsync(_actor, setup.Run)).Files.Should().BeEmpty(); + } + [Test] + public async Task Mobile_due_pages_filter_before_pagination_and_never_offer_a_managers_draft_form() + { + _authorization.Setup(a => a.TargetsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { new ChecklistTarget { Type = ChecklistTargetType.Department, Id = "77", Name = "Department" } }); + for (var i = 0; i < 52; i++) + { + var form = Form(); form.Name = "Published " + i; var id = await _service.SaveDefinitionAsync(_actor, null, 0, form); await _service.PublishAsync(_actor, id, 1); + form.Name = "SENSITIVE DRAFT " + i; await _service.SaveDefinitionAsync(_actor, id, 2, form); + } + var first = await _service.MobileDueAsync(_actor, new ChecklistMobileQuery()); first.Definitions.Should().HaveCount(50); first.HasMoreDefinitions.Should().BeTrue(); + var next = await _service.MobileDueAsync(_actor, new ChecklistMobileQuery { Page = 1 }); next.Definitions.Should().HaveCount(2); next.HasMoreDefinitions.Should().BeFalse(); + first.Definitions.Concat(next.Definitions).Should().OnlyContain(d => d.Definition.Form.Name.StartsWith("Published ")); + Newtonsoft.Json.JsonConvert.SerializeObject(first).Should().NotContain("SENSITIVE DRAFT"); + } + [Test] + public async Task Mobile_replay_rechecks_revoked_membership_and_ADP_before_returning_an_existing_run() + { + var setup = await Start(); var version = (await _service.GetRunAsync(_actor, setup.Run)).Completion.VersionId; + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { RedactedFields = { "checklistcompletions.content" } })); + Func retry = () => _service.StartPinnedAsync(_actor, setup.Definition, version, "77", setup.Run); + (await retry.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + Func upload = () => _service.AddFileAtRevisionAsync(_actor, setup.Run, setup.Form.Sections[0].Items[0].Id, 1, "private.png", "image/png", new byte[] { 1 }); + (await upload.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + _scanner.Verify(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _authorization.Setup(a => a.RequireMemberAsync(It.IsAny())).ThrowsAsync(new ChecklistException(403, "Active department membership is required.")); + Func save = () => _service.SaveRunAsync(_actor, setup.Run, Answers(setup.Form), true); + (await save.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + _events.Where(e => e.Trigger.HasValue).Should().BeEmpty(); + } + [Test] + public async Task API_projection_excludes_storage_envelopes_binary_data_and_raw_JSON() + { + var setup = await Start(); var view = await _service.GetRunAsync(_actor, setup.Run); + view.Completion.Content = "SENSITIVE RAW JSON"; view.Completion.ProtectedScoreEnvelope = "rgdp:secret"; + view.Files.Add(new ChecklistCompletionFile { Data = new byte[] { 1, 2, 3 }, Content = "image.png" }); + var json = Newtonsoft.Json.JsonConvert.SerializeObject(ChecklistRunData.From(view, "author")); + json.Should().NotContain("SENSITIVE RAW JSON").And.NotContain("rgdp:").And.NotContain("AQID").And.NotContain("ProtectedScoreEnvelope"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M4ApiTests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M4ApiTests.cs new file mode 100644 index 000000000..09f09729b --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M4ApiTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; +using ApiClaims = Resgrid.Web.ServicesCore.Helpers.ClaimsAuthorizationHelper; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + [Test, NonParallelizable] + public async Task P1M4_HTTP_reports_require_authentication_UTC_ranges_and_current_ADP_without_caching_or_raw_errors() + { + await SeedReportMonth(); PacketService(null); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" }); + builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Services.AddHttpContextAccessor(); builder.Services.AddLocalization(); + const string scheme = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + builder.Services.AddAuthentication(scheme).AddScheme(scheme, _ => { }); builder.Services.AddAuthorization(); builder.Services.AddApiVersioning(); + builder.Services.AddControllers().AddApplicationPart(typeof(ChecklistRunsController).Assembly).AddNewtonsoftJson(o => o.SerializerSettings.ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver()); + builder.Services.AddSingleton(_service); builder.Services.AddSingleton(_access.Object); builder.Services.AddSingleton(Mock.Of()); + await using var app = builder.Build(); var previous = ApiClaims._httpContextAccessor; ApiClaims._httpContextAccessor = app.Services.GetRequiredService(); + app.UseRequestLocalization(new RequestLocalizationOptions().SetDefaultCulture("fr").AddSupportedCultures("fr").AddSupportedUICultures("fr")); + app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); + try + { + await app.StartAsync(); using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; + const string route = "/api/v4/ChecklistRuns/"; + const string query = "GetComplianceSummary?fromUtc=2026-08-01T00:00:00Z&untilUtc=2026-08-31T00:00:00Z"; + (await client.GetAsync(route + query)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + client.DefaultRequestHeaders.Add("Test-Member", "author"); var response = await client.GetAsync(route + query); + response.StatusCode.Should().Be(HttpStatusCode.OK); response.Headers.CacheControl.NoStore.Should().BeTrue(); + var json = JObject.Parse(await response.Content.ReadAsStringAsync()); json["Data"]["Groups"].Count().Should().Be(3); json.ToString().Should().NotContain("CANARY"); + response = await client.GetAsync(route + "GetComplianceSummary?fromUtc=2026-08-31&untilUtc=2026-08-01"); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); (await response.Content.ReadAsStringAsync()).Should().NotContain("Select a chronological"); + response = await client.GetAsync(route + "GetReadinessPacket?callId=101"); response.StatusCode.Should().Be(HttpStatusCode.OK); (await response.Content.ReadAsStringAsync()).Should().Contain("ReadinessEvidenceManifestV1").And.NotContain("CANARY"); + response = await client.GetAsync(route + "GetEntityChecklistHistory?entityType=1&entityId=1&fromUtc=2026-08-01&untilUtc=2026-08-31"); response.StatusCode.Should().Be(HttpStatusCode.OK); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { RedactedFields = { "checklistdefinitionversions.content" } })); + response = await client.GetAsync(route + query); response.StatusCode.Should().Be(HttpStatusCode.Forbidden); (await response.Content.ReadAsStringAsync()).Should().Contain("protected_data_required").And.NotContain("CANARY"); + } + finally { ApiClaims._httpContextAccessor = previous; await app.StopAsync(); } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M4DatabaseTests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M4DatabaseTests.cs new file mode 100644 index 000000000..82ceca094 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M4DatabaseTests.cs @@ -0,0 +1,36 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model.Checklists; +using Resgrid.Repositories.DataRepository.Transactions; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistDatabaseTests + { + [Test, Order(97)] + public async Task Reporting_query_scopes_the_UTC_cohort_and_pages_without_dropping_ties_on_both_databases() + { + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + var start = new DateTime(2025, 8, 1, 0, 0, 0, DateTimeKind.Utc); var end = start.AddMonths(1); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + var definition = Row(); await store.WriteAsync(definition, true); + var version = Row(definition.Id); version.Version = 1; await store.WriteAsync(version, true); + for (var i = 0; i < 505; i++) + { + var row = Row(definition.Id); row.VersionId = version.Id; row.CompletionId = Guid.NewGuid().ToString(); row.TargetType = i % 2 == 0 ? 1 : 5; row.TargetId = i % 2 == 0 ? "1" : Guid.NewGuid().ToString(); + row.CreatedOn = start; row.PeriodStartUtc = i == 504 ? null : start; await store.WriteAsync(row, true); + } + foreach (var at in new[] { start.AddSeconds(-1), end }) + { var row = Row(definition.Id); row.VersionId = version.Id; row.CompletionId = Guid.NewGuid().ToString(); row.TargetType = 1; row.TargetId = "1"; row.CreatedOn = at; row.PeriodStartUtc = at; await store.WriteAsync(row, true); } + uow.CommitChanges(); var timer = Stopwatch.StartNew(); + var first = await store.ReportOccurrencesAsync(77, start, end, 0); var second = await store.ReportOccurrencesAsync(77, start, end, 500); + timer.Stop(); TestContext.Out.WriteLine($"{_type}: report query returned 505 rows in {timer.ElapsedMilliseconds} ms (two pages; existing indexes)."); + first.Should().HaveCount(500); second.Should().HaveCount(5); first.Concat(second).Select(o => o.Id).Should().OnlyHaveUniqueItems(); + first.Concat(second).Should().ContainSingle(o => o.PeriodStartUtc == null); (await store.ReportOccurrencesAsync(88, start, end, 0)).Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs new file mode 100644 index 000000000..3c47e0a21 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Services.Records.Evidence; +using Resgrid.Workers.Framework.Logic; +using Resgrid.Workers.Framework.Workers.ReportDelivery; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + private static readonly DateTime ReportMonth = new(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + private const string ReportAsset = "81366c6a-aaad-41be-9df1-9091a42d6073"; + private static ChecklistReportQuery Month() => new() { FromUtc = ReportMonth, UntilUtc = ReportMonth.AddDays(30) }; + private async Task SeedReportMonth() + { + _authorization.Setup(a => a.CanReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, ChecklistCompletion c) => a.DepartmentId == c.DepartmentId && a.UserId == "author"); + foreach (var target in new[] { new ChecklistTarget { Type = ChecklistTargetType.Unit, Id = "1", Name = "Engine 1" }, new ChecklistTarget { Type = ChecklistTargetType.InventoryAsset, Id = ReportAsset, Name = "=SUM(1,2)" }, new ChecklistTarget { Type = ChecklistTargetType.Personnel, Id = "person", Name = "Synthetic person" } }) + { + var definition = new ChecklistDefinition { DepartmentId = 77, Content = "{}" }; + var version = new ChecklistDefinitionVersion { DepartmentId = 77, ParentId = definition.Id, Version = 3, Content = JsonConvert.SerializeObject(new ChecklistForm { Name = "Synthetic check " }) }; + await _store.WriteAsync(definition, true); await _store.WriteAsync(version, true); + for (var day = 0; day < 30; day++) + { + var at = ReportMonth.AddDays(day).AddHours(8); + var occurrence = new ChecklistOccurrence { DepartmentId = 77, ParentId = definition.Id, VersionId = version.Id, ScheduleId = Guid.NewGuid().ToString(), TargetType = (int)target.Type, TargetId = target.Id, + PeriodStartUtc = at, WindowEndUtc = at.AddHours(1), CreatedOn = at, UpdatedOn = at, State = day < 20 ? 2 : day < 25 ? 4 : 5, Content = JsonConvert.SerializeObject(target) }; + if (day < 20) + { + var completion = new ChecklistCompletion { DepartmentId = 77, ParentId = definition.Id, VersionId = version.Id, OccurrenceId = occurrence.Id, TargetType = (int)target.Type, TargetId = target.Id, CreatedBy = "author", + State = 2, SubmittedOn = at.AddMinutes(day < 15 ? 30 : 90), Content = "{\"Note\":\"SYNTHETIC-PHI-CANARY\"}", Score = 80, Passed = true }; + occurrence.CompletionId = completion.Id; await _store.WriteAsync(completion, true); + } + await _store.WriteAsync(occurrence, true); + } + } + } + [Test] + public async Task P1M4_seeded_month_matches_hand_calculated_unit_person_and_asset_rates_and_missed_trend() + { + await SeedReportMonth(); var report = await _service.GetComplianceSummaryAsync(_actor, Month()); + report.Groups.Should().HaveCount(3); + foreach (var group in report.Groups) { group.Expected.Should().Be(25); group.Completed.Should().Be(20); group.OnTime.Should().Be(15); group.Missed.Should().Be(10); group.Skipped.Should().Be(5); group.CompletionRate.Should().Be(80); } + report.Trend.Sum(d => d.Missed).Should().Be(30); report.Trend.Sum(d => d.Expected).Should().Be(75); + var history = await _service.GetEntityChecklistHistoryAsync(_actor, ChecklistTargetType.InventoryAsset, ReportAsset, ReportMonth, ReportMonth.AddDays(30)); + history.Should().HaveCount(30); history.Should().OnlyContain(e => e.Version == 3 && e.Target.Id == ReportAsset); + JsonConvert.SerializeObject(report).Should().NotContain("SYNTHETIC-PHI-CANARY"); + } + [Test] + public async Task P1M4_reports_fail_closed_for_protected_data_disabled_flags_and_invalid_ranges_and_filter_other_members() + { + await SeedReportMonth(); var query = Month(); query.UntilUtc = query.FromUtc; + (await FluentActions.Awaiting(() => _service.GetComplianceSummaryAsync(_actor, query)).Should().ThrowAsync()).Which.StatusCode.Should().Be(400); + var other = await _service.GetComplianceSummaryAsync(new ChecklistActor { DepartmentId = 77, UserId = "crew" }, Month()); other.Entries.Should().BeEmpty(); other.UnavailableSources.Should().Contain("RestrictedChecklistResults"); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { RedactedFields = { "checklistdefinitionversions.content" } })); + (await FluentActions.Awaiting(() => _service.GetComplianceSummaryAsync(_actor, Month())).Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + _access.Setup(a => a.CanUseChecklistsAsync(77)).ReturnsAsync(false); + (await FluentActions.Awaiting(() => _service.GetComplianceSummaryAsync(_actor, Month())).Should().ThrowAsync()).Which.StatusCode.Should().Be(404); + } + [Test] + public async Task P1M4_open_windows_and_on_demand_checks_do_not_inflate_the_denominator() + { + await SeedReportMonth(); var rows = await _store.ListAsync(77); + foreach (var row in rows.Take(2)) { row.WindowEndUtc = DateTime.UtcNow.AddYears(1); await _store.WriteAsync(row, false); } + foreach (var row in rows.Skip(2).Take(2)) { row.ScheduleId = null; await _store.WriteAsync(row, false); } + var report = await _service.GetComplianceSummaryAsync(_actor, Month()); + report.Entries.Where(e => e.DueUtc > DateTime.UtcNow || !e.Scheduled).Should().OnlyContain(e => !e.Expected && !e.Missed); + } + private void PacketService(Mock assets, DateTime? callAt = null, int department = 77) + { + var call = new Call { CallId = 101, DepartmentId = department, LoggedOn = callAt ?? ReportMonth.AddDays(30), Name = "CALL-PHI-CANARY", UnitDispatches = new List { new() { CallId = 101, UnitId = 1, CallDispatchUnitId = 21, DispatchedOn = ReportMonth.AddDays(30) } } }; + var calls = new Mock(); calls.Setup(c => c.GetCallByIdAsync(101, true)).ReturnsAsync(call); + calls.Setup(c => c.PopulateCallData(call, false, false, false, false, true, false, false, false, false, false)).ReturnsAsync(call); + var auth = new Mock(); auth.Setup(a => a.CanUserViewCallAsync("author", 101)).ReturnsAsync(true); + _service = new ChecklistsService(_store, _authorization.Object, _access.Object, _uow.Object, _audits.Object, _outbox.Object, new(() => _read.Object), new(() => _write.Object), _scanner.Object, + reportCalls: new(() => calls.Object), reportAuthorization: new(() => auth.Object), historicalAssets: assets?.Object); + } + [Test] + public async Task P1M4_packet_includes_all_dispatched_unit_and_historically_issued_asset_checks_without_call_narrative() + { + await SeedReportMonth(); var assets = new Mock(); + assets.Setup(a => a.AtCallAsync(It.IsAny(), 101, ReportMonth.AddDays(30), It.IsAny>(), false)).ReturnsAsync(new List { new() { DepartmentId = 77, UnitId = 1, AssetId = ReportAsset, Name = "Issued SCBA", SourceId = "issue-7", SourceVersion = "1", IssuedUtc = ReportMonth } }); + PacketService(assets); var packet = await _service.GetReadinessPacketForCallAsync(_actor, 101); + packet.Units.Should().ContainSingle(); packet.Assets.Should().ContainSingle(); packet.Checklists.Should().HaveCount(60); packet.Checklists.Count(e => e.Missed).Should().Be(20); + packet.Checklists.Should().OnlyContain(e => e.Target.Type != ChecklistTargetType.Personnel); + packet.UnavailableSources.Should().Contain("ContractorEquipmentUnavailable").And.Contain("WorkOrdersUnavailable").And.NotContain("HistoricalInventoryUnavailable"); + JsonConvert.SerializeObject(packet).Should().NotContain("CANARY"); + } + [Test] + public async Task P1M4_contractor_equipment_can_be_deployed_directly_to_the_call_without_a_unit_assignment() + { + await SeedReportMonth(); var assets = new Mock(); + assets.Setup(a => a.AtCallAsync(It.IsAny(), 101, It.IsAny(), It.IsAny>(), true)).ReturnsAsync(new List { new() { DepartmentId = 77, CallId = 101, AssetId = ReportAsset, Name = "Deployed equipment", SourceId = "deployment-7", SourceVersion = "2", IssuedUtc = ReportMonth } }); + PacketService(assets); var packet = await _service.GetReadinessPacketForCallAsync(_actor, 101); + packet.Assets.Should().ContainSingle(a => a.SourceSubsystem == "ContractorBilling" && a.UnitId == null); packet.Checklists.Should().HaveCount(60); + packet.UnavailableSources.Should().NotContain("ContractorEquipmentUnavailable"); + } + + [Test] + public async Task P1M4_packet_does_not_use_submissions_or_witnesses_after_call_time_and_rejects_foreign_calls() + { + await SeedReportMonth(); PacketService(null, ReportMonth.AddHours(8).AddMinutes(45)); + var packet = await _service.GetReadinessPacketForCallAsync(_actor, 101); packet.Checklists.Should().ContainSingle(); packet.Checklists[0].Completed.Should().BeTrue(); + var completion = await _store.GetAsync(77, packet.Checklists[0].CompletionId); completion.WitnessedOn = ReportMonth.AddDays(1); await _store.WriteAsync(completion, false); + packet = await _service.GetReadinessPacketForCallAsync(_actor, 101); packet.Checklists[0].Completed.Should().BeFalse(); packet.Checklists[0].Passed.Should().BeNull(); packet.Checklists[0].Score.Should().BeNull(); + PacketService(null, department: 88); + (await FluentActions.Awaiting(() => _service.GetReadinessPacketForCallAsync(_actor, 101)).Should().ThrowAsync()).Which.StatusCode.Should().Be(404); + } + [Test] + public async Task P1M4_packet_rejects_current_only_asset_assignments_and_marks_unavailable_sources() + { + await SeedReportMonth(); PacketService(null); var packet = await _service.GetReadinessPacketForCallAsync(_actor, 101); + packet.Assets.Should().BeEmpty(); packet.UnavailableSources.Should().Contain("HistoricalInventoryUnavailable"); + var assets = new Mock(); assets.Setup(a => a.AtCallAsync(It.IsAny(), 101, It.IsAny(), It.IsAny>(), false)) + .ReturnsAsync(new List { new() { DepartmentId = 77, UnitId = 1, AssetId = ReportAsset, SourceId = "issue", SourceVersion = "2", IssuedUtc = ReportMonth.AddDays(31) } }); + PacketService(assets); (await FluentActions.Awaiting(() => _service.GetReadinessPacketForCallAsync(_actor, 101)).Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + } + [Test] + public async Task P1M4_exports_escape_HTML_and_CSV_formulas_and_package_checksums_survive_source_changes() + { + await SeedReportMonth(); var report = await _service.GetComplianceSummaryAsync(_actor, Month()); + report.Entries[0].Passed = false; report.Entries[0].Score = 100; + ChecklistReportDocuments.Compliance(report).Should().Contain(ChecklistReportDocuments.Text("Failed")); + Encoding.UTF8.GetString(ChecklistReportDocuments.Csv(report)).Should().Contain("'=" + "SUM(1,2)"); + ChecklistReportDocuments.Compliance(report).Should().NotContain("(); + checklists.Setup(s => s.SchedulesAsync(It.IsAny(), attack, 1)).ReturnsAsync(new List + { + new ChecklistScheduleView { Schedule = new ChecklistSchedule { Id = attack, TimeZoneId = attack, Frequency = 2 }, Content = new ChecklistScheduleContent { Name = attack } } + }); + _access.Setup(a => a.CanUseChecklistsAsync(77)).ReturnsAsync(canEdit); + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) directory = directory.Parent; + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = Path.Combine(directory.FullName, "Web", "Resgrid.Web"), EnvironmentName = "Testing" }); + builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddHttpContextAccessor(); builder.Services.AddLocalization(); + builder.Services.AddAuthentication("checklist-test").AddScheme("checklist-test", _ => { }); + builder.Services.AddAuthorization(o => o.AddPolicy(ResgridResources.Checklist_Update, p => p.RequireRole("manager"))); + builder.Services.AddControllersWithViews(o => o.Filters.Add(new PageBodyFilter())).AddApplicationPart(typeof(ChecklistsController).Assembly); + builder.Services.AddSingleton(checklists.Object); builder.Services.AddSingleton(_access.Object); + builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); + await using var app = builder.Build(); var previous = ClaimsAuthorizationHelper._httpContextAccessor; + ClaimsAuthorizationHelper._httpContextAccessor = app.Services.GetRequiredService(); + app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllerRoute("areas", "{area:exists}/{controller}/{action=Index}/{id?}"); + try + { + await app.StartAsync(); using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; client.DefaultRequestHeaders.Add("Test-Member", "author"); + var response = await client.GetAsync("/User/Checklists/Schedules?page=1&id=" + Uri.EscapeDataString(attack)); + var html = await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.OK, html); + html.Should().NotContain("`); + await page.evaluate(()=>{ + window.$=f=>f();window.requests=[];window.reopened=[]; + window.fail=true;window.allowed=false; + window.resgridAdpReveal={bindForm:form=>form.addEventListener('submit',e=>{if(!window.allowed)e.preventDefault();})}; + window.fetch=async(url,options)=>{window.requests.push(Object.fromEntries(options.body));return window.fail ? {ok:false,json:async()=>({message:'Conflit',code:'Conflict'})}:{ok:true,json:async()=>({id:123})};}; + HTMLFormElement.prototype.submit=function(){window.reopened.push(Object.fromEntries(new FormData(this)));}; + }); + await page.addScriptTag({path:path.resolve(__dirname,'../../../Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js')}); + await page.click('button[type=submit]'); + assert.equal(await page.evaluate(()=>requests.length),0,'ADP verification must run before submission'); + await page.evaluate(()=>window.allowed=true); + await page.click('button[type=submit]'); await page.waitForFunction(()=>requests.length===1); + assert.equal(await page.locator('textarea').inputValue(),'private draft'); + assert.equal(await page.locator('.work-order-error').textContent(),'Conflit'); + assert.equal(await page.evaluate(()=>requests[0].__ResgridProtectedGrant),'synthetic-grant'); + await page.evaluate(()=>window.fail=false); await page.click('button[type=submit]'); + await page.waitForFunction(()=>reopened.length===1); + assert.equal(await page.evaluate(()=>reopened[0].destination),'Detail'); + assert.equal(await page.evaluate(()=>reopened[0].__RequestVerificationToken),'synthetic-csrf'); + assert.equal(await page.evaluate(()=>reopened[0].__ResgridProtectedGrant),'synthetic-grant'); + assert.equal(await page.evaluate(()=>document.getElementById('work-order-page').textContent),''); + await page.click('#work-order-add-step'); + assert.equal(await page.locator('input[name="Input.Content.Steps[0].Text"]').count(),1); + assert.deepEqual(errors,[]); + console.log('PASS: ADP interception, CSRF/grant propagation, conflict preservation, protected POST navigation and step creation.'); + } finally { await browser.close(); } +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs index 17e6779da..eb7da3bbb 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs @@ -170,7 +170,7 @@ await _protectedReadService.ResolveCalendarItemsForReadAsync(DepartmentId, items foreach (var entry in await _checklists.CalendarAsync(new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = ProtectedGrantToken }, start.Date, end.Date.AddDays(1))) result.Data.Add(new GetAllCalendarItemResultData { CalendarItemId = entry.Id, Title = entry.Title, StartUtc = entry.StartUtc, EndUtc = entry.EndUtc, Start = entry.StartUtc.TimeConverter(department), End = entry.EndUtc.TimeConverter(department), StartTimezone = department?.TimeZone, EndTimezone = department?.TimeZone, IsVirtual = true, LockEditing = true, SourceType = "Checklist", SourceId = entry.OccurrenceId, IsRedacted = entry.IsRedacted, DeepLinkUrl = "/User/Checklists/Occurrence?id=" + entry.OccurrenceId, ChecklistState = entry.State, TypeColor = entry.State == 4 ? "#c0392b" : entry.State == 2 ? "#247a42" : "#6a4c93" }); } - catch (Resgrid.Model.Checklists.ChecklistException ex) { return StatusCode(ex.StatusCode); } + catch (Resgrid.Model.Checklists.ChecklistException ex) { Resgrid.Framework.Logging.LogError($"Checklist calendar unavailable for department {DepartmentId}: status {ex.StatusCode}."); } result.PageSize = result.Data.Count; result.Status = result.Data.Count > 0 ? ResponseHelper.Success : ResponseHelper.NotFound; } Response.Headers["Cache-Control"] = "no-store"; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistApiControllerBase.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistApiControllerBase.cs new file mode 100644 index 000000000..2b9c4106a --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistApiControllerBase.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Localization; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Checklists; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [RequestSizeLimit(1024 * 1024)] + public abstract class ChecklistApiControllerBase : V4AuthenticatedApiControllerbase, IAsyncActionFilter + { + protected readonly IChecklistsService Checklists; + private readonly IStringLocalizer _strings; + protected ChecklistApiControllerBase(IChecklistsService checklists, IStringLocalizer strings) { Checklists = checklists; _strings = strings; } + protected ChecklistActor Actor => new ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = Request.Headers[DataProtectionController.GrantHeader].ToString() }; + protected OkObjectResult Reply(T data, int count = 1, bool hasMore = false) + { + var result = new ChecklistApiResult { Data = data, Status = ResponseHelper.Success, PageSize = count, HasMore = hasMore }; + ResponseHelper.PopulateV4ResponseData(result); return Ok(result); + } + protected static T Required(T input) where T : class => input ?? throw new ChecklistException(400, "The form content is invalid."); + [NonAction] + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + // Framework binding errors can quote the supplied value (including free text). + // Keep malformed JSON/model errors under the same value-free localized contract. + if (!context.ModelState.IsValid) + { + context.Result = Failure(new ChecklistException(400, "The form content is invalid.")); + return; + } + var executed = await next(); + if (executed.Exception is ChecklistException ex) + { + executed.ExceptionHandled = true; + executed.Result = Failure(ex); + } + } + private ObjectResult Failure(ChecklistException ex) + { + var protectedData = ex.StatusCode == 403 && (ex.Message.Contains("protected", StringComparison.OrdinalIgnoreCase) || ex.Message.StartsWith("Unlock", StringComparison.Ordinal)); + var code = protectedData ? "protected_data_required" : ex.StatusCode == 409 ? "checklist_conflict" : ex.StatusCode == 403 ? "checklist_forbidden" : ex.StatusCode == 404 ? "checklist_unavailable" : "checklist_validation"; + var message = _strings[ex.Message]; + // Do not return validation excerpts containing item names, entered values or envelopes. + var problem = new ProblemDetails { Status = ex.StatusCode, Type = code, Title = message.ResourceNotFound ? _strings["The request could not be completed."].Value : message.Value }; + problem.Extensions["IsRedacted"] = protectedData; + return new ObjectResult(problem) { StatusCode = ex.StatusCode }; + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistManagementController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistManagementController.cs new file mode 100644 index 000000000..b2a22fa0d --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistManagementController.cs @@ -0,0 +1,78 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Checklists; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Models.v4.Checklists; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + public partial class ChecklistsController + { + [HttpGet("GetChecklists")] + public async Task GetChecklists(int page = 0) + { var rows = await Checklists.ListAsync(Actor, page, true); return Reply(rows.Take(50).Select(ChecklistDefinitionData.From).ToList(), Math.Min(50, rows.Count), rows.Count > 50); } + [HttpGet("GetChecklist")] + public async Task GetChecklist(string id) => Reply(ChecklistDefinitionData.From(await Checklists.GetDefinitionAsync(Actor, id))); + [HttpGet("GetChecklistTargets")] + public async Task GetChecklistTargets(ChecklistTargetType type, int page = 0) + { + if (page < 0 || page > 10000) throw new ChecklistException(400, "Invalid page."); + var rows = (await Checklists.TargetsAsync(Actor, type)).Skip(page * 50).Take(51).ToList(); + return Reply(rows.Take(50).ToList(), Math.Min(50, rows.Count), rows.Count > 50); + } + [HttpGet("GetChecklistAssignments"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task GetChecklistAssignments(int page = 0) + { + if (page < 0 || page > 10000) throw new ChecklistException(400, "Invalid page."); + var rows = (await Checklists.AssignmentChoicesAsync(Actor)).Skip(page * 50).Take(51).ToList(); + return Reply(rows.Take(50).ToList(), Math.Min(50, rows.Count), rows.Count > 50); + } + [HttpPost("NewChecklist"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task NewChecklist([FromBody] ChecklistDefinitionInput input) + { Required(input); if (input.Id != null) throw new ChecklistException(400, "The form content is invalid."); var id = await Checklists.SaveDefinitionAsync(Actor, null, 0, Required(input.Form)); return Reply(ChecklistDefinitionData.From(await Checklists.GetDefinitionAsync(Actor, id))); } + [HttpPost("UpdateChecklist"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task UpdateChecklist([FromBody] ChecklistDefinitionInput input) + { Required(input); if (input.Id == null) throw new ChecklistException(400, "The form content is invalid."); await Checklists.SaveDefinitionAsync(Actor, input.Id, input.Revision, Required(input.Form)); return Reply(ChecklistDefinitionData.From(await Checklists.GetDefinitionAsync(Actor, input.Id))); } + [HttpPost("PublishChecklist"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task PublishChecklist([FromBody] ChecklistCommandInput input) + { Required(input); await Checklists.PublishAsync(Actor, input.Id, input.Revision); return Reply(ChecklistDefinitionData.From(await Checklists.GetDefinitionAsync(Actor, input.Id))); } + [HttpPost("RetireChecklist"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task RetireChecklist([FromBody] ChecklistCommandInput input) + { Required(input); await Checklists.RetireAsync(Actor, input.Id, input.Revision); return Reply(true); } + [HttpPost("DeleteChecklist"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task DeleteChecklist([FromBody] ChecklistCommandInput input) + { Required(input); await Checklists.RetireAsync(Actor, input.Id, input.Revision, true); return Reply(true); } + [HttpGet("GetSchedules"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task GetSchedules(string definitionId, int page = 0) + { var rows = await Checklists.SchedulesAsync(Actor, definitionId, page); var more = rows.Count == 50 && page < 10000 && (await Checklists.SchedulesAsync(Actor, definitionId, page + 1)).Count > 0; return Reply(rows.Select(ScheduleData).ToList(), rows.Count, more); } + [HttpGet("GetSchedule"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task GetSchedule(string id) => Reply(ScheduleData(await Checklists.GetScheduleAsync(Actor, id))); + [HttpPost("NewSchedule"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task NewSchedule([FromBody] ChecklistScheduleInput input) + { Required(input); if (input.Id != null) throw new ChecklistException(400, "ScheduleValidation"); var id = await Checklists.SaveScheduleAsync(Actor, input); return Reply(ScheduleData(await Checklists.GetScheduleAsync(Actor, id))); } + [HttpPost("UpdateSchedule"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task UpdateSchedule([FromBody] ChecklistScheduleInput input) + { Required(input); if (input.Id == null) throw new ChecklistException(400, "ScheduleValidation"); await Checklists.SaveScheduleAsync(Actor, input); return Reply(ScheduleData(await Checklists.GetScheduleAsync(Actor, input.Id))); } + /// Disables future schedule work, preserving immutable occurrences and completion history. + [HttpPost("DeleteSchedule"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task DeleteSchedule([FromBody] ChecklistCommandInput input) + { Required(input); await Checklists.DisableScheduleAsync(Actor, input.Id, input.Revision); return Reply(true); } + [HttpGet("GetDepartmentChecklistSettings"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task GetDepartmentChecklistSettings() => Reply(await Checklists.ReminderSettingsAsync(Actor)); + [HttpPost("SaveDepartmentChecklistSettings"), Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task SaveDepartmentChecklistSettings([FromBody] ChecklistReminderSettingsInput input) + { await Checklists.SaveReminderSettingsAsync(Actor, Required(input)); return Reply(await Checklists.ReminderSettingsAsync(Actor)); } + private static object ScheduleData(ChecklistScheduleView view) + { + var r = view.Schedule; + return new { Input = new ChecklistScheduleInput { Id = r.Id, DefinitionId = r.ParentId, Revision = r.Revision, Name = view.Content.Name, Notes = view.Content.Notes, TargetId = r.TargetId, + AssignmentType = r.AssignmentType, AssignmentId = r.AssignmentId, Frequency = (ChecklistScheduleFrequency)r.Frequency, TimeZoneId = r.TimeZoneId, StartDate = r.StartDate, EndDate = r.EndDate, + TimesOfDay = string.Join(",", r.ClockMinutes.Split(',').Select(v => TimeOnly.MinValue.AddMinutes(int.Parse(v, CultureInfo.InvariantCulture)).ToString("HH:mm", CultureInfo.InvariantCulture))), + Weekdays = r.Weekdays, DayOfMonth = r.DayOfMonth, MonthOfYear = r.MonthOfYear, WindowMinutes = r.WindowMinutes, WorkshiftId = r.WorkshiftId, IsActive = r.IsActive }, r.VersionId, r.IsSuspended, r.IsProtected, r.UpdatedOn }; + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistReportingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistReportingController.cs new file mode 100644 index 000000000..cd61dcaf1 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistReportingController.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Checklists; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + public sealed partial class ChecklistRunsController + { + /// Completion rates and missed deadlines within a UTC start-date cohort (at most 93 days), filtered by current result permissions and ADP. + [HttpGet("GetComplianceSummary")] + public async Task GetComplianceSummary([FromQuery] ChecklistReportQuery query) => Reply(await Checklists.GetComplianceSummaryAsync(Actor, query)); + /// Checklist evidence at the call time, with explicit unavailable historical inventory/deployment sources. This is a preview; RMS captures an immutable artifact separately. + [HttpGet("GetReadinessPacket")] + public async Task GetReadinessPacket(int callId, int lookbackDays = 30) => Reply(await Checklists.GetReadinessPacketForCallAsync(Actor, callId, lookbackDays)); + [HttpGet("GetEntityChecklistHistory")] + public async Task GetEntityChecklistHistory(ChecklistTargetType entityType, string entityId, DateTime fromUtc, DateTime untilUtc) => Reply(await Checklists.GetEntityChecklistHistoryAsync(Actor, entityType, entityId, fromUtc, untilUtc)); + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistRunsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistRunsController.cs new file mode 100644 index 000000000..7f8a09cd0 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistRunsController.cs @@ -0,0 +1,95 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Models.v4.Checklists; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0"), ApiExplorerSettings(GroupName = "v4"), Authorize] + public sealed partial class ChecklistRunsController : ChecklistApiControllerBase + { + private readonly IReadinessAccessService _access; + private readonly IDepartmentDataProtectionService _protection; + public ChecklistRunsController(IChecklistsService checklists, IStringLocalizer strings, IReadinessAccessService access, IDepartmentDataProtectionService protection) : base(checklists, strings) { _access = access; _protection = protection; } + /// Fresh value-free scope and access state for secure local drafts. A mutation always repeats authorization and grant validation. + [HttpGet("GetChecklistAccess")] + public async Task GetChecklistAccess() + { + var manage = await Checklists.CanManageAsync(Actor); + return Reply(new { DepartmentId, UserId, CanManage = manage, Enabled = await _access.CanUseChecklistsAsync(DepartmentId), IsProtected = await _protection.IsProtectionEnforcedAsync(DepartmentId) }); + } + /// Paged, authorized scheduled checks and published on-demand forms. Unit scope includes currently issued serialized assets when available. + [HttpGet("GetDueChecklists")] + public async Task GetDueChecklists([FromQuery] ChecklistMobileQuery query) + { + var page = await Checklists.MobileDueAsync(Actor, query); + return Reply(new ChecklistDuePageData { Occurrences = page.Occurrences.Select(ChecklistDueData.From).ToList(), Definitions = page.Definitions.Select(v => { var d = ChecklistDefinitionData.From(v.Definition); d.Targets = v.Targets; return d; }).ToList(), + HasMoreDefinitions = page.HasMoreDefinitions, HasMoreOccurrences = page.HasMoreOccurrences }, page.Occurrences.Count + page.Definitions.Count, page.HasMoreDefinitions || page.HasMoreOccurrences); + } + /// Reads a run by ID, or previews a pinned scheduled occurrence without creating a run. + [HttpGet("GetChecklistRun")] + public async Task GetChecklistRun(string id = null, string occurrenceId = null) + { + if ((id == null) == (occurrenceId == null)) throw new ChecklistException(400, "The form content is invalid."); + return Reply(ChecklistRunData.From(id == null ? await Checklists.PreviewOccurrenceAsync(Actor, occurrenceId) : await Checklists.GetRunAsync(Actor, id), UserId)); + } + /// Starts or resumes one logical client GUID. The supplied published version is preserved for an offline on-demand run. + [HttpPost("StartChecklistRun")] + public async Task StartChecklistRun([FromBody] ChecklistStartInput input) + { + Required(input); + if (input.CompletionId == null || (input.OccurrenceId == null) == (input.DefinitionId == null) || input.OccurrenceId == null && input.VersionId == null) throw new ChecklistException(400, "The form content is invalid."); + var id = input.OccurrenceId == null ? await Checklists.StartPinnedAsync(Actor, input.DefinitionId, input.VersionId, input.TargetId, input.CompletionId) + : await Checklists.StartOccurrenceWithIdAsync(Actor, input.OccurrenceId, input.CompletionId); + return Reply(ChecklistRunData.From(await Checklists.GetRunAsync(Actor, id), UserId)); + } + [HttpPost("SaveChecklistRunProgress")] + public Task SaveChecklistRunProgress([FromBody] ChecklistProgressInput input) => Save(input, false); + /// Replays with the same validated answers/evidence produce one completion and one set of Workflow events. Different stale content returns 409. + [HttpPost("CompleteChecklistRun")] + public Task CompleteChecklistRun([FromBody] ChecklistProgressInput input) => Save(input, true); + private async Task Save(ChecklistProgressInput input, bool submit) + { + Required(input); Required(input.Input); + await Checklists.SaveRunAsync(Actor, input.Id, input.Input, submit); + return Reply(ChecklistRunData.From(await Checklists.GetRunAsync(Actor, input.Id), UserId)); + } + [HttpPost("AttestChecklistRun")] + public async Task AttestChecklistRun([FromBody] ChecklistWitnessInput input) + { Required(input); await Checklists.WitnessAsync(Actor, input.Id, input.SubmissionHash, input.Attestation); return Reply(ChecklistRunData.From(await Checklists.GetRunAsync(Actor, input.Id), UserId)); } + [HttpPost("SkipChecklistOccurrence")] + public async Task SkipChecklistOccurrence([FromBody] ChecklistSkipInput input) + { Required(input); await Checklists.SkipOccurrenceAsync(Actor, input.Id, input.Revision, input.Reason); return Reply(true); } + [HttpPost("UploadChecklistRunFile"), Consumes("multipart/form-data"), RequestSizeLimit(11 * 1024 * 1024)] + public async Task UploadChecklistRunFile([FromForm] string id, [FromForm] string itemId, [FromForm] int revision, IFormFile file) + { + if (file == null || file.Length == 0 || file.Length > 10 * 1024 * 1024) throw new ChecklistException(400, "Choose an evidence image up to 10 MB."); + using var stream = new MemoryStream(); await file.CopyToAsync(stream); + await Checklists.AddFileAtRevisionAsync(Actor, id, itemId, revision, file.FileName, file.ContentType, stream.ToArray()); + return Reply(ChecklistRunData.From(await Checklists.GetRunAsync(Actor, id), UserId)); + } + /// In-memory image transport for native clients, with the same size, scan, revision and ADP rules as multipart uploads. + [HttpPost("UploadChecklistRunFile"), Consumes("application/json"), RequestSizeLimit(15 * 1024 * 1024)] + public async Task UploadChecklistRunEvidence([FromBody] ChecklistEvidenceInput input) + { + Required(input); await Checklists.AddFileAtRevisionAsync(Actor, input.Id, input.ItemId, input.Revision, input.Name, input.ContentType, input.Data); + return Reply(ChecklistRunData.From(await Checklists.GetRunAsync(Actor, input.Id), UserId)); + } + [HttpGet("GetChecklistRunFile")] + public async Task GetChecklistRunFile(string id) + { var file = await Checklists.GetFileAsync(Actor, id); Response.Headers["X-Content-Type-Options"] = "nosniff"; return File(file.Data, file.ContentType, file.Content); } + [HttpPost("DeleteChecklistRunFile")] + public async Task DeleteChecklistRunFile([FromBody] ChecklistCommandInput input) + { Required(input); await Checklists.DeleteFileAtRevisionAsync(Actor, input.Id, input.Revision); return Reply(true); } + [HttpGet("GetChecklistHistory")] + public async Task GetChecklistHistory([FromQuery] ChecklistMobileQuery query) + { var rows = await Checklists.MobileHistoryAsync(Actor, query); return Reply(rows.Take(50).Select(ChecklistHistoryData.From).ToList(), System.Math.Min(rows.Count, 50), rows.Count > 50); } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistsController.cs index 4bb0e09cd..f6df5de53 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistsController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; using Resgrid.Model.Services; using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.Checklists; @@ -11,10 +12,10 @@ namespace Resgrid.Web.Services.Controllers.v4 [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] [Authorize] - public class ChecklistsController : V4AuthenticatedApiControllerbase + public partial class ChecklistsController : ChecklistApiControllerBase { private readonly IChecklistTemplateService _templates; - public ChecklistsController(IChecklistTemplateService templates) => _templates = templates; + public ChecklistsController(IChecklistTemplateService templates, IChecklistsService checklists, IStringLocalizer strings) : base(checklists, strings) => _templates = templates; /// Searches the free starter catalog for the authenticated department. [HttpGet("GetChecklistTemplates")] diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs new file mode 100644 index 000000000..30518811f --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs @@ -0,0 +1,77 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Localization; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.WorkOrders; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + [Route("api/v{VersionId:apiVersion}/[controller]"), ApiVersion("4.0"), ApiExplorerSettings(GroupName = "v4"), Authorize] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] + public sealed class WorkOrdersController : V4AuthenticatedApiControllerbase, IAsyncActionFilter + { + private readonly IWorkOrdersService _orders; + private readonly IStringLocalizer _strings; + public WorkOrdersController(IWorkOrdersService orders, IStringLocalizer strings) { _orders = orders; _strings = strings; } + private ChecklistActor Actor => new ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = Request.Headers[DataProtectionController.GrantHeader].ToString() }; + private OkObjectResult Reply(T value, int count = 1, bool more = false) { var response = new WorkOrderApiResult { Data = value, Status = ResponseHelper.Success, PageSize = count, HasMore = more }; ResponseHelper.PopulateV4ResponseData(response); return Ok(response); } + private static T Required(T input) where T : class => input ?? throw new WorkOrderException(400, "InvalidInput"); + [NonAction] + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + if (!context.ModelState.IsValid) { context.Result = Failure(new WorkOrderException(400, "InvalidInput")); return; } + var executed = await next(); if (executed.Exception is WorkOrderException ex) { executed.ExceptionHandled = true; executed.Result = Failure(ex); } + } + private ObjectResult Failure(WorkOrderException ex) + { + var problem = new ProblemDetails { Status = ex.StatusCode, Type = ex.Code == "ProtectedDataRequired" ? "protected_data_required" : "work_order_" + ex.Code, Title = _strings[ex.Code] }; + problem.Extensions["IsRedacted"] = ex.Code == "ProtectedDataRequired"; return new ObjectResult(problem) { StatusCode = ex.StatusCode }; + } + [HttpGet("GetWorkOrders")] + public async Task GetWorkOrders([FromQuery] WorkOrderFilter filter) { var page = await _orders.ListAsync(Actor, filter); return Reply(page, page.Items.Count, page.HasMore); } + [HttpGet("GetWorkOrder")] + public async Task GetWorkOrder(int id) => Reply(await _orders.GetAsync(Actor, id)); + [HttpGet("GetWorkOrderChoices")] + public async Task GetWorkOrderChoices() => Reply(await _orders.ChoicesAsync(Actor)); + [HttpGet("GetWorkOrderActivity")] + public async Task GetWorkOrderActivity(int id) { var detail = await _orders.GetAsync(Actor, id); return Reply(detail.Activities, detail.Activities.Count); } + [HttpGet("GetWorkOrderHistoryForAsset")] + public Task GetWorkOrderHistoryForAsset(string assetId, int page = 0) { if (string.IsNullOrWhiteSpace(assetId)) throw new WorkOrderException(400, "InvalidInput"); return GetWorkOrders(new WorkOrderFilter { AssetId = assetId, Page = page }); } + [HttpPost("NewWorkOrder")] + public async Task NewWorkOrder([FromBody] WorkOrderInput input) => Reply(await _orders.CreateAsync(Actor, Required(input))); + [HttpPost("UpdateWorkOrder")] + public async Task UpdateWorkOrder([FromBody] WorkOrderUpdateInput input) { Required(input); await _orders.UpdateAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("SetWorkOrderStatus")] + public async Task SetWorkOrderStatus([FromBody] WorkOrderStatusInput input) { Required(input); await _orders.TransitionAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("AssignWorkOrder")] + public async Task AssignWorkOrder([FromBody] WorkOrderAssignInput input) { Required(input); await _orders.AssignAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("AcceptWorkOrderAssignment")] + public async Task AcceptWorkOrderAssignment([FromBody] WorkOrderCommandInput input) { Required(input); await _orders.AcceptAssignmentAsync(Actor, input.Id, input.Revision); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("AddWorkOrderComment")] + public async Task AddWorkOrderComment([FromBody] WorkOrderCommandInput input) { Required(input); await _orders.CommentAsync(Actor, input.Id, input.Revision, input.Note); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("AddWorkOrderLabor")] + public async Task AddWorkOrderLabor([FromBody] WorkOrderApiLaborInput input) { Required(input); await _orders.AddLaborAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("AddWorkOrderPart")] + public async Task AddWorkOrderPart([FromBody] WorkOrderApiPartInput input) { Required(input); await _orders.AddPartAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("RemoveWorkOrderPart")] + public async Task RemoveWorkOrderPart([FromBody] WorkOrderCommandInput input) { Required(input); await _orders.VoidPartAsync(Actor, input.Id, input.ChildId, input.Revision, input.Note); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("UploadWorkOrderFile"), RequestSizeLimit(11 * 1024 * 1024)] + public async Task UploadWorkOrderFile([FromForm] int id, [FromForm] int revision, IFormFile file) + { + if (file == null || file.Length > 10 * 1024 * 1024) throw new WorkOrderException(400, "InvalidFile"); + using var buffer = new MemoryStream(); await file.CopyToAsync(buffer); await _orders.AddFileAsync(Actor, id, revision, file.FileName, file.ContentType, buffer.ToArray()); return Reply(await _orders.GetAsync(Actor, id)); + } + [HttpGet("GetWorkOrderFile")] + public async Task GetWorkOrderFile(int id) { var file = await _orders.GetFileAsync(Actor, id); Response.Headers["X-Content-Type-Options"] = "nosniff"; return File(file.Data, file.ContentType, file.Content); } + [HttpPost("RemoveWorkOrderFile")] + public async Task RemoveWorkOrderFile([FromBody] WorkOrderCommandInput input) { Required(input); await _orders.WithdrawFileAsync(Actor, input.Id, input.ChildId, input.Revision, input.Note); return Reply(await _orders.GetAsync(Actor, input.Id)); } + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs index 9c619f70a..d75d1c978 100644 --- a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs @@ -11,6 +11,8 @@ public static class ClaimsAuthorizationHelper { public static IHttpContextAccessor _httpContextAccessor; + public static bool CanManageWorkOrders() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.Update); + public static bool CanViewAllWorkOrders() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.View); public static bool CanManageChecklists() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update); public static bool CanViewChecklistResults() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View); public static ResgridIdentity GetIdentity() diff --git a/Web/Resgrid.Web.Services/Helpers/ResponseHelper.cs b/Web/Resgrid.Web.Services/Helpers/ResponseHelper.cs index cdd35803b..4da67571b 100644 --- a/Web/Resgrid.Web.Services/Helpers/ResponseHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/ResponseHelper.cs @@ -10,7 +10,7 @@ public static void PopulateV4ResponseData(StandardApiResponseV4Base data) data.Timestamp = DateTime.UtcNow; data.Version = "v4"; data.Node = Environment.MachineName; - data.RequestId = System.Diagnostics.Activity.Current.Id; + data.RequestId = System.Diagnostics.Activity.Current?.Id ?? Guid.NewGuid().ToString("N"); data.Environment = Enum.GetName(Config.SystemBehaviorConfig.Environment); } diff --git a/Web/Resgrid.Web.Services/Models/v4/Checklists/ChecklistApiContracts.cs b/Web/Resgrid.Web.Services/Models/v4/Checklists/ChecklistApiContracts.cs new file mode 100644 index 000000000..6cabe190c --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Checklists/ChecklistApiContracts.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model.Checklists; + +namespace Resgrid.Web.Services.Models.v4.Checklists +{ + public sealed class ChecklistApiResult : StandardApiResponseV4Base + { + public T Data { get; set; } + public bool HasMore { get; set; } + public int ContractVersion { get; set; } = 1; + } + public class ChecklistCommandInput { public string Id { get; set; } public int Revision { get; set; } } + public sealed class ChecklistDefinitionInput : ChecklistCommandInput { public ChecklistForm Form { get; set; } } + public sealed class ChecklistStartInput + { + public string OccurrenceId { get; set; } + public string DefinitionId { get; set; } + public string VersionId { get; set; } + public string TargetId { get; set; } + public string CompletionId { get; set; } + } + public sealed class ChecklistProgressInput { public string Id { get; set; } public ChecklistRunInput Input { get; set; } } + public sealed class ChecklistWitnessInput { public string Id { get; set; } public string SubmissionHash { get; set; } public string Attestation { get; set; } } + public sealed class ChecklistSkipInput : ChecklistCommandInput { public string Reason { get; set; } } + public sealed class ChecklistEvidenceInput : ChecklistCommandInput + { + public string ItemId { get; set; } + public string Name { get; set; } + public string ContentType { get; set; } + public byte[] Data { get; set; } + } + public sealed class ChecklistDefinitionData + { + public string Id { get; set; } + public int Revision { get; set; } + public string VersionId { get; set; } + public int VersionNumber { get; set; } + public bool Retired { get; set; } + public bool IsProtected { get; set; } + public DateTime UpdatedOn { get; set; } + public ChecklistForm Form { get; set; } + public ChecklistForm PublishedForm { get; set; } + public List Targets { get; set; } + public static ChecklistDefinitionData From(ChecklistDefinitionView view) => new ChecklistDefinitionData { Id = view.Definition.Id, Revision = view.Definition.Revision, + VersionId = view.Definition.CurrentVersionId, VersionNumber = view.Definition.PublishedVersion, Retired = view.Definition.Retired, + IsProtected = view.Definition.IsProtected, UpdatedOn = view.Definition.UpdatedOn, Form = view.Form, PublishedForm = view.PublishedForm }; + } + public sealed class ChecklistFileData + { + public string Id { get; set; } + public string ItemId { get; set; } + public string Name { get; set; } + public string ContentType { get; set; } + public int Size { get; set; } + public int ScanState { get; set; } + public static ChecklistFileData From(ChecklistCompletionFile f) => new ChecklistFileData { Id = f.Id, ItemId = f.ItemId, Name = f.Content, ContentType = f.ContentType, Size = f.Size, ScanState = f.ScanState }; + } + public sealed class ChecklistRunData + { + public string Id { get; set; } + public string OccurrenceId { get; set; } + public string DefinitionId { get; set; } + public string VersionId { get; set; } + public int VersionNumber { get; set; } + public int Revision { get; set; } + public int State { get; set; } + public bool IsProtected { get; set; } + public bool IsPreview { get; set; } + public bool CanEdit { get; set; } + public string CreatedBy { get; set; } + public DateTime UpdatedOn { get; set; } + public DateTime? SubmittedOn { get; set; } + public string SubmissionHash { get; set; } + public string WitnessUserId { get; set; } + public string WitnessAttestation { get; set; } + public decimal? Score { get; set; } + public bool? Passed { get; set; } + public ChecklistTarget Target { get; set; } + public ChecklistForm Form { get; set; } + public ChecklistRunInput Input { get; set; } + public List Files { get; set; } + public static ChecklistRunData From(ChecklistRunView v, string userId) => new ChecklistRunData { Id = v.Completion.Id, OccurrenceId = v.Completion.OccurrenceId, DefinitionId = v.Completion.ParentId, + VersionId = v.Completion.VersionId, VersionNumber = v.VersionNumber, Revision = v.Completion.Revision, State = v.Completion.State, IsProtected = v.Completion.IsProtected, + IsPreview = v.Completion.Revision == 0, CanEdit = v.Completion.State == 0 && v.Completion.CreatedBy == userId, CreatedBy = v.Completion.CreatedBy, + UpdatedOn = v.Completion.UpdatedOn, SubmittedOn = v.Completion.SubmittedOn, SubmissionHash = v.Completion.SubmissionHash, WitnessUserId = v.Completion.WitnessUserId, + WitnessAttestation = v.WitnessAttestation, Score = v.Completion.Score, Passed = v.Completion.Passed, Target = v.Target, Form = v.Form, Input = v.Input, Files = v.Files.Select(ChecklistFileData.From).ToList() }; + } + public sealed class ChecklistDueData + { + public string Id { get; set; } + public string CompletionId { get; set; } + public string DefinitionId { get; set; } + public string VersionId { get; set; } + public string Name { get; set; } + public ChecklistTarget Target { get; set; } + public int State { get; set; } + public int Revision { get; set; } + public DateTime UpdatedOn { get; set; } + public DateTime? PeriodStartUtc { get; set; } + public DateTime? WindowEndUtc { get; set; } + public bool CanStart { get; set; } + public static ChecklistDueData From(ChecklistOccurrenceView v) => new ChecklistDueData { Id = v.Occurrence.Id, CompletionId = v.Occurrence.CompletionId, DefinitionId = v.Occurrence.ParentId, + VersionId = v.Occurrence.VersionId, Name = v.Name, Target = v.Target, State = v.Occurrence.State, Revision = v.Occurrence.Revision, UpdatedOn = v.Occurrence.UpdatedOn, + PeriodStartUtc = v.Occurrence.PeriodStartUtc, WindowEndUtc = v.Occurrence.WindowEndUtc, CanStart = v.CanStart }; + } + public sealed class ChecklistDuePageData + { + public List Occurrences { get; set; } + public List Definitions { get; set; } + public bool HasMoreOccurrences { get; set; } + public bool HasMoreDefinitions { get; set; } + } + public sealed class ChecklistHistoryData + { + public string Id { get; set; } + public string DefinitionId { get; set; } + public string VersionId { get; set; } + public int TargetType { get; set; } + public string TargetId { get; set; } + public string TargetName { get; set; } + public string CreatedBy { get; set; } + public int State { get; set; } + public int Revision { get; set; } + public bool IsProtected { get; set; } + public DateTime UpdatedOn { get; set; } + public DateTime? SubmittedOn { get; set; } + public decimal? Score { get; set; } + public bool? Passed { get; set; } + public static ChecklistHistoryData From(ChecklistHistoryEntry v) => new ChecklistHistoryData { Id = v.Completion.Id, DefinitionId = v.Completion.ParentId, VersionId = v.Completion.VersionId, + TargetType = v.Completion.TargetType, TargetId = v.Completion.TargetId, TargetName = v.TargetName, CreatedBy = v.Completion.CreatedBy, State = v.Completion.State, Revision = v.Completion.Revision, + IsProtected = v.Completion.IsProtected, UpdatedOn = v.Completion.UpdatedOn, SubmittedOn = v.Completion.SubmittedOn, Score = v.Completion.Score, Passed = v.Completion.Passed }; + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WorkOrders/WorkOrderApiContracts.cs b/Web/Resgrid.Web.Services/Models/v4/WorkOrders/WorkOrderApiContracts.cs new file mode 100644 index 000000000..b5e5ec1b8 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WorkOrders/WorkOrderApiContracts.cs @@ -0,0 +1,12 @@ +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Web.Services.Models.v4.WorkOrders +{ + public sealed class WorkOrderApiResult : StandardApiResponseV4Base { public T Data { get; set; } public bool HasMore { get; set; } public int ContractVersion { get; set; } = 1; } + public sealed class WorkOrderCommandInput { public int Id { get; set; } public int Revision { get; set; } public int ChildId { get; set; } public string Note { get; set; } } + public sealed class WorkOrderUpdateInput { public int Id { get; set; } public WorkOrderInput Input { get; set; } } + public sealed class WorkOrderStatusInput { public int Id { get; set; } public WorkOrderTransition Input { get; set; } } + public sealed class WorkOrderAssignInput { public int Id { get; set; } public WorkOrderAssignment Input { get; set; } } + public sealed class WorkOrderApiLaborInput { public int Id { get; set; } public WorkOrderLaborInput Input { get; set; } } + public sealed class WorkOrderApiPartInput { public int Id { get; set; } public WorkOrderPartInput Input { get; set; } } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index b0025ac92..2246f1684 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1120,12 +1120,39 @@ The call identifier to inspect. + + Disables future schedule work, preserving immutable occurrences and completion history. + Searches the free starter catalog for the authenticated department. Returns a starter template preview, including critical checks and witness requirements. + + Completion rates and missed deadlines within a UTC start-date cohort (at most 93 days), filtered by current result permissions and ADP. + + + Checklist evidence at the call time, with explicit unavailable historical inventory/deployment sources. This is a preview; RMS captures an immutable artifact separately. + + + Fresh value-free scope and access state for secure local drafts. A mutation always repeats authorization and grant validation. + + + Paged, authorized scheduled checks and published on-demand forms. Unit scope includes currently issued serialized assets when available. + + + Reads a run by ID, or previews a pinned scheduled occurrence without creating a run. + + + Starts or resumes one logical client GUID. The supplied published version is preserved for an offline on-demand run. + + + Replays with the same validated answers/evidence produce one completion and one set of Workflow events. Different stale content returns 409. + + + In-memory image transport for native clients, with the same size, scan, revision and ADP rules as multipart uploads. + Command definitions: predefined incident-command templates (swimlanes) per call type, used to diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index f17693a84..baa4e8690 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -317,6 +317,8 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.Record_Reassign, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Reassign)); options.AddPolicy(ResgridResources.RecordLegacy_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordLegacy, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.RecordRestricted_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordRestricted, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.WorkOrder_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.WorkOrder_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.Checklist_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.ChecklistResults_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.RecordDefinition_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordDefinition, ResgridClaimTypes.Actions.Update)); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs index 58cba895e..d0aa704e8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs @@ -661,10 +661,10 @@ public async Task GetV2CalendarEntriesForCal(string start, string if (!DateTimeOffset.TryParse(start, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var from) || !DateTimeOffset.TryParse(end, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var until)) return BadRequest(); try { - foreach (var entry in await _checklists.CalendarAsync(new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = DepartmentId, UserId = UserId }, from.UtcDateTime, until.UtcDateTime)) + foreach (var entry in await _checklists.CalendarAsync(new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = Request.Headers[Resgrid.Web.Helpers.HttpProtectedGrantContext.HeaderName].ToString() }, from.UtcDateTime, until.UtcDateTime)) jsonItems.Add(new { id = entry.Id, title = entry.Title, start = entry.StartUtc.ToString("O"), end = entry.EndUtc.ToString("O"), allDay = false, backgroundColor = entry.State == 4 ? "#c0392b" : entry.State == 2 ? "#247a42" : "#6a4c93", checklistState = entry.State, url = Url.Action("Occurrence", "Checklists", new { area = "User", id = entry.OccurrenceId }), isVirtual = true, isRedacted = entry.IsRedacted }); } - catch (Resgrid.Model.Checklists.ChecklistException ex) { return StatusCode(ex.StatusCode); } + catch (Resgrid.Model.Checklists.ChecklistException ex) { Logging.LogError($"Checklist calendar unavailable for department {DepartmentId}: status {ex.StatusCode}."); } } Response.Headers["Cache-Control"] = "no-store"; return Json(jsonItems); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportingController.cs new file mode 100644 index 000000000..d36f6c3f9 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportingController.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Services; + +namespace Resgrid.Web.Areas.User.Controllers +{ + public partial class ChecklistsController + { + [HttpGet] + public Task Compliance() => ComplianceView(new ChecklistReportQuery { FromUtc = DateTime.UtcNow.Date.AddDays(-30), UntilUtc = DateTime.UtcNow.Date.AddDays(1) }); + [HttpPost, ValidateAntiForgeryToken] + public Task Compliance(ChecklistReportQuery query) => ComplianceView(query); + private async Task ComplianceView(ChecklistReportQuery query) + { + try { return View("Compliance", await _checklists.GetComplianceSummaryAsync(Actor, query)); } + catch (ChecklistException ex) when (ex.StatusCode == 403 && ex.Message.StartsWith("Unlock", StringComparison.Ordinal)) + { ViewBag.ProtectionEnforced = true; return View("Compliance", new ChecklistComplianceSummary { FromUtc = query.FromUtc, UntilUtc = query.UntilUtc, TargetType = query.TargetType, TargetId = query.TargetId, IsRedacted = true }); } + } + [HttpPost, ValidateAntiForgeryToken] + public async Task ComplianceCsv(ChecklistReportQuery query) => File(ChecklistReportDocuments.Csv(await _checklists.GetComplianceSummaryAsync(Actor, query)), "text/csv; charset=utf-8", "checklist-compliance.csv"); + [HttpGet] + public IActionResult ReadinessPacket() => View("ReadinessPacket"); + [HttpPost, ValidateAntiForgeryToken] + public async Task ReadinessPacket(int callId, int lookbackDays = 30) + { + ViewBag.CallId = callId; ViewBag.LookbackDays = lookbackDays; + return View("ReadinessPacket", await _checklists.GetReadinessPacketForCallAsync(Actor, callId, lookbackDays)); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task ReadinessPacketDownload(int callId, int lookbackDays, [FromServices] IPdfProvider pdf) + { + var manifest = await _checklists.GetReadinessPacketForCallAsync(Actor, callId, lookbackDays); + var package = ChecklistReportDocuments.Package(manifest, pdf); + // Revalidate the attended source after potentially slow PDF conversion. + var current = await _checklists.GetReadinessPacketForCallAsync(Actor, callId, lookbackDays); + ChecklistReportDocuments.EnsureStillAuthorized(manifest, current); + using var output = new MemoryStream(); + using (var zip = new ZipArchive(output, ZipArchiveMode.Create, true)) + { + void Add(string name, byte[] data) { using var stream = zip.CreateEntry(name).Open(); stream.Write(data); } + Add("readiness.pdf", package.Pdf); Add("manifest.json", Encoding.UTF8.GetBytes(package.ManifestJson)); + Add("sha256.txt", Encoding.UTF8.GetBytes(package.PdfSha256 + " readiness.pdf\n" + package.ManifestSha256 + " manifest.json\n")); + } + return File(output.ToArray(), "application/zip", "readiness-" + callId + ".zip"); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportsController.cs new file mode 100644 index 000000000..f368b6c07 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportsController.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Web.Areas.User.Controllers +{ + public partial class ReportsController + { + [HttpPost, ValidateAntiForgeryToken, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task ChecklistComplianceReport(ChecklistReportQuery query, [FromServices] IChecklistsService checklists, [FromServices] IProtectedGrantContext grant, [FromServices] IDepartmentDataProtectionService protection, bool missedOnly = false) + { + Response.Headers["Cache-Control"] = "no-store"; + try + { + var report = await checklists.GetComplianceSummaryAsync(new ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = grant.GrantToken }, query); + ViewBag.ProtectionEnforced = await protection.IsProtectionEnforcedAsync(DepartmentId); + ViewBag.ProtectedGrant = grant.GrantToken; ViewBag.GrantExpiresOn = Resgrid.Web.Helpers.HttpProtectedGrantContext.ReadExpiry(Request); + ViewBag.MissedOnly = missedOnly; + return View("ChecklistComplianceReport", report); + } + catch (ChecklistException ex) { return StatusCode(ex.StatusCode, ChecklistReportDocuments.Text("The request could not be completed.")); } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs index b328e0104..cfe1563e1 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs @@ -32,6 +32,8 @@ public partial class ChecklistsController : SecureBaseController public ChecklistsController(IChecklistTemplateService templates, IChecklistsService checklists, IReadinessAccessService access, IProtectedGrantContext grant, IDepartmentDataProtectionService protection, IStringLocalizer strings, Lazy workshifts = null, Lazy departments = null) { _templates = templates; _checklists = checklists; _access = access; _grant = grant; _protection = protection; _strings = strings; _workshifts = workshifts; _departments = departments; } private ChecklistActor Actor => new ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = _grant.GrantToken }; + private Task _checklistsEnabled; + private Task ChecklistsEnabledAsync() => _checklistsEnabled ??= _access.CanUseChecklistsAsync(DepartmentId); public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { Response.Headers["Cache-Control"] = "no-store"; @@ -39,7 +41,7 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context ViewBag.ProtectedGrant = _grant.GrantToken; ViewBag.GrantExpiresOn = HttpProtectedGrantContext.ReadExpiry(Request); ViewBag.ChecklistUserId = UserId; - ViewBag.ChecklistsEnabled = await _access.CanUseChecklistsAsync(DepartmentId); + ViewBag.ChecklistsEnabled = await ChecklistsEnabledAsync(); var executed = await next(); if (executed.Exception is ChecklistException ex) { @@ -54,7 +56,7 @@ public async Task Index(int page = 0) { var rows = await _checklists.ListAsync(Actor, page, includeNext: true); return View("Index", new ChecklistIndexView { Definitions = rows.Take(50).ToList(), HasMore = rows.Count > 50, - CanManage = await _checklists.CanManageAsync(Actor) && await _access.CanUseChecklistsAsync(DepartmentId), Page = page }); + CanManage = await _checklists.CanManageAsync(Actor) && await ChecklistsEnabledAsync(), Page = page }); } [HttpGet] public async Task Templates(string query = null) @@ -75,7 +77,7 @@ public async Task Template(string id) [HttpGet, Authorize(Policy = ResgridResources.Checklist_Update)] public async Task New(string templateId = null) { - if (!await _access.CanUseChecklistsAsync(DepartmentId) || !await _checklists.CanManageAsync(Actor)) return NotFound(); + if (!await ChecklistsEnabledAsync() || !await _checklists.CanManageAsync(Actor)) return NotFound(); var assetsAvailable = await _checklists.AssetTargetsAvailableAsync(Actor); var form = new ChecklistForm { Name = "", Sections = { new ChecklistSection { Name = _strings["Checks"].Value, Items = { new ChecklistItem { Name = "" } } } } }; if (templateId != null) @@ -90,7 +92,7 @@ public async Task New(string templateId = null) public async Task Edit(string id) { if (!await _checklists.CanManageAsync(Actor)) return Forbid(); - if (!await _access.CanUseChecklistsAsync(DepartmentId)) return NotFound(); + if (!await ChecklistsEnabledAsync()) return NotFound(); var row = await _checklists.GetDefinitionAsync(Actor, id); return View("Edit", new ChecklistEditView { AssetsAvailable = await _checklists.AssetTargetsAvailableAsync(Actor), Id = id, Revision = row.Definition.Revision, Form = row.Form }); } @@ -111,9 +113,9 @@ private static T Parse(string json) where T : class public async Task Detail(string id, int page = 0) { var row = await _checklists.GetDefinitionAsync(Actor, id); - var canStart = await _access.CanUseChecklistsAsync(DepartmentId) && !row.Definition.Retired && row.Definition.CurrentVersionId != null; + var canStart = await ChecklistsEnabledAsync() && !row.Definition.Retired && row.Definition.CurrentVersionId != null; var history = await _checklists.HistoryAsync(Actor, id, page, includeNext: true); - return View("Detail", new ChecklistDetailView { Definition = row, History = history.Take(50).ToList(), HasMore = history.Count > 50, CanManage = await _checklists.CanManageAsync(Actor) && await _access.CanUseChecklistsAsync(DepartmentId), CanStart = canStart, + return View("Detail", new ChecklistDetailView { Definition = row, History = history.Take(50).ToList(), HasMore = history.Count > 50, CanManage = await _checklists.CanManageAsync(Actor) && await ChecklistsEnabledAsync(), CanStart = canStart, Targets = canStart ? await _checklists.TargetsAsync(Actor, row.PublishedForm.TargetType) : new System.Collections.Generic.List(), Page = page }); } [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = ResgridResources.Checklist_Update)] @@ -127,7 +129,7 @@ public async Task Start(string id, string targetId, string comple public async Task Run(string id) { var run = await _checklists.GetRunAsync(Actor, id); - return View(run.Completion.State == (int)ChecklistRunState.InProgress && run.Completion.CreatedBy == UserId && await _access.CanUseChecklistsAsync(DepartmentId) ? "Run" : "CompletionDetail", run); + return View(run.Completion.State == (int)ChecklistRunState.InProgress && run.Completion.CreatedBy == UserId && await ChecklistsEnabledAsync() ? "Run" : "CompletionDetail", run); } [HttpGet] public Task CompletionDetail(string id) => Run(id); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs index d37c4b37c..707c103e3 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs @@ -13,11 +13,11 @@ namespace Resgrid.Web.Areas.User.Controllers public partial class ChecklistsController { [HttpGet, Authorize(Policy = ResgridResources.Checklist_Update)] - public async Task Schedules(string id, int page = 0) => View("Schedules", new ChecklistSchedulesView { DefinitionId = id, Schedules = await _checklists.SchedulesAsync(Actor, id, page), Page = page, CanEdit = await _access.CanUseChecklistsAsync(DepartmentId) }); + public async Task Schedules(string id, int page = 0) => View("Schedules", new ChecklistSchedulesView { DefinitionId = id, Schedules = await _checklists.SchedulesAsync(Actor, id, page), Page = page, CanEdit = await ChecklistsEnabledAsync() }); [HttpGet, Authorize(Policy = ResgridResources.Checklist_Update)] public async Task EditSchedule(string id = null, string definitionId = null) { - if (!await _access.CanUseChecklistsAsync(DepartmentId) || !await _checklists.CanManageAsync(Actor)) return Forbid(); + if (!await ChecklistsEnabledAsync() || !await _checklists.CanManageAsync(Actor)) return Forbid(); var input = new ChecklistScheduleInput { DefinitionId = definitionId }; if (id != null) { @@ -55,7 +55,11 @@ public async Task SaveSchedule(ChecklistScheduleInput input, int[ await _checklists.SaveScheduleAsync(Actor, input); return RedirectToAction("Schedules", new { id = input.DefinitionId }); } [HttpGet] - public async Task Due(int page = 0) => View("Due", new ChecklistDueView { Occurrences = await _checklists.DueAsync(Actor, page), Page = page }); + public async Task Due(int page = 0) + { + var rows = await _checklists.DueAsync(Actor, page, includeNext: true); + return View("Due", new ChecklistDueView { Occurrences = rows.Take(50).ToList(), Page = page, HasMore = rows.Count > 50 }); + } [HttpGet] public async Task Occurrence(string id) => View("Occurrence", await _checklists.OccurrenceAsync(Actor, id)); [HttpPost, ValidateAntiForgeryToken] diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index 9c37631df..7f323f00a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -117,6 +117,9 @@ private string ResolvePwdError(string key) } #region Reporting + private static string ChecklistReportName(ReportTypes type) => type == ReportTypes.ChecklistCompliance ? Resgrid.Services.ChecklistReportDocuments.Text("ChecklistComplianceReport") : type == ReportTypes.ChecklistMissed ? Resgrid.Services.ChecklistReportDocuments.Text("ChecklistMissedReport") : type.ToString(); + private static Microsoft.AspNetCore.Mvc.Rendering.SelectList ChecklistReportTypes(ReportTypes selected) => new Microsoft.AspNetCore.Mvc.Rendering.SelectList(Enum.GetValues().Select(t => new { Value = (int)t, Text = ChecklistReportName(t) }), "Value", "Text", (int)selected); + [HttpGet] [Authorize(Policy = ResgridResources.Profile_View)] public async Task Reporting() @@ -179,7 +182,7 @@ public async Task GetScheduledReportingForGrid() if (task.ScheduleType == (int)ScheduleTypes.Weekly) st.DaysOfWeek = days.ToString(); - st.Data = ((ReportTypes)int.Parse(task.Data)).ToString(); + st.Data = ChecklistReportName((ReportTypes)int.Parse(task.Data)); scheduleJson.Add(st); } @@ -192,7 +195,7 @@ public async Task GetScheduledReportingForGrid() public async Task AddNewScheduledReport() { var model = new NewScheduledReportView(); - model.ReportTypes = model.ReportType.ToSelectList(); + model.ReportTypes = ChecklistReportTypes(model.ReportType); return View(model); } @@ -202,7 +205,7 @@ public async Task AddNewScheduledReport() [ValidateAntiForgeryToken] public async Task AddNewScheduledReport(NewScheduledReportView model, CancellationToken cancellationToken) { - model.ReportTypes = model.ReportType.ToSelectList(); + model.ReportTypes = ChecklistReportTypes(model.ReportType); if (!model.SpecificDatetime) { @@ -286,7 +289,7 @@ public async Task AddNewScheduledReport(NewScheduledReportView m public async Task EditScheduledReport(int scheduleId) { var model = new EditScheduledReportView(); - model.ReportTypes = model.ReportType.ToSelectList(); + model.ReportTypes = ChecklistReportTypes(model.ReportType); var schedule= await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); if (schedule.ScheduleType == (int)ScheduleTypes.SpecifcDateTime) @@ -316,7 +319,7 @@ public async Task EditScheduledReport(int scheduleId) [Authorize(Policy = ResgridResources.Profile_Update)] public async Task EditScheduledReport(EditScheduledReportView model, CancellationToken cancellationToken) { - model.ReportTypes = model.ReportType.ToSelectList(); + model.ReportTypes = ChecklistReportTypes(model.ReportType); if (!model.SpecificDatetime) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReadinessProBillingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReadinessProBillingController.cs new file mode 100644 index 000000000..b3eef2e7c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReadinessProBillingController.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Areas.User.Controllers +{ + [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Resgrid.Web.Filters.AllowDuringDepartmentLock] + public sealed class ReadinessProBillingController : SecureBaseController + { + private readonly IReadinessProBillingService _billing; + private readonly IDepartmentsService _departments; + private readonly IStringLocalizer _strings; + public ReadinessProBillingController(IReadinessProBillingService billing, IDepartmentsService departments, + IStringLocalizer strings) + { _billing = billing; _departments = departments; _strings = strings; } + private async Task OwnerAsync() + { + var member = await _departments.GetDepartmentMemberAsync(UserId, DepartmentId, true); + var department = await _departments.GetDepartmentByIdAsync(DepartmentId, true); + return member?.DepartmentId == DepartmentId && !member.IsDeleted && member.IsDisabled != true && department?.ManagingUserId == UserId; + } + [HttpGet] + public async Task Index() + { + if (!await OwnerAsync()) return Forbid(); + return View(await _billing.GetAsync(DepartmentId)); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task Checkout() + { + if (!await OwnerAsync()) return Forbid(); + var checkout = await _billing.BeginCheckoutAsync(DepartmentId); + if (checkout?.Provider == "Stripe" && Uri.TryCreate(checkout.Url, UriKind.Absolute, out var url) && + url.Scheme == "https" && url.Host == "checkout.stripe.com" && string.IsNullOrEmpty(url.UserInfo)) + return Json(new { url = checkout.Url }); + if (checkout?.Provider == "Paddle" && System.Text.RegularExpressions.Regex.IsMatch(checkout.TransactionId ?? "", "^txn_[a-z0-9]{26}$")) + return Json(new { transactionId = checkout.TransactionId }); + return StatusCode(503, new { message = _strings["BillingUnavailable"].Value }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task CancelRenewal() + { + if (!await OwnerAsync()) return Forbid(); + if (!await _billing.CancelRenewalAsync(DepartmentId)) return StatusCode(503, new { message = _strings["BillingUnavailable"].Value }); + return RedirectToAction(nameof(Index)); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index 8283e1b00..70ee4b585 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -32,7 +32,7 @@ namespace Resgrid.Web.Areas.User.Controllers { [Area("User")] [ClaimsResource(ResgridClaimTypes.Resources.Reports)] - public class ReportsController : SecureBaseController + public partial class ReportsController : SecureBaseController { #region Private Members and Constructors diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index afd3e1867..356c947f1 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs @@ -482,7 +482,7 @@ SelectList AdpOptions(bool includeEveryone) // Rows come from RecordPermissionCatalog so this screen, ClaimsLogic.AddRecordClaims and the // activation-time row migration share one set of no-row defaults. A missing row preselects that // default, which for the Logs-parity types equals today's CreateLog/DeleteLog fall-through. - model.RecordsPermissions = RecordsPermissionRows.Build(permissions).Concat(RecordsPermissionRows.Build(permissions, ChecklistPermissionCatalog.All)).ToList(); + model.RecordsPermissions = RecordsPermissionRows.Build(permissions).Concat(RecordsPermissionRows.Build(permissions, ChecklistPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, WorkOrderPermissionCatalog.All)).ToList(); var recordsState = await _recordsCutoverService.GetModuleStateAsync(DepartmentId); model.RecordsFlagEnabled = recordsState != null && recordsState.FlagEnabled; model.RecordsActivated = recordsState != null && recordsState.RecordsUsable; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderFormCultureAttribute.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderFormCultureAttribute.cs new file mode 100644 index 000000000..f296e8e6c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderFormCultureAttribute.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Resgrid.Web.Areas.User.Controllers +{ + // HTML number/date controls submit invariant values regardless of the display language. + [AttributeUsage(AttributeTargets.Class)] + public sealed class WorkOrderFormCultureAttribute : Attribute, IResourceFilter + { + public void OnResourceExecuting(ResourceExecutingContext context) => context.ValueProviderFactories.Insert(0,new InvariantFormFactory()); + public void OnResourceExecuted(ResourceExecutedContext context) { } + private sealed class InvariantFormFactory : IValueProviderFactory + { + public async Task CreateValueProviderAsync(ValueProviderFactoryContext context) + { + var request=context.ActionContext.HttpContext.Request; + if(request.HasFormContentType) context.ValueProviders.Add(new FormValueProvider(BindingSource.Form,await request.ReadFormAsync(),CultureInfo.InvariantCulture)); + } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs new file mode 100644 index 000000000..96a817013 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Web.Areas.User.Models.WorkOrders; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + [WorkOrderFormCulture] + [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] + public sealed class WorkOrdersController : SecureBaseController + { + private readonly IWorkOrdersService _orders; + private readonly IWorkOrderAuthorizationService _authorization; + private readonly IReadinessAccessService _access; + private readonly IProtectedGrantContext _grant; + private readonly IDepartmentDataProtectionService _protection; + private readonly IStringLocalizer _strings; + public WorkOrdersController(IWorkOrdersService orders, IWorkOrderAuthorizationService authorization, IReadinessAccessService access, IProtectedGrantContext grant, + IDepartmentDataProtectionService protection, IStringLocalizer strings) + { _orders = orders; _authorization = authorization; _access = access; _grant = grant; _protection = protection; _strings = strings; } + private ChecklistActor Actor => new ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = _grant.GrantToken }; + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + try { await _authorization.RequireMemberAsync(Actor); } catch (WorkOrderException membershipError) { context.Result = StatusCode(membershipError.StatusCode, new { message = _strings[membershipError.Code].Value }); return; } + ViewBag.ProtectionEnforced = await _protection.IsProtectionEnforcedAsync(DepartmentId); ViewBag.ProtectedGrant = _grant.GrantToken; ViewBag.GrantExpiresOn = HttpProtectedGrantContext.ReadExpiry(Request); + if (!ModelState.IsValid) { context.Result = StatusCode(400, new { message = _strings["InvalidInput"].Value }); return; } + var executed = await next(); + if (executed.Exception is WorkOrderException ex) + { + executed.ExceptionHandled = true; + if (HttpMethods.IsGet(Request.Method) && ex.Code == "ProtectedDataRequired") + { + int.TryParse(Request.Query["id"].ToString() is { Length: > 0 } id ? id : context.RouteData.Values["id"]?.ToString(), out var orderId); + executed.Result = View("Locked", new WorkOrderLockedView { Page = context.RouteData.Values["action"]?.ToString(), Id = orderId, Filter = context.ActionArguments.TryGetValue("filter", out var filters) && filters is WorkOrderFilter workOrderFilter ? workOrderFilter : new WorkOrderFilter() }); + } + else executed.Result = StatusCode(ex.StatusCode, new { message = _strings[ex.Code].Value, code = ex.Code }); + } + } + [HttpGet] + public async Task Index(WorkOrderFilter filter) => View("Index", new WorkOrderIndexView { Orders = await _orders.ListAsync(Actor, filter), Filter = filter, Choices = await _orders.ChoicesAsync(Actor) }); + [HttpGet] + public async Task New() + { + if (!await _access.CanUseMaintenanceAsync(DepartmentId)) throw new WorkOrderException(402, "ReadinessProRequired"); + return View("Edit", new WorkOrderEditView { Input = new WorkOrderInput(), Choices = await _orders.ChoicesAsync(Actor), CanManage = await _authorization.CanManageAsync(Actor, null) }); + } + [HttpGet] + public async Task Detail(int id) => View("Detail", new WorkOrderDetailView { Detail = await _orders.GetAsync(Actor, id), Choices = await _orders.ChoicesAsync(Actor) }); + [HttpGet] + public async Task Edit(int id) + { + var detail = await _orders.GetAsync(Actor, id); if (!detail.CanEdit) throw new WorkOrderException(403, "PermissionRequired"); + return View("Edit", new WorkOrderEditView { Id = id, Input = detail.Input, Choices = await _orders.ChoicesAsync(Actor), CanManage = detail.CanManage }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task Save(int id, WorkOrderInput input) + { + if (id == 0) id = (await _orders.CreateAsync(Actor, input)).Order.Id; else await _orders.UpdateAsync(Actor, id, input); return Json(new { id }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task Transition(int id, WorkOrderTransition input) { await _orders.TransitionAsync(Actor, id, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task Assign(int id, WorkOrderAssignment input) { await _orders.AssignAsync(Actor, id, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task Accept(int id, int revision) { await _orders.AcceptAssignmentAsync(Actor, id, revision); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task Comment(int id, int revision, string note) { await _orders.CommentAsync(Actor, id, revision, note); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task Labor(int id, WorkOrderLaborInput input) { await _orders.AddLaborAsync(Actor, id, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task Part(int id, WorkOrderPartInput input) { await _orders.AddPartAsync(Actor, id, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task VoidPart(int id, int partId, int revision, string reason) { await _orders.VoidPartAsync(Actor, id, partId, revision, reason); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken, RequestSizeLimit(11 * 1024 * 1024)] + public async Task Upload(int id, int revision, IFormFile file) + { + if (file == null || file.Length > 10 * 1024 * 1024) throw new WorkOrderException(400, "InvalidFile"); + using var buffer = new MemoryStream(); await file.CopyToAsync(buffer); await _orders.AddFileAsync(Actor, id, revision, file.FileName, file.ContentType, buffer.ToArray()); return Json(new { id }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task WithdrawFile(int id, int fileId, int revision, string reason) { await _orders.WithdrawFileAsync(Actor, id, fileId, revision, reason); return Json(new { id }); } + [HttpGet] + public async Task Evidence(int id) { var file = await _orders.GetFileAsync(Actor, id); Response.Headers["X-Content-Type-Options"] = "nosniff"; return File(file.Data, file.ContentType, file.Content); } + [HttpPost, ValidateAntiForgeryToken] + public async Task Export(int id) { var detail = await _orders.GetAsync(Actor, id); return File(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(detail, Formatting.Indented)), "application/json", "work-order-" + id + ".json"); } + [HttpPost, ValidateAntiForgeryToken] + public Task Reopen(string destination, int id, WorkOrderFilter filter) => destination switch { "Detail" => Detail(id), "Edit" => Edit(id), "New" => New(), "Index" => Index(filter ?? new WorkOrderFilter()), _ => Task.FromResult(BadRequest()) }; + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistScheduleViews.cs b/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistScheduleViews.cs index ae501ec91..730650f1a 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistScheduleViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistScheduleViews.cs @@ -12,5 +12,5 @@ public class ChecklistScheduleEditView public List Workshifts { get; set; } = new(); } public class ChecklistWorkshiftChoice { public string Id { get; set; } public string Name { get; set; } } - public class ChecklistDueView { public List Occurrences { get; set; } public int Page { get; set; } } + public class ChecklistDueView { public List Occurrences { get; set; } public int Page { get; set; } public bool HasMore { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs new file mode 100644 index 000000000..4d9d4c12a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs @@ -0,0 +1,9 @@ +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Web.Areas.User.Models.WorkOrders +{ + public sealed class WorkOrderIndexView { public WorkOrderPage Orders { get; set; } public WorkOrderFilter Filter { get; set; } public WorkOrderChoices Choices { get; set; } } + public sealed class WorkOrderEditView { public int Id { get; set; } public WorkOrderInput Input { get; set; } public WorkOrderChoices Choices { get; set; } public bool CanManage { get; set; } } + public sealed class WorkOrderDetailView { public WorkOrderDetail Detail { get; set; } public WorkOrderChoices Choices { get; set; } } + public sealed class WorkOrderLockedView { public WorkOrderFilter Filter { get; set; } = new WorkOrderFilter(); public string Page { get; set; } public int Id { get; set; } } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/Index.cshtml index 66b58e95a..2adeb58d6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/Index.cshtml @@ -205,6 +205,7 @@ @section Scripts { + @await Html.PartialAsync("~/Areas/User/Views/Checklists/_LiveScripts.cshtml") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Compliance.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Compliance.cshtml new file mode 100644 index 000000000..2beac856e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Compliance.cshtml @@ -0,0 +1,36 @@ +@model Resgrid.Model.Checklists.ChecklistComplianceSummary +@using Resgrid.Model.Checklists +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["ChecklistComplianceReport"]; } + +@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml index d9d899d68..77df74003 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml @@ -17,6 +17,7 @@ } @if (Model.Page > 0) { @localizer["Previous"] } - @localizer["Next"]@localizer["Back"] + @if (Model.HasMore) { @localizer["Next"] }@localizer["Back"] -@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } + +@section Scripts { @await Html.PartialAsync("_ProtectionScripts") @await Html.PartialAsync("_LiveScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml index cf43f701a..a0b797435 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml @@ -5,6 +5,7 @@
@await Html.PartialAsync("_Protection")

@localizer["Templates"]@localizer["DueChecks"] + @localizer["ChecklistComplianceReport"] @if (Model.CanManage) { @localizer["ReminderSettings"] @localizer["NewChecklist"] }

@if (Model.Definitions.Count == 0) {

@localizer["NoDefinitions"]

} diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/ReadinessPacket.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/ReadinessPacket.cshtml new file mode 100644 index 000000000..24ed9407c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/ReadinessPacket.cshtml @@ -0,0 +1,20 @@ +@model Resgrid.Model.Checklists.ReadinessEvidenceManifestV1 +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["ReadinessPacketReport"]; } +
+

@localizer["ReadinessPacketReport"]

@localizer["PacketMethod"]

+@await Html.PartialAsync("_Protection") +
+ @Html.AntiForgeryToken() + + + + + +@if (Model != null) +{ + +} +@localizer["Back"] +
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/_LiveScripts.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/_LiveScripts.cshtml new file mode 100644 index 000000000..597ab4214 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/_LiveScripts.cshtml @@ -0,0 +1,2 @@ + + diff --git a/Web/Resgrid.Web/Areas/User/Views/ReadinessProBilling/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/ReadinessProBilling/Index.cshtml new file mode 100644 index 000000000..8596adfe7 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/ReadinessProBilling/Index.cshtml @@ -0,0 +1,32 @@ +@model Resgrid.Model.ReadinessProBillingStatus +@inject IStringLocalizer localizer +@using Resgrid.Config +@using Newtonsoft.Json +@{ ViewBag.Title = "Resgrid | Readiness Pro"; } +

Readiness Pro

+
+@if (Model == null) {

@localizer["BillingUnavailable"]

} +else { +

@Model.MonthlyAmount.ToString("N2") @Model.Currency / @localizer["Monthly"]

+

@localizer["CheckoutPending"]

+ @if (Model.PaidThroughUtc.HasValue) {

@localizer["PaidThrough"]: @Model.PaidThroughUtc.Value.ToString("u")

} + @if (Model.Cancelled) {

@localizer["SubscriptionCancelled"]

} + @if (Model.CheckoutAvailable) { +
@Html.AntiForgeryToken() + } + @if (Model.CanCancel && !Model.Cancelled) { +
@Html.AntiForgeryToken() + } +} + +

@localizer["ManageSubscription"]

+

@localizer["WorkOrders"]

+
+@section Scripts { +@if (Model?.Provider == "Paddle") { } + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/ChecklistComplianceReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/ChecklistComplianceReport.cshtml new file mode 100644 index 000000000..5f9168ce6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/ChecklistComplianceReport.cshtml @@ -0,0 +1,9 @@ +@model Resgrid.Model.Checklists.ChecklistComplianceSummary +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["ChecklistComplianceReport"]; } +
+@await Html.PartialAsync("~/Areas/User/Views/Checklists/_Protection.cshtml") + +@Html.Raw(Resgrid.Services.ChecklistReportDocuments.ComplianceBody(Model, ViewBag.MissedOnly == true)) +
+@section Scripts { @await Html.PartialAsync("~/Areas/User/Views/Checklists/_ProtectionScripts.cshtml") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml index 0308c5b9e..cf2352652 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml @@ -2,6 +2,7 @@ @using Resgrid.Model @using Resgrid.Web.Helpers @using Microsoft.Extensions.Localization +@inject IStringLocalizer checklistLocalizer @model Resgrid.Web.Areas.User.Models.Logs.LogsIndexView @inject IStringLocalizer flaggedReportLocalizer @inject IStringLocalizer localizer @@ -46,6 +47,7 @@
+ } - - + +
@localizer["Name"]@localizer["Version"]@localizer["State"]
@checklistLocalizer["ChecklistComplianceReport"]@checklistLocalizer["AuthorizedScope"]@checklistLocalizer["ReadinessPacketReport"]
@localizer["PersonnelStatusHistoryReportName"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml index a1fa2ddd8..9b53c167c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml @@ -2,6 +2,7 @@ @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.Security.PermissionsView @inject IStringLocalizer localizer +@inject IStringLocalizer workOrderLocalizer @inject IStringLocalizer checklistLocalizer @inject IStringLocalizer twoFactorLocalizer @{ @@ -458,8 +459,8 @@ { if (row.Type == PermissionTypes.ManageChecklists) {
@checklistLocalizer["Checklists"]
@(row.Type == PermissionTypes.ManageChecklists ? checklistLocalizer["Manage checklists"].Value : row.Type == PermissionTypes.ViewChecklistResults ? checklistLocalizer["View checklist results"].Value : localizer[row.LabelKey].Value)@(row.Type == PermissionTypes.ManageChecklists ? checklistLocalizer["Create, edit, publish and retire free checklists."].Value : row.Type == PermissionTypes.ViewChecklistResults ? checklistLocalizer["View results from other members. Members retain their own history."].Value : localizer[row.NoteKey].Value)@(row.Type == PermissionTypes.ManageWorkOrders ? workOrderLocalizer["ManageWorkOrders"].Value : row.Type == PermissionTypes.ViewAllWorkOrders ? workOrderLocalizer["ViewAllWorkOrders"].Value : row.Type == PermissionTypes.ManageChecklists ? checklistLocalizer["Manage checklists"].Value : row.Type == PermissionTypes.ViewChecklistResults ? checklistLocalizer["View checklist results"].Value : localizer[row.LabelKey].Value)@(row.Type == PermissionTypes.ManageWorkOrders ? workOrderLocalizer["ManageWorkOrdersNote"].Value : row.Type == PermissionTypes.ViewAllWorkOrders ? workOrderLocalizer["ViewAllWorkOrdersNote"].Value : row.Type == PermissionTypes.ManageChecklists ? checklistLocalizer["Create, edit, publish and retire free checklists."].Value : row.Type == PermissionTypes.ViewChecklistResults ? checklistLocalizer["View results from other members. Members retain their own history."].Value : localizer[row.NoteKey].Value) @Html.DropDownList(row.ElementId, row.Options, new { id = row.ElementId }) @if (row.ShowLockToGroup) diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml index d4044488b..487f8ded6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -1,5 +1,6 @@ @inject Resgrid.Model.Services.IFeatureToggleService featureToggleService @inject Resgrid.Model.Services.IReadinessAccessService readinessAccess +@inject IStringLocalizer workOrderLocalizer @inject IStringLocalizer checklistLocalizer @inject Resgrid.Model.Services.IRecordsAuthorizationService recordsAuthorization @inject IStringLocalizer recordsLocalizer @@ -161,6 +162,10 @@ @commonLocalizer["TrainingsModule"] } + @if (await readinessAccess.CanUseMaintenanceAsync(ClaimsAuthorizationHelper.GetDepartmentId())) + { +
  • @workOrderLocalizer["WorkOrders"]
  • + } @if (await readinessAccess.CanUseChecklistsAsync(ClaimsAuthorizationHelper.GetDepartmentId())) {
  • diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml index 8febf6b6e..8746877c2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml @@ -537,6 +537,7 @@

    Advanced Data Protection encrypts your department's sensitive fields with a key held only for your department, and hides them behind a second-factor check. It is available on paid plans, billed yearly, and only your department's managing member can buy or cancel it. Buying the addon does not encrypt anything on its own — you enroll afterwards, from the Data Protection settings page, at a time you choose.

    Manage Data Protection + Readiness Pro
    diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml new file mode 100644 index 000000000..77c8b4717 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml @@ -0,0 +1,67 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderDetailView +@using Resgrid.Model.WorkOrders +@inject IStringLocalizer localizer +@{ var d = Model.Detail; var o = d.Order; var c = d.Input.Content; ViewBag.Title = "Resgrid | " + o.Number; } +
    +@await Html.PartialAsync("_Protection") +
    +

    @o.Number — @o.Title

    @localizer["Status" + o.Status] · @localizer["Priority" + o.Priority] · @localizer["DueOn"]: @o.DueOn?.ToString("u")

    +@if (!d.CanWrite) {

    @localizer["ReadOnlyHistory"]

    } +

    @c.Description

    @c.LocationText

    +

    @localizer["ReportedBy"]: @d.ReportedBy · @localizer["AssignedTo"]: @(Model.Choices.Users.FirstOrDefault(x => x.Id == o.AssignedToUserId)?.Name ?? Model.Choices.Roles.FirstOrDefault(x => x.Id == o.AssignedToRoleId?.ToString())?.Name ?? localizer["None"].Value)

    +

    @localizer["Currency"]: @c.Currency · @localizer["EstimatedCost"]: @c.EstimatedCost · @localizer["ApprovedCost"]: @c.ApprovedCost · @localizer["CostCenter"]: @c.CostCenter

    +

    @localizer["VendorDetails"]: @c.VendorDetails

    @localizer["WarrantyReference"]: @c.WarrantyReference

    +@if (c.SafetyCritical || c.HazardousWork) {

    @localizer[c.HazardousWork ? "HazardousWork" : "SafetyCritical"] — @localizer["SafetyNotice"]

    } +
    @localizer["ProcedureReference"] / @localizer["ProcedureVersion"]
    @c.ProcedureReference / @c.ProcedureVersion
    @localizer["PermitReference"]
    @c.PermitReference
    @localizer["IsolationReference"]
    @c.IsolationReference
    @localizer["QualifiedPersonnel"]
    @c.QualifiedPersonnel
    +@if (c.Steps.Count > 0) {

    @localizer["TaskSteps"]

      @foreach (var step in c.Steps) {
    1. @step.Text — @localizer[step.Completed ? "Done" : "Pending"]
    2. }
    } +@if (d.CompletedOn.HasValue) {

    @localizer["Resolution"]

    @c.Resolution

    @localizer["Cause"]: @c.Cause

    } +@if (d.ClosedOn.HasValue) {

    @localizer["VerificationEvidence"]: @c.VerificationEvidence

    @localizer["VerifiedBy"]: @d.VerifiedBy · @d.ClosedOn?.ToString("u")

    } +@if (d.CanEdit) { @localizer["EditWorkOrder"] } +
    @Html.AntiForgeryToken()
    +@if (d.CanAccept) {
    @Html.AntiForgeryToken()
    } +@if (d.CanManage && o.Status is WorkOrderStatus.Accepted or WorkOrderStatus.Assigned or WorkOrderStatus.InProgress or WorkOrderStatus.OnHold) +{ +

    @localizer["Assign"]

    + @Html.AntiForgeryToken() + + +
    +} +@if (d.Transitions.Length > 0) +{ +

    @localizer["ChangeStatus"]

    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    +
    +} +

    @localizer["Activity"]

      @foreach (var a in d.Activities) {
    1. @localizer["Activity" + a.Type] · @a.CreatedOn.ToString("u") · @a.UserId @if (a.NewStatus.HasValue) { → @localizer["Status" + (WorkOrderStatus)a.NewStatus.Value] }

      @a.Note

      +@if (a.AssignedToUserId != null || a.AssignedToRoleId.HasValue) {

      @localizer["AssignedTo"]: @(a.AssignedToUserId ?? a.AssignedToRoleId.ToString())

      } +@if (a.Snapshot != null) {
      @localizer["Description"]: @a.Snapshot.Title +

      @a.Snapshot.Description

      @localizer["Resolution"]: @a.Snapshot.Resolution

      @localizer["Cause"]: @a.Snapshot.Cause

      +

      @localizer["VerificationEvidence"]: @a.Snapshot.VerificationEvidence

      @localizer["ProcedureReference"]: @a.Snapshot.ProcedureReference (@a.Snapshot.ProcedureVersion)

      +
      }
    2. }
    +@if (d.CanWrite && o.Status is not (WorkOrderStatus.Closed or WorkOrderStatus.Cancelled or WorkOrderStatus.Rejected or WorkOrderStatus.Duplicate) && (d.CanContribute || d.ReportedBy == User.Identity.Name || d.ReportedBy == Resgrid.Web.Helpers.ClaimsAuthorizationHelper.GetUserId())) +{ +
    @Html.AntiForgeryToken()
    +} +

    @localizer["Labor"]

    @foreach (var l in d.Labor) { }
    @localizer["User"]@localizer["WorkDate"]@localizer["Hours"]@localizer["Rate"]@localizer["Note"]
    @l.UserId@l.WorkDate.ToString("d")@l.Content.Hours@l.Content.RatePerHour @c.Currency@l.Content.Note
    +@if (d.CanContribute) +{ +
    @Html.AntiForgeryToken() + @if (d.CanManage) { } + +
    +} +

    @localizer["Parts"]

    @localizer["FreeTextPartsNotice"]

    +@foreach (var p in d.Parts) { } +
    @localizer["Description"]@localizer["Quantity"]@localizer["UnitCost"]@localizer["Actions"]
    @p.Content.Description@p.Content.Quantity@p.Content.UnitCost @c.Currency@if (p.VoidedOn.HasValue) { @localizer["Voided"]: @p.Content.VoidReason } else if (d.CanContribute) {
    @Html.AntiForgeryToken()
    }
    +@if (d.CanContribute) {
    @Html.AntiForgeryToken()
    } +

    @localizer["Files"]

      @foreach (var f in d.Files) {
    • @f.Name (@f.Size) @if (f.WithdrawnOn.HasValue) { @localizer["Withdrawn"] } else if (d.CanContribute) {
      @Html.AntiForgeryToken()
      }
    • }
    +@if (d.CanContribute || d.CanWrite && o.Status == WorkOrderStatus.Requested && d.ReportedBy == Resgrid.Web.Helpers.ClaimsAuthorizationHelper.GetUserId()) {
    @Html.AntiForgeryToken()

    @localizer["FileHelp"]

    } +
    +@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml new file mode 100644 index 000000000..6c267c71c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml @@ -0,0 +1,39 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderEditView +@using Resgrid.Model.WorkOrders +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer[Model.Id == 0 ? "NewWorkOrder" : "EditWorkOrder"]; } +
    +

    @localizer[Model.Id == 0 ? "NewWorkOrder" : "EditWorkOrder"]

    +@await Html.PartialAsync("_Protection") +
    +@Html.AntiForgeryToken() +
    +
    +
    +
    +
    +
    +
    +@if (Model.Choices.Assets.Count > 0) {
    } +else { }
    +
    +
    +
    +@if (Model.CanManage) {
    }
    +
    +
    +
    + + +

    @localizer["SafetyNotice"]

    +
    +
    +
    +
    +
    +

    @localizer["TaskSteps"]

    +@for (var i = 0; i < Model.Input.Content.Steps.Count; i++) {
    } +
    +@localizer["Cancel"] +
    +@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml new file mode 100644 index 000000000..027fdc379 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml @@ -0,0 +1,31 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderIndexView +@using Resgrid.Model.WorkOrders +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["WorkOrders"]; } +

    @localizer["WorkOrders"]

    Readiness Pro

    +
    +@await Html.PartialAsync("_Protection") +@if (Model.Orders.CanWrite) { @localizer["NewWorkOrder"] } +else {

    @localizer["ReadOnlyHistory"] Readiness Pro

    } +
    + @Html.AntiForgeryToken() + + + + + @if (Model.Choices.Assets.Count > 0) { } + + +
    +
    +@foreach (var item in Model.Orders.Items) { } +
    @localizer["Number"]@localizer["Title"]@localizer["Status"]@localizer["Priority"]@localizer["DueOn"]@localizer["Actions"]
    @item.Number@item.Title@localizer["Status" + item.Status]@localizer["Priority" + item.Priority]@item.DueOn?.ToString("u")@if (item.QuickStatus.HasValue) { +
    @Html.AntiForgeryToken() + +
    +}
    +@if (Model.Orders.Items.Count == 0) {

    @localizer["NoWorkOrders"]

    } +@if (Model.Filter.Page > 0) { @localizer["Previous"] } +@if (Model.Orders.HasMore) { @localizer["Next"] } +
    +@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml new file mode 100644 index 000000000..b7c95290d --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml @@ -0,0 +1,13 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderLockedView +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["WorkOrders"]; ViewBag.ProtectionEnforced = true; } +
    +

    @localizer["WorkOrders"]

    + @await Html.PartialAsync("_Protection") +
    + @Html.AntiForgeryToken() + @await Html.PartialAsync("_FilterFields", Model.Filter) + +
    +
    +@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_FilterFields.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_FilterFields.cshtml new file mode 100644 index 000000000..70bbe1d19 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_FilterFields.cshtml @@ -0,0 +1,8 @@ +@model Resgrid.Model.WorkOrders.WorkOrderFilter + + + + + + + diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Protection.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Protection.cshtml new file mode 100644 index 000000000..ee1f71165 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Protection.cshtml @@ -0,0 +1,7 @@ +@using Resgrid.Web.Areas.User.Models +@inject IStringLocalizer localizer +@if (ViewBag.ProtectionEnforced == true) +{ + @await Html.PartialAsync("_AdpRevealBanner", new AdpRevealView { BannerTitle = localizer["ProtectedDataRequired"].Value }) +} + diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Scripts.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Scripts.cshtml new file mode 100644 index 000000000..e415bcbd0 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Scripts.cshtml @@ -0,0 +1,9 @@ +@using Resgrid.Web.Areas.User.Models +@using Newtonsoft.Json +@inject IStringLocalizer localizer + + +@if (ViewBag.ProtectionEnforced == true) +{ + @await Html.PartialAsync("_AdpRevealScripts", new AdpRevealView { BannerTitle = localizer["ProtectedDataRequired"].Value, GrantExpiresOnUtc = (DateTime?)ViewBag.GrantExpiresOn, BindForms = new System.Collections.Generic.List { "form.work-order-form" } }) +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml index 8ac7ac4da..143059ee3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml @@ -1,3 +1,4 @@ +@inject IStringLocalizer workOrderStrings @inject IStringLocalizer checklistStrings @model Resgrid.Model.Workflow @inject IStringLocalizer localizer @@ -5,7 +6,7 @@ ViewData["Title"] = "Resgrid | " + localizer["EditWorkflowHeader"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; var credentialsJson = (string)(ViewBag.CredentialsJson ?? "[]"); - var triggerEventTypeName = Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)Model.TriggerEventType) ? checklistStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : (string)(ViewBag.TriggerEventTypeName ?? Model.TriggerEventType.ToString()); + var triggerEventTypeName = Model.TriggerEventType is 70 or 71 or 72 ? workOrderStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)Model.TriggerEventType) ? checklistStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : (string)(ViewBag.TriggerEventTypeName ?? Model.TriggerEventType.ToString()); } @section Styles { diff --git a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml index dc84286f5..1afc05be2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml @@ -1,3 +1,4 @@ +@inject IStringLocalizer workOrderStrings @inject IStringLocalizer checklistStrings @model Resgrid.Model.Workflow @inject IStringLocalizer localizer @@ -10,7 +11,7 @@ .Cast() .Where(e => !usedEventTypes.Contains((int)e)) .Where(e => recordsTriggersAvailable || !Resgrid.Model.WorkflowTriggerEventTypes.IsRecordsTrigger(e)) - .Select(e => new SelectListItem { Value = ((int)e).ToString(), Text = Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)e) ? checklistStrings[e.ToString()].Value : e.ToString() }) + .Select(e => new SelectListItem { Value = ((int)e).ToString(), Text = (int)e is 70 or 71 or 72 ? workOrderStrings[e.ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)e) ? checklistStrings[e.ToString()].Value : e.ToString() }) .ToList(); var noEventTypesAvailable = !eventTypes.Any(); } diff --git a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs index a0e6ebbd6..2788025db 100644 --- a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs @@ -313,6 +313,8 @@ public static bool CanDeleteContacts() return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Contacts, ResgridClaimTypes.Actions.Delete); } + public static bool CanManageWorkOrders() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.Update); + public static bool CanViewAllWorkOrders() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.View); public static bool CanManageChecklists() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update); public static bool CanViewChecklistResults() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View); diff --git a/Web/Resgrid.Web/Startup.cs b/Web/Resgrid.Web/Startup.cs index a4ae3b435..59e711600 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -254,6 +254,8 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.Record_Reassign, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Reassign)); options.AddPolicy(ResgridResources.RecordLegacy_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordLegacy, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.RecordRestricted_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordRestricted, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.WorkOrder_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.WorkOrder_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WorkOrder, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.Checklist_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.ChecklistResults_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.RecordDefinition_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordDefinition, ResgridClaimTypes.Actions.Update)); diff --git a/Web/Resgrid.Web/wwwroot/js/app/common/signalr/resgrid.common.signalr.js b/Web/Resgrid.Web/wwwroot/js/app/common/signalr/resgrid.common.signalr.js index 07067d541..150bd7a02 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/common/signalr/resgrid.common.signalr.js +++ b/Web/Resgrid.Web/wwwroot/js/app/common/signalr/resgrid.common.signalr.js @@ -1,4 +1,4 @@ -var resgrid; +var resgrid; (function (resgrid) { var common; (function (common) { @@ -39,6 +39,7 @@ eventHub.keepAliveIntervalInMilliseconds = 1000; eventHub.onreconnected(function () { + document.dispatchEvent(new Event("resgrid:checklists-updated")); if (departmentId && departmentId > 0) { eventHub.invoke("Connect", Number(departmentId)).catch(function (err) { return console.error(err.toString()); @@ -141,6 +142,7 @@ } } function registerClientMethods() { + eventHub.on("checklistUpdated", function () { document.dispatchEvent(new Event("resgrid:checklists-updated")); }); eventHub.on("onConnected", function (id) { //connectionId = id; }); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklist-live.js b/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklist-live.js new file mode 100644 index 000000000..f7dfaee67 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklist-live.js @@ -0,0 +1,22 @@ +(function () { + 'use strict'; + var timer = null, pending = false; + function refresh() { + timer = null; + if (!pending || document.hidden) return; + // A live hint must never discard an excuse being typed or submit another checklist form. + if (document.querySelector('textarea:focus, input:not([type="hidden"]):focus') || + Array.from(document.querySelectorAll('textarea')).some(function (x) { return x.value.length > 0; })) return; + pending = false; + var form = document.getElementById('checklist-live-refresh'); + if (form) form.requestSubmit(); + else if (document.getElementById('include-checklists')?.checked) resgrid.calendar.index.getCalendar()?.refetchEvents(); + } + function changed() { pending = true; clearTimeout(timer); timer = setTimeout(refresh, 500); } + document.addEventListener('resgrid:checklists-updated', changed); + document.addEventListener('visibilitychange', changed); + document.addEventListener('focusout', function () { if (pending) changed(); }); + document.addEventListener('input', function () { if (pending) changed(); }); + window.addEventListener('pagehide', function () { clearTimeout(timer); }); + $(function () { resgrid.common.signalr.init(function () {}, function () {}, function () {}, function () {}); }); +})(); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/workorders/readiness-billing.js b/Web/Resgrid.Web/wwwroot/js/app/internal/workorders/readiness-billing.js new file mode 100644 index 000000000..d97253099 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/workorders/readiness-billing.js @@ -0,0 +1,28 @@ +(function () { + 'use strict'; + var form = document.getElementById('readiness-checkout'); + if (!form) return; + var config = JSON.parse(document.getElementById('readiness-billing-config').textContent); + form.addEventListener('submit', async function (event) { + event.preventDefault(); + var button = form.querySelector('button'); + if (button.disabled) return; + button.disabled = true; + try { + var response = await fetch(form.action, { method: 'POST', body: new FormData(form), credentials: 'same-origin', cache: 'no-store' }); + if (!response.ok) throw new Error(); + var result = await response.json(); + if (config.provider === 'Stripe' && result.url) { + var url = new URL(result.url); + if (url.protocol !== 'https:' || url.hostname !== 'checkout.stripe.com' || url.username) throw new Error(); + window.location.assign(url.href); + } else if (config.provider === 'Paddle' && /^txn_[a-z0-9]{26}$/.test(result.transactionId) && window.Paddle && config.token) { + if (config.environment === 'sandbox') Paddle.Environment.set('sandbox'); + Paddle.Initialize({ token: config.token }); + Paddle.Checkout.open({ transactionId: result.transactionId }); + } else throw new Error(); + } catch (_) { + var error = document.getElementById('readiness-billing-error'); error.textContent = config.error; error.hidden = false; + } finally { button.disabled = false; } + }); +}()); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js b/Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js new file mode 100644 index 000000000..089c8218e --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js @@ -0,0 +1,66 @@ +(function () { + 'use strict'; + var configNode = document.getElementById('work-order-page'); + if (!configNode) return; + var config = JSON.parse(configNode.textContent); + function hidden(form, name, value) { + var input = form.querySelector('input[name="' + name + '"]'); + if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = name; form.appendChild(input); } + input.value = value || ''; + } + document.querySelectorAll('form.work-order-form').forEach(function (form) { + if (config.protectedData && config.grant) { + hidden(form, '__ResgridProtectedGrant', config.grant); + hidden(form, '__ResgridProtectedGrantExpiresOn', config.expiry); + } + }); + config.grant = null; configNode.textContent = ''; + window.resgridAdpPageConcealed = function () { + document.querySelectorAll('.work-order-protected').forEach(function (node) { node.replaceChildren(); }); + window.location.replace(config.index); + }; + function showError(message) { + var error = document.querySelector('.work-order-error'); + if (error) { error.textContent = message || config.error; error.hidden = false; error.scrollIntoView({ block: 'nearest' }); } + } + $(function () { + document.querySelectorAll('form.work-order-command').forEach(function (form) { + if (config.protectedData && window.resgridAdpReveal) window.resgridAdpReveal.bindForm(form); + form.addEventListener('submit', async function (event) { + if (event.defaultPrevented) return; + event.preventDefault(); + if (form.dataset.busy === 'true') return; + form.dataset.busy = 'true'; + var buttons = Array.from(form.querySelectorAll('button[type="submit"]')); + try { + buttons.forEach(function (b) { b.disabled = true; }); + var response = await fetch(form.action, { method: 'POST', body: new FormData(form), credentials: 'same-origin', cache: 'no-store' }); + var result = await response.json(); + if (!response.ok || !Number.isInteger(result.id)) { showError(result.message); if (result.code === 'ProtectedDataRequired') form.dispatchEvent(new Event('adp:grant-required')); return; } + var reopen = document.createElement('form'); reopen.method = 'POST'; reopen.action = config.reopen; + hidden(reopen, 'destination', 'Detail'); hidden(reopen, 'id', String(result.id)); + ['__RequestVerificationToken', '__ResgridProtectedGrant', '__ResgridProtectedGrantExpiresOn'].forEach(function (name) { + var field = form.querySelector('input[name="' + name + '"]'); if (field) hidden(reopen, name, field.value); + }); + document.body.appendChild(reopen); reopen.submit(); + } catch (_) { showError(config.error); } + finally { form.dataset.busy = 'false'; buttons.forEach(function (b) { b.disabled = false; }); } + }); + }); + }); + document.querySelectorAll('a.work-order-evidence').forEach(function (link) { + link.addEventListener('click', function (event) { + if (config.protectedData) { event.preventDefault(); if (window.resgridAdpReveal) window.resgridAdpReveal.download(link.href); } + }); + }); + document.querySelectorAll('.work-order-user, .work-order-role').forEach(function (select) { + select.addEventListener('change', function () { if (select.value) select.form.querySelector(select.classList.contains('work-order-user') ? '.work-order-role' : '.work-order-user').value = ''; }); + }); + var add = document.getElementById('work-order-add-step'), steps = document.getElementById('work-order-steps'); + if (add && steps) add.addEventListener('click', function () { + var index = steps.children.length; if (index >= 100) return; + var row = document.createElement('div'); row.className = 'form-group'; + var input = document.createElement('input'); input.name = 'Input.Content.Steps[' + index + '].Text'; input.className = 'form-control'; input.maxLength = 1000; input.required = true; + row.appendChild(input); steps.appendChild(row); input.focus(); + }); +}()); diff --git a/Workers/Resgrid.Workers.Framework/Logic/ChecklistReminderLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ChecklistReminderLogic.cs index bd22c9056..9720acf8a 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ChecklistReminderLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ChecklistReminderLogic.cs @@ -17,7 +17,7 @@ public async Task> Process(CancellationToken ct) return Tuple.Create(result.Errors == 0, $"Checklist reminders: handedOff={result.HandedOff}, suppressed={result.Suppressed}, errors={result.Errors}"); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } - catch { return Tuple.Create(false, "Checklist reminders failed."); } + catch (Exception ex) { Resgrid.Framework.Logging.LogError($"Checklist reminder worker failed: {ex.GetType().FullName}."); return Tuple.Create(false, "Checklist reminders failed."); } } } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/ChecklistSchedulingLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ChecklistSchedulingLogic.cs index b7d361a46..af58ce0c9 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ChecklistSchedulingLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ChecklistSchedulingLogic.cs @@ -17,7 +17,7 @@ public async Task> Process(CancellationToken ct) return Tuple.Create(result.Errors == 0, $"Checklist scheduling: generated={result.Generated}, missed={result.Missed}, errors={result.Errors}"); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } - catch { return Tuple.Create(false, "Checklist scheduling failed."); } + catch (Exception ex) { Resgrid.Framework.Logging.LogError($"Checklist scheduling worker failed: {ex.GetType().FullName}."); return Tuple.Create(false, "Checklist scheduling failed."); } } } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs index ce2856ad2..888909271 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs @@ -18,16 +18,33 @@ public class ReportDeliveryLogic private IScheduledTasksService _scheduledTasksService; private IEmailService _emailService; private IPdfProvider _pdfProvider; + private IChecklistScheduledReportService _checklistReports; public ReportDeliveryLogic() { _scheduledTasksService = Bootstrapper.GetKernel().Resolve(); _emailService = Bootstrapper.GetKernel().Resolve(); _pdfProvider = Bootstrapper.GetKernel().Resolve(); + _checklistReports = Bootstrapper.GetKernel().Resolve(); } + public ReportDeliveryLogic(IScheduledTasksService tasks, IEmailService email, IPdfProvider pdf, IChecklistScheduledReportService checklistReports) + { _scheduledTasksService = tasks; _emailService = email; _pdfProvider = pdf; _checklistReports = checklistReports; } public async Task> Process(ReportDeliveryQueueItem item) { + // Only a static PDF notice and authenticated link leave the system. No checklist data is emailed. + if (item?.ScheduledTask?.Data is "4" or "5") + { + try + { + if (item.Department?.DepartmentId != item.ScheduledTask.DepartmentId || !ConfigHelper.CanTransmit(item.ScheduledTask.DepartmentId)) return Tuple.Create(false, "Checklist report delivery is unavailable."); + var notification = await _checklistReports.BuildAsync(item.ScheduledTask); + await _emailService.SendReportDeliveryAsync(notification, item.ScheduledTask.DepartmentId, $"{SystemBehaviorConfig.ResgridBaseUrl}/User/Checklists/Compliance", notification.Subject); + await _scheduledTasksService.CreateScheduleTaskLogAsync(item.ScheduledTask); + return Tuple.Create(true, ""); + } + catch (Exception) { Logging.LogError("Checklist scheduled report delivery failed."); return Tuple.Create(false, "Checklist scheduled report delivery failed."); } + } bool success = true; string result = ""; @@ -45,7 +62,7 @@ public async Task> Process(ReportDeliveryQueueItem item) var response = await client.ExecuteAsync(request); - if (!string.IsNullOrWhiteSpace(response.Content)) + if (response.IsSuccessful && !string.IsNullOrWhiteSpace(response.Content)) { //var content = // response.Content.Replace( @@ -87,6 +104,7 @@ public async Task> Process(ReportDeliveryQueueItem item) await _emailService.SendReportDeliveryAsync(systemNotificaiton, item.Department.DepartmentId, reportUrl, ((ReportTypes)int.Parse(item.ScheduledTask.Data)).ToString()); } + else { success = false; result = "Report generation failed."; } } } catch (Exception ex)