From cd2f562bed20d13d6246433f0884a2d83c4ee233 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 8 Sep 2026 12:32:21 -0700 Subject: [PATCH] RG-T66 Checklists implementation --- Core/Resgrid.Config/PaymentProviderConfig.cs | 11 + Core/Resgrid.Config/ReadinessProConfig.cs | 9 + .../Areas/User/Checklists/Checklists.ar.resx | 537 ++++++++++++++++++ .../Areas/User/Checklists/Checklists.cs | 4 + .../Areas/User/Checklists/Checklists.de.resx | 537 ++++++++++++++++++ .../Areas/User/Checklists/Checklists.el.resx | 537 ++++++++++++++++++ .../Areas/User/Checklists/Checklists.en.resx | 537 ++++++++++++++++++ .../Areas/User/Checklists/Checklists.es.resx | 537 ++++++++++++++++++ .../Areas/User/Checklists/Checklists.fr.resx | 537 ++++++++++++++++++ .../Areas/User/Checklists/Checklists.it.resx | 537 ++++++++++++++++++ .../Areas/User/Checklists/Checklists.pl.resx | 537 ++++++++++++++++++ .../Areas/User/Checklists/Checklists.resx | 537 ++++++++++++++++++ .../Areas/User/Checklists/Checklists.sv.resx | 537 ++++++++++++++++++ .../Areas/User/Checklists/Checklists.uk.resx | 537 ++++++++++++++++++ Core/Resgrid.Model/AuditLogTypes.cs | 13 +- .../Checklists/ChecklistContracts.cs | 114 ++++ .../Checklists/ChecklistEntities.cs | 83 +++ .../Checklists/ChecklistEnums.cs | 27 + .../Checklists/ChecklistPermissionCatalog.cs | 13 + .../Checklists/ChecklistTemplate.cs | 78 +++ .../Checklists/ChecklistTemplateCatalog.cs | 175 ++++++ .../Checklists/ChecklistValidation.cs | 130 +++++ .../Resgrid.Model/DepartmentModuleSettings.cs | 3 + Core/Resgrid.Model/EventingTypes.cs | 5 +- Core/Resgrid.Model/FeatureFlagKeys.cs | 6 + Core/Resgrid.Model/PermissionTypes.cs | 7 +- Core/Resgrid.Model/PlanAddon.cs | 14 + Core/Resgrid.Model/PlanAddonTypes.cs | 3 +- .../Providers/IRabbitInboundEventProvider.cs | 3 +- .../Repositories/IChecklistRepository.cs | 18 + .../IChecklistAuthorizationService.cs | 15 + .../Services/IChecklistTemplateService.cs | 14 + .../Services/IChecklistsService.cs | 25 + .../Services/IReadinessAccessService.cs | 16 + .../WorkflowTemplateVariableCatalog.cs | 7 +- .../Resgrid.Model/WorkflowTriggerEventType.cs | 4 +- Core/Resgrid.Services/AdpTableBindings.cs | 5 +- .../ChecklistAuthorizationService.cs | 93 +++ .../ChecklistTemplateService.cs | 30 + Core/Resgrid.Services/ChecklistsService.cs | 364 ++++++++++++ .../Resgrid.Services/GdprDataExportService.cs | 24 +- .../Resgrid.Services/ProtectedFieldCatalog.cs | 5 + .../ReadinessAccessService.cs | 86 +++ .../Records/RecordsAnalyticsService.cs | 11 +- Core/Resgrid.Services/ServicesModule.cs | 4 + Core/Resgrid.Services/SubscriptionsService.cs | 6 + .../WorkflowSampleDataGenerator.cs | 6 +- .../WorkflowTemplateContextBuilder.cs | 14 +- MEMORY.md | 10 + .../RabbitInboundEventProvider.cs | 8 +- .../RabbitTopicProvider.cs | 7 +- .../OutboundEventProvider.cs | 9 +- .../Resgrid.Providers.Claims/ClaimsLogic.cs | 8 +- .../ResgridClaimTypes.cs | 4 +- .../ResgridResources.cs | 4 +- .../M0189_SeedReadinessFeatureFlags.cs | 20 + .../Migrations/M0190_SeedReadinessProAddon.cs | 30 + .../Migrations/M0191_AddChecklistWorkflow.cs | 94 +++ .../M0189_SeedReadinessFeatureFlagsPg.cs | 19 + .../M0190_SeedReadinessProAddonPg.cs | 29 + .../M0191_AddChecklistWorkflowPg.cs | 94 +++ .../ChecklistRepository.cs | 54 ++ .../DeleteRepository.cs | 14 +- .../Modules/ApiDataModule.cs | 3 +- .../Modules/DataModule.cs | 3 +- .../Modules/NonWebDataModule.cs | 3 +- .../Modules/TestingDataModule.cs | 1 + .../Allocations/IdentifierAllocationTests.cs | 2 + .../Rms/RecordsAnalyticsServiceTests.cs | 8 + .../Services/ChecklistAuthorizationTests.cs | 95 ++++ .../Services/ChecklistDatabaseTests.cs | 122 ++++ .../Services/ChecklistLocalizationTests.cs | 61 ++ .../Services/ChecklistTemplateServiceTests.cs | 87 +++ .../Services/ChecklistValidationTests.cs | 105 ++++ .../Services/ChecklistWorkflowTests.cs | 215 +++++++ .../Services/ReadinessAccessServiceTests.cs | 221 +++++++ .../Services/ReadinessApiTests.cs | 98 ++++ .../ReadinessProBillingMappingTests.cs | 123 ++++ .../Web/checklist-localization.test.cjs | 69 +++ Tests/Resgrid.Tests/Web/checklists.test.cjs | 67 +++ Web/Resgrid.Web.Eventing/Worker.cs | 3 +- .../Controllers/v4/ChecklistsController.cs | 47 ++ .../Controllers/v4/ReadinessController.cs | 37 ++ .../Helpers/ClaimsAuthorizationHelper.cs | 4 +- .../v4/Checklists/ChecklistTemplateResults.cs | 17 + .../v4/Checklists/ReadinessAccessResult.cs | 30 + .../Resgrid.Web.Services.xml | 9 + Web/Resgrid.Web.Services/Startup.cs | 4 +- .../User/Controllers/ChecklistsController.cs | 154 +++++ .../User/Controllers/DepartmentController.cs | 2 + .../User/Controllers/SecurityController.cs | 2 +- .../Controllers/SubscriptionController.cs | 10 +- .../Checklists/ChecklistTemplatesView.cs | 11 + .../User/Models/Checklists/ChecklistViews.cs | 18 + .../DepartmentModulesSettingView.cs | 1 + .../Models/Security/RecordsPermissionRow.cs | 6 +- .../Views/Checklists/CompletionDetail.cshtml | 57 ++ .../Areas/User/Views/Checklists/Detail.cshtml | 44 ++ .../Areas/User/Views/Checklists/Edit.cshtml | 21 + .../Areas/User/Views/Checklists/Index.cshtml | 19 + .../Areas/User/Views/Checklists/Locked.cshtml | 14 + .../Areas/User/Views/Checklists/Run.cshtml | 19 + .../User/Views/Checklists/Template.cshtml | 33 ++ .../User/Views/Checklists/Templates.cshtml | 31 + .../User/Views/Checklists/_Protection.cshtml | 6 + .../Checklists/_ProtectionScripts.cshtml | 24 + .../Views/Department/ModuleSettings.cshtml | 15 +- .../Areas/User/Views/Security/Index.cshtml | 8 +- .../User/Views/Shared/_Navigation.cshtml | 8 + .../Helpers/ClaimsAuthorizationHelper.cs | 5 +- Web/Resgrid.Web/Startup.cs | 4 +- .../js/app/internal/checklists/checklists.js | 212 +++++++ .../checklists-p1-m1-implementation.md | 49 ++ .../readiness-pro-plan-review-2026-09-08.md | 160 ++++++ .../readiness-workflows-adp-contract.md | 83 +++ 115 files changed, 10161 insertions(+), 45 deletions(-) create mode 100644 Core/Resgrid.Config/ReadinessProConfig.cs create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.cs create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx create mode 100644 Core/Resgrid.Model/Checklists/ChecklistContracts.cs create mode 100644 Core/Resgrid.Model/Checklists/ChecklistEntities.cs create mode 100644 Core/Resgrid.Model/Checklists/ChecklistEnums.cs create mode 100644 Core/Resgrid.Model/Checklists/ChecklistPermissionCatalog.cs create mode 100644 Core/Resgrid.Model/Checklists/ChecklistTemplate.cs create mode 100644 Core/Resgrid.Model/Checklists/ChecklistTemplateCatalog.cs create mode 100644 Core/Resgrid.Model/Checklists/ChecklistValidation.cs create mode 100644 Core/Resgrid.Model/Repositories/IChecklistRepository.cs create mode 100644 Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs create mode 100644 Core/Resgrid.Model/Services/IChecklistTemplateService.cs create mode 100644 Core/Resgrid.Model/Services/IChecklistsService.cs create mode 100644 Core/Resgrid.Model/Services/IReadinessAccessService.cs create mode 100644 Core/Resgrid.Services/ChecklistAuthorizationService.cs create mode 100644 Core/Resgrid.Services/ChecklistTemplateService.cs create mode 100644 Core/Resgrid.Services/ChecklistsService.cs create mode 100644 Core/Resgrid.Services/ReadinessAccessService.cs create mode 100644 MEMORY.md create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0189_SeedReadinessFeatureFlags.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0190_SeedReadinessProAddon.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0191_AddChecklistWorkflow.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0189_SeedReadinessFeatureFlagsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0190_SeedReadinessProAddonPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0191_AddChecklistWorkflowPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistDatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistLocalizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistTemplateServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistValidationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistWorkflowTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ReadinessAccessServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ReadinessApiTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ReadinessProBillingMappingTests.cs create mode 100644 Tests/Resgrid.Tests/Web/checklist-localization.test.cjs create mode 100644 Tests/Resgrid.Tests/Web/checklists.test.cjs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/ChecklistsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/ReadinessController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Checklists/ChecklistTemplateResults.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Checklists/ReadinessAccessResult.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistTemplatesView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistViews.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/Locked.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/Run.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/Template.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/Templates.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/_Protection.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Checklists/_ProtectionScripts.cshtml create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js create mode 100644 docs/architecture/checklists-p1-m1-implementation.md create mode 100644 docs/architecture/readiness-pro-plan-review-2026-09-08.md create mode 100644 docs/architecture/readiness-workflows-adp-contract.md diff --git a/Core/Resgrid.Config/PaymentProviderConfig.cs b/Core/Resgrid.Config/PaymentProviderConfig.cs index 96bd5d661..dd1552765 100644 --- a/Core/Resgrid.Config/PaymentProviderConfig.cs +++ b/Core/Resgrid.Config/PaymentProviderConfig.cs @@ -42,6 +42,12 @@ public static class PaymentProviderConfig // precedent and live here. public static string PaddleAdpAddon = "pri_01m11vm50c17z0rxcgy4fppf80"; public static string PaddleAdpAddonTest = ""; + + // Readiness Pro: EUR 195/month, Paddle product pro_01m20xwmzpnkxzp7mm7nwwxp7p. + // Stripe USD 150/month is seeded on PlanAddons by M0190. Test prices must be + // configured separately; a missing sandbox price must never fall back to production. + public static string PaddleReadinessProAddon = "pri_01m20xy5x54j0sp4mcydcm4q6m"; + public static string PaddleReadinessProAddonTest = ""; public static string PaddleProductionEnvironment = "production"; public static string PaddleTestEnvironment = "sandbox"; public static string PaddleProductionClientToken = ""; @@ -153,6 +159,11 @@ public static string GetPaddleAdpAddonPriceId() return PaddleAdpAddon; } + public static string GetPaddleReadinessProAddonPriceId() + { + return NormalizeConfigValue(IsTestMode ? PaddleReadinessProAddonTest : PaddleReadinessProAddon); + } + public static string GetPaddleEnvironment() { if (IsTestMode) diff --git a/Core/Resgrid.Config/ReadinessProConfig.cs b/Core/Resgrid.Config/ReadinessProConfig.cs new file mode 100644 index 000000000..5f8cd008b --- /dev/null +++ b/Core/Resgrid.Config/ReadinessProConfig.cs @@ -0,0 +1,9 @@ +namespace Resgrid.Config +{ + /// Department-level monthly Readiness Pro prices. Provider checkout is implemented in P2-M1. + public static class ReadinessProConfig + { + 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 new file mode 100644 index 000000000..50f28ada6 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + قوائم التحقق + + + قوالب قوائم التحقق + + + قوائم التحقق مجانية لجميع الإدارات. + + + قبل الاستخدام، عدّل هذه القوالب لتناسب معداتك وإجراءات الشركة المصنّعة والمتطلبات المحلية. + + + البحث عن قالب + + + بحث + + + معاينة القالب + + + لا توجد قوالب تطابق بحثك. + + + رجوع + + + حرج + + + مطلوب + + + يلزم إدخال ملاحظة عند عدم اجتياز الفحص. + + + يجب على مُعدّ القائمة وعضو آخر، بعد التحقق من هوية كل منهما، التحقق من النتيجة بشكل مستقل. + + + الصيانة وأوامر العمل (Readiness Pro) + + + قائمة تحقق جديدة + + + لا توجد قوائم تحقق في هذه الصفحة. + + + الاسم + + + الإصدار + + + الحالة + + + متوقفة عن الاستخدام + + + مسودة + + + منشورة + + + السابق + + + التالي + + + محرر قائمة التحقق + + + احفظ مسودة ثم انشرها. تحتفظ عمليات التحقق الحالية بإصدارها المنشور الأصلي. لا تُدخل بيانات المرضى. + + + حفظ المسودة + + + الإصدار المنشور + + + نشطة + + + تعديل المسودة + + + نشر المسودة + + + ينشئ النشر إصدارًا غير قابل للتعديل لعمليات التحقق الجديدة. + + + إيقاف الاستخدام + + + حذف المسودة + + + الجهة أو العنصر محل الفحص + + + اختر الجهة أو العنصر محل الفحص + + + بدء قائمة التحقق + + + السجل + + + وقت البدء + + + الدرجة + + + النتيجة + + + اجتاز الفحص + + + لم يجتز الفحص + + + غير مكتمل + + + أجب عن كل بند ينطبق على الحالة. يتطلب اختيار «لا ينطبق» ذكر السبب. احفظ التقدم للمتابعة لاحقًا. لا يمكن تعديل الإجابات والمرفقات بعد إرسالها. + + + حفظ التقدم + + + إرسال قائمة التحقق + + + لا توجد درجة قابلة للاحتساب + + + مُعدّ القائمة + + + تم الإرسال + + + الشاهد + + + البند + + + الإجابة + + + الملاحظة + + + المرفقات الإثباتية + + + لم تتم الإجابة + + + الموقع المُبلّغ عنه + + + يجب أن يتحقق عضو آخر، بعد التحقق من هويته، من الإجابات والمرفقات المُرسلة بشكل مستقل. شارك معه رابط هذه الصفحة. + + + إقرار التحقق المستقل + + + إقرار وإكمال + + + تصدير بصيغة JSON + + + طباعة + + + اسم قائمة التحقق + + + التعليمات (لا تُدرج بيانات المرضى) + + + الفئة + + + نوع الجهة أو العنصر محل الفحص + + + درجة الاجتياز (%) + + + إلزام إدخال الموقع المُبلّغ عنه + + + اشتراط أن يشهد على الإرسال عضو آخر تم التحقق من هويته + + + اسم القسم + + + السؤال / الفحص + + + تعليمات البند + + + نوع الإجابة + + + عدم اجتياز بند حرج يجعل النتيجة غير ناجحة مهما كانت الدرجة + + + السماح باختيار «لا ينطبق» مع ذكر السبب + + + إلزام إدخال ملاحظة عند عدم الاجتياز + + + إلزام إرفاق صورة عند عدم الاجتياز + + + وزن البند في الدرجة (0 يستبعده من الحساب) + + + الإجابة التي تحقق الاجتياز + + + وحدات القياس + + + الحد الأدنى للاجتياز (اختياري إذا تم تحديد الحد الأعلى) + + + الحد الأعلى للاجتياز (اختياري إذا تم تحديد الحد الأدنى) + + + الخيارات (خيار واحد في كل سطر) + + + الخيار المطابق المطلوب للاجتياز + + + الإظهار فقط عند + + + مطلوب أيضًا عند + + + دائمًا / دون شرط + + + نقل القسم إلى أعلى + + + نقل القسم إلى أسفل + + + حذف القسم + + + نقل البند إلى أعلى + + + نقل البند إلى أسفل + + + حذف البند + + + إضافة بند + + + إضافة قسم + + + حالة الإجابة + + + سبب عدم الانطباق + + + ملاحظة (مطلوبة عند عدم الاجتياز) + + + ملاحظة الإكمال / التسليم + + + خط العرض المُبلّغ عنه + + + خط الطول المُبلّغ عنه + + + استخدام الموقع الحالي + + + مسح التوقيع + + + حفظ صورة التوقيع + + + حذف + + + اجتاز / لم يجتز + + + نعم / لا + + + مربع اختيار + + + قراءة رقمية + + + الكمية + + + نص حر + + + قائمة خيارات + + + التاريخ + + + صورة + + + توقيع + + + بداية الوردية + + + فحص الوحدة + + + المعدات الشخصية + + + مراجعة سنوية + + + المنشأة + + + تدقيق السلامة + + + فحص المعدات + + + أخرى + + + الإدارة + + + الوحدة + + + المجموعة / المحطة + + + الأفراد + + + القسم {0} + + + البند {0} + + + هل تريد حذف هذا القسم وبنوده من المسودة؟ + + + هل تريد حذف هذا البند من المسودة؟ + + + نعم / محدد + + + لا / غير محدد + + + بند سابق دون اسم + + + الإجابة التي تفعّل هذا الشرط + + + اختياري + + + تمت الإجابة + + + لا ينطبق + + + اختر + + + اجتاز + + + لم يجتز + + + نعم + + + لا + + + محدد + + + غير محدد + + + نطاق الاجتياز: من {0} إلى {1} + + + دون حد أدنى + + + دون حد أعلى + + + صورة إثباتية (PNG/JPEG، حتى 10 ميغابايت؛ يلزم فحص أمني) + + + ارسم توقيعك أو ارفع صورة له. إذا كان مطلوبًا وجود شاهد مستقل، فعليه تسجيل الدخول بشكل منفصل. + + + مساحة رسم التوقيع. يمكنك رفع صورة بدلًا من ذلك. + + + ارسم توقيعًا أولًا. + + + الموقع / المبنى / الغرفة + + + خط العرض المُبلّغ عنه (مطلوب) + + + الموقع غير متاح. أدخل الإحداثيات يدويًا. + + + تعذّر تحديد الموقع. أدخل الإحداثيات يدويًا. + + + تم حفظ التقدم السابق؛ لا تزال هناك تغييرات غير محفوظة. + + + تم حفظ التقدم. + + + تعذّر إكمال الطلب. لا تزال تغييراتك موجودة في هذه الصفحة. + + + تعذّر إكمال الطلب. + + + يجب ألا يتجاوز حجم المرفق الإثباتي 10 ميغابايت. + + + قيد التنفيذ + + + بانتظار الشاهد + + + بيانات قائمة التحقق المحمية + + + قوائم التحقق المحمية + + + فتح بيانات قائمة التحقق + + + تحقق من هويتك لعرض قائمة التحقق والإجابات والمرفقات الإثباتية. + + + تحقق وافتح + + + استخدام هذا القالب + + + إدارة قوائم التحقق + + + عرض نتائج قوائم التحقق + + + إنشاء قوائم تحقق مجانية وتعديلها ونشرها وإيقاف استخدامها. + + + عرض نتائج الأعضاء الآخرين. يحتفظ كل عضو بإمكانية الوصول إلى سجله. + + + الفحوصات + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.cs b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.cs new file mode 100644 index 000000000..9d9f39054 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.cs @@ -0,0 +1,4 @@ +namespace Resgrid.Localization.Areas.User.Checklists +{ + public class Checklists { } +} diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx new file mode 100644 index 000000000..7e7457a20 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Neue Checkliste + + + Keine Checklisten auf dieser Seite. + + + Name + + + Version + + + Status + + + Stillgelegt + + + Entwurf + + + Veröffentlicht + + + Zurück + + + Weiter + + + Checklisten-Editor + + + Speichern Sie einen Entwurf und veröffentlichen Sie ihn. Bestehende Durchläufe behalten ihre ursprüngliche Version. Keine Patientendaten eingeben. + + + Entwurf speichern + + + Veröffentlichte Version + + + Aktiv + + + Entwurf bearbeiten + + + Entwurf veröffentlichen + + + Die Veröffentlichung erstellt eine unveränderliche Version für neue Durchläufe. + + + Stilllegen + + + Entwurf löschen + + + Ziel + + + Ziel auswählen + + + Checkliste starten + + + Verlauf + + + Gestartet + + + Punktzahl + + + Ergebnis + + + Bestanden + + + Nicht bestanden + + + Unvollständig + + + Beantworten Sie alle zutreffenden Punkte. Nicht zutreffend erfordert eine Begründung. Speichern Sie den Fortschritt, um später fortzufahren. Eingereichte Antworten und Nachweise sind unveränderlich. + + + Fortschritt speichern + + + Checkliste einreichen + + + Keine bewertbaren Punkte + + + Verfasser + + + Eingereicht + + + Zeuge + + + Punkt + + + Antwort + + + Anmerkung + + + Nachweis + + + Unbeantwortet + + + Gemeldeter Standort + + + Ein anderes angemeldetes Mitglied muss die eingereichten Antworten und Nachweise unabhängig prüfen. Teilen Sie den Link zu dieser Seite mit diesem Mitglied. + + + Erklärung zur unabhängigen Prüfung + + + Bestätigen und abschließen + + + JSON exportieren + + + Drucken + + + Name der Checkliste + + + Anweisungen (keine Patientendaten) + + + Kategorie + + + Zieltyp + + + Mindestpunktzahl (%) + + + Gemeldeten Standort verlangen + + + Ein anderes angemeldetes Mitglied muss die Einreichung bezeugen + + + Abschnittsname + + + Frage / Prüfung + + + Anweisungen zum Punkt + + + Antworttyp + + + Erforderlich + + + Kritische Fehler haben Vorrang vor der Punktzahl + + + Nicht zutreffend mit Begründung erlauben + + + Anmerkung bei Fehler verlangen + + + Foto bei Fehler verlangen + + + Gewichtung (0 schließt den Punkt aus) + + + Als bestanden gewertete Antwort + + + Einheiten + + + Minimaler zulässiger Wert (optional, wenn die Obergrenze festgelegt ist) + + + Maximaler zulässiger Wert (optional, wenn die Untergrenze festgelegt ist) + + + Optionen (eine pro Zeile) + + + Exakte als bestanden gewertete Auswahl + + + Nur anzeigen, wenn + + + Zusätzlich erforderlich, wenn + + + Immer / keine Bedingung + + + Abschnitt nach oben + + + Abschnitt nach unten + + + Abschnitt entfernen + + + Punkt nach oben + + + Punkt nach unten + + + Punkt entfernen + + + Punkt hinzufügen + + + Abschnitt hinzufügen + + + Antwortstatus + + + Begründung für nicht zutreffend + + + Anmerkung (bei Fehler erforderlich) + + + Abschluss- / Übergabeanmerkung + + + Gemeldeter Breitengrad + + + Gemeldeter Längengrad + + + Aktuellen Standort verwenden + + + Unterschrift löschen + + + Unterschriftsbild speichern + + + Entfernen + + + Checklisten + + + Checklisten-Vorlagen + + + Checklisten sind für alle Abteilungen kostenlos. + + + Passen Sie die Vorlagen vor dem Einsatz an Ihre Ausrüstung, Herstelleranweisungen und örtlichen Anforderungen an. + + + Vorlage finden + + + Suchen + + + Vorlage ansehen + + + Keine passenden Vorlagen gefunden. + + + Zurück + + + Kritisch + + + Eine nicht bestandene Prüfung erfordert eine Anmerkung. + + + Ein angemeldeter Verfasser und ein anderes angemeldetes Mitglied müssen das Ergebnis unabhängig bestätigen. + + + Wartung und Arbeitsaufträge (Readiness Pro) + + + Bestanden / Nicht bestanden + + + Ja / Nein + + + Kontrollkästchen + + + Messwert + + + Menge + + + Freitext + + + Auswahlliste + + + Datum + + + Foto + + + Unterschrift + + + Schichtbeginn + + + Einheitenprüfung + + + Persönliche Ausrüstung + + + Jährliche Überprüfung + + + Einrichtung + + + Sicherheitsprüfung + + + Ausrüstungsprüfung + + + Sonstiges + + + Abteilung + + + Einheit + + + Gruppe / Wache + + + Personal + + + Abschnitt {0} + + + Punkt {0} + + + Diesen Abschnitt und seine Punkte aus dem Entwurf entfernen? + + + Diesen Punkt aus dem Entwurf entfernen? + + + Ja / aktiviert + + + Nein / nicht aktiviert + + + Vorheriger Punkt ohne Namen + + + Antwort, die diese Bedingung auslöst + + + Optional + + + Beantwortet + + + Nicht zutreffend + + + Auswählen + + + Bestanden + + + Nicht bestanden + + + Ja + + + Nein + + + Aktiviert + + + Nicht aktiviert + + + Zulässiger Bereich: {0} bis {1} + + + Keine Untergrenze + + + Keine Obergrenze + + + Nachweisbild (PNG/JPEG, bis 10 MB; Virenprüfung erforderlich) + + + Zeichnen Sie Ihre Unterschrift oder laden Sie ein Unterschriftsbild hoch. Ein erforderlicher unabhängiger Zeuge muss sich separat anmelden. + + + Zeichenbereich für die Unterschrift. Alternativ ein Bild hochladen. + + + Zeichnen Sie zuerst eine Unterschrift. + + + Standort / Gebäude / Raum + + + Gemeldeter Breitengrad (erforderlich) + + + Standort nicht verfügbar. Geben Sie die Koordinaten manuell ein. + + + Standort konnte nicht ermittelt werden. Geben Sie die Koordinaten manuell ein. + + + Vorheriger Fortschritt gespeichert; neuere Änderungen sind noch nicht gespeichert. + + + Fortschritt gespeichert. + + + Die Anfrage konnte nicht abgeschlossen werden. Ihre Änderungen bleiben auf dieser Seite erhalten. + + + Die Anfrage konnte nicht abgeschlossen werden. + + + Nachweise dürfen höchstens 10 MB groß sein. + + + In Bearbeitung + + + Warten auf Zeugenbestätigung + + + Geschützte Checklistendaten + + + Geschützte Checklisten + + + Checklistendaten entsperren + + + Bestätigen Sie Ihre Identität, um die Checkliste, Antworten und Nachweise anzuzeigen. + + + Bestätigen und öffnen + + + Diese Vorlage verwenden + + + Checklisten verwalten + + + Checklistenergebnisse anzeigen + + + Kostenlose Checklisten erstellen, bearbeiten, veröffentlichen und stilllegen. + + + Ergebnisse anderer Mitglieder anzeigen. Mitglieder behalten Zugriff auf ihren eigenen Verlauf. + + + Prüfungen + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx new file mode 100644 index 000000000..253341648 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Νέα λίστα + + + Δεν υπάρχουν λίστες σε αυτή τη σελίδα. + + + Όνομα + + + Έκδοση + + + Κατάσταση + + + Αποσυρμένη + + + Πρόχειρο + + + Δημοσιευμένη + + + Προηγούμενο + + + Επόμενο + + + Επεξεργασία λίστας + + + Αποθηκεύστε ένα πρόχειρο και δημοσιεύστε το. Οι υπάρχουσες εκτελέσεις διατηρούν την αρχική έκδοση. Μην εισάγετε δεδομένα ασθενών. + + + Αποθήκευση πρόχειρου + + + Δημοσιευμένη έκδοση + + + Ενεργή + + + Επεξεργασία πρόχειρου + + + Δημοσίευση πρόχειρου + + + Η δημοσίευση δημιουργεί αμετάβλητη έκδοση για νέες εκτελέσεις. + + + Απόσυρση + + + Διαγραφή πρόχειρου + + + Στόχος + + + Επιλέξτε στόχο + + + Έναρξη λίστας + + + Ιστορικό + + + Έναρξη + + + Βαθμολογία + + + Αποτέλεσμα + + + Επιτυχία + + + Αποτυχία + + + Ελλιπές + + + Απαντήστε σε κάθε σχετικό στοιχείο. Το μη εφαρμόσιμο απαιτεί αιτιολογία. Αποθηκεύστε την πρόοδο για συνέχεια αργότερα. Οι υποβληθείσες απαντήσεις και αποδείξεις δεν αλλάζουν. + + + Αποθήκευση προόδου + + + Υποβολή λίστας + + + Χωρίς εφαρμόσιμη βαθμολογία + + + Συντάκτης + + + Υποβλήθηκε + + + Μάρτυρας + + + Στοιχείο + + + Απάντηση + + + Σημείωση + + + Αποδεικτικό + + + Αναπάντητο + + + Δηλωμένη τοποθεσία + + + Ένα διαφορετικό μέλος με επαληθευμένη ταυτότητα πρέπει να ελέγξει ανεξάρτητα τις υποβληθείσες απαντήσεις και τα αποδεικτικά. Μοιραστείτε μαζί του τον σύνδεσμο αυτής της σελίδας. + + + Δήλωση ανεξάρτητης επαλήθευσης + + + Βεβαίωση και ολοκλήρωση + + + Εξαγωγή JSON + + + Εκτύπωση + + + Όνομα λίστας + + + Οδηγίες (χωρίς δεδομένα ασθενών) + + + Κατηγορία + + + Τύπος στόχου + + + Ελάχιστη βαθμολογία (%) + + + Απαίτηση δηλωμένης τοποθεσίας + + + Απαίτηση διαφορετικού μέλους με επαληθευμένη ταυτότητα ως μάρτυρα της υποβολής + + + Όνομα ενότητας + + + Ερώτηση / έλεγχος + + + Οδηγίες στοιχείου + + + Τύπος απάντησης + + + Απαιτείται + + + Η κρίσιμη αποτυχία υπερισχύει της βαθμολογίας + + + Να επιτρέπεται μη εφαρμόσιμο με αιτιολογία + + + Απαίτηση σημείωσης σε αποτυχία + + + Απαίτηση φωτογραφίας σε αποτυχία + + + Βαρύτητα βαθμολογίας (0 εξαιρεί το στοιχείο) + + + Απάντηση επιτυχίας + + + Μονάδες + + + Ελάχιστη τιμή επιτυχίας (προαιρετική με μέγιστο) + + + Μέγιστη τιμή επιτυχίας (προαιρετική με ελάχιστο) + + + Επιλογές (μία ανά γραμμή) + + + Ακριβής επιλογή επιτυχίας + + + Εμφάνιση μόνο όταν + + + Απαιτείται επίσης όταν + + + Πάντα / χωρίς συνθήκη + + + Μετακίνηση ενότητας πάνω + + + Μετακίνηση ενότητας κάτω + + + Αφαίρεση ενότητας + + + Μετακίνηση στοιχείου πάνω + + + Μετακίνηση στοιχείου κάτω + + + Αφαίρεση στοιχείου + + + Προσθήκη στοιχείου + + + Προσθήκη ενότητας + + + Κατάσταση απάντησης + + + Αιτιολογία μη εφαρμογής + + + Σημείωση (απαιτείται σε αποτυχία) + + + Σημείωση ολοκλήρωσης / παράδοσης + + + Δηλωμένο γεωγραφικό πλάτος + + + Δηλωμένο γεωγραφικό μήκος + + + Χρήση τρέχουσας τοποθεσίας + + + Εκκαθάριση υπογραφής + + + Αποθήκευση εικόνας υπογραφής + + + Αφαίρεση + + + Λίστες ελέγχου + + + Πρότυπα λιστών + + + Οι λίστες είναι δωρεάν για όλα τα τμήματα. + + + Προσαρμόστε τα πρότυπα στον εξοπλισμό, τις διαδικασίες του κατασκευαστή και τις τοπικές απαιτήσεις πριν από τη χρήση. + + + Εύρεση προτύπου + + + Αναζήτηση + + + Προεπισκόπηση προτύπου + + + Δεν βρέθηκαν σχετικά πρότυπα. + + + Πίσω + + + Κρίσιμο + + + Μια αποτυχημένη δοκιμή απαιτεί σημείωση. + + + Ο συντάκτης και ένα διαφορετικό μέλος, με επαληθευμένη την ταυτότητα και των δύο, πρέπει να επαληθεύσουν ανεξάρτητα το αποτέλεσμα. + + + Συντήρηση και εντολές εργασίας (Readiness Pro) + + + Επιτυχία / Αποτυχία + + + Ναι / Όχι + + + Πλαίσιο ελέγχου + + + Αριθμητική μέτρηση + + + Ποσότητα + + + Ελεύθερο κείμενο + + + Λίστα επιλογών + + + Ημερομηνία + + + Φωτογραφία + + + Υπογραφή + + + Έναρξη βάρδιας + + + Έλεγχος μονάδας + + + Ατομικός εξοπλισμός + + + Ετήσια ανασκόπηση + + + Εγκατάσταση + + + Έλεγχος ασφάλειας + + + Έλεγχος εξοπλισμού + + + Άλλο + + + Τμήμα + + + Μονάδα + + + Ομάδα / σταθμός + + + Προσωπικό + + + Ενότητα {0} + + + Στοιχείο {0} + + + Να αφαιρεθούν αυτή η ενότητα και τα στοιχεία της από το πρόχειρο; + + + Να αφαιρεθεί αυτό το στοιχείο από το πρόχειρο; + + + Ναι / επιλεγμένο + + + Όχι / μη επιλεγμένο + + + Προηγούμενο στοιχείο χωρίς όνομα + + + Απάντηση που ενεργοποιεί αυτή τη συνθήκη + + + Προαιρετικό + + + Απαντημένο + + + Δεν εφαρμόζεται + + + Επιλέξτε + + + Επιτυχία + + + Αποτυχία + + + Ναι + + + Όχι + + + Επιλεγμένο + + + Μη επιλεγμένο + + + Εύρος επιτυχίας: {0} έως {1} + + + Χωρίς ελάχιστο + + + Χωρίς μέγιστο + + + Εικόνα τεκμηρίωσης (PNG/JPEG, έως 10 MB· απαιτείται έλεγχος για κακόβουλο λογισμικό) + + + Σχεδιάστε την υπογραφή σας ή μεταφορτώστε εικόνα της. Αν απαιτείται ανεξάρτητος μάρτυρας, πρέπει να συνδεθεί ξεχωριστά. + + + Περιοχή σχεδίασης υπογραφής. Εναλλακτικά, μεταφορτώστε μια εικόνα. + + + Σχεδιάστε πρώτα μια υπογραφή. + + + Τοποθεσία / κτίριο / δωμάτιο + + + Δηλωμένο γεωγραφικό πλάτος (απαιτείται) + + + Η τοποθεσία δεν είναι διαθέσιμη. Εισαγάγετε τις συντεταγμένες χειροκίνητα. + + + Δεν ήταν δυνατός ο εντοπισμός της τοποθεσίας. Εισαγάγετε τις συντεταγμένες χειροκίνητα. + + + Η προηγούμενη πρόοδος αποθηκεύτηκε· υπάρχουν νεότερες μη αποθηκευμένες αλλαγές. + + + Η πρόοδος αποθηκεύτηκε. + + + Δεν ήταν δυνατή η ολοκλήρωση του αιτήματος. Οι αλλαγές σας παραμένουν σε αυτή τη σελίδα. + + + Δεν ήταν δυνατή η ολοκλήρωση του αιτήματος. + + + Τα αποδεικτικά δεν πρέπει να υπερβαίνουν τα 10 MB. + + + Σε εξέλιξη + + + Αναμονή μάρτυρα + + + Προστατευμένα δεδομένα λίστας ελέγχου + + + Προστατευμένες λίστες ελέγχου + + + Ξεκλείδωμα δεδομένων λίστας ελέγχου + + + Επαληθεύστε την ταυτότητά σας για να δείτε τη λίστα, τις απαντήσεις και τα αποδεικτικά. + + + Επαλήθευση και άνοιγμα + + + Χρήση αυτού του προτύπου + + + Διαχείριση λιστών ελέγχου + + + Προβολή αποτελεσμάτων λιστών ελέγχου + + + Δημιουργία, επεξεργασία, δημοσίευση και απόσυρση δωρεάν λιστών ελέγχου. + + + Προβολή αποτελεσμάτων άλλων μελών. Τα μέλη διατηρούν πρόσβαση στο δικό τους ιστορικό. + + + Έλεγχοι + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx new file mode 100644 index 000000000..d32c76c91 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Checklists + + + Checklist templates + + + Checklists are free for all departments. + + + Adapt these starting points to your equipment, manufacturer procedures and local requirements before use. + + + Find a template + + + Search + + + Preview template + + + No templates match your search. + + + Back + + + Critical + + + Required + + + A failed check requires a note. + + + An authenticated author and a different authenticated member must independently verify the result. + + + Maintenance and Work Orders (Readiness Pro) + + + New checklist + + + No checklists on this page. + + + Name + + + Version + + + State + + + Retired + + + Draft + + + Published + + + Previous + + + Next + + + Checklist editor + + + Save a draft, then publish it. Existing runs keep their original published version. Do not enter patient data. + + + Save draft + + + Published version + + + Active + + + Edit draft + + + Publish draft + + + Publishing creates an immutable version for new runs. + + + Retire + + + Delete draft + + + Target + + + Select a target + + + Start checklist + + + History + + + Started + + + Score + + + Result + + + Passed + + + Failed + + + Incomplete + + + Answer each applicable item. N/A requires a reason. Save progress to resume later. Submitted answers and evidence cannot be changed. + + + Save progress + + + Submit checklist + + + No applicable score + + + Author + + + Submitted + + + Witness + + + Item + + + Answer + + + Note + + + Evidence + + + Unanswered + + + Reported location + + + This run needs a different authenticated member to independently verify the submitted answers and evidence. Share this page link with that member. + + + Independent verification statement + + + Attest and complete + + + Export JSON + + + Print + + + Checklist name + + + Instructions (do not include patient data) + + + Category + + + Target type + + + Passing score (%) + + + Require reported location + + + Require a different authenticated member to witness the submission + + + Section name + + + Question / check + + + Item instructions + + + Answer type + + + Critical failure overrides the score + + + Allow N/A with a reason + + + Require a note on failure + + + Require a photo on failure + + + Score weight (0 excludes this item from the score) + + + Passing answer + + + Units + + + Minimum passing value (optional if maximum is set) + + + Maximum passing value (optional if minimum is set) + + + Choices (one per line) + + + Exact passing choice + + + Show only when + + + Also required when + + + Always / no condition + + + Move section up + + + Move section down + + + Remove section + + + Move item up + + + Move item down + + + Remove item + + + Add item + + + Add section + + + Answer status + + + N/A reason + + + Note (required on failure) + + + Completion / handover note + + + Reported latitude + + + Reported longitude + + + Use current location + + + Clear signature + + + Save signature image + + + Remove + + + In progress + + + Awaiting witness + + + Pass / Fail + + + Yes / No + + + Checkbox + + + Numeric reading + + + Quantity + + + Free text + + + Select list + + + Date + + + Photo + + + Signature + + + Start of shift + + + Unit check + + + Personal gear + + + Annual review + + + Facility + + + Safety audit + + + Equipment check + + + Other + + + Department + + + Unit + + + Group / station + + + Personnel + + + Section {0} + + + Item {0} + + + Remove this section and its items from the draft? + + + Remove this item from the draft? + + + Yes / checked + + + No / unchecked + + + Unnamed earlier item + + + Answer that activates this condition + + + Optional + + + Answered + + + N/A + + + Choose + + + Pass + + + Fail + + + Yes + + + No + + + Checked + + + Unchecked + + + Passing range: {0} to {1} + + + No minimum + + + No maximum + + + Evidence image (PNG/JPEG, up to 10 MB; scanning required) + + + Draw your signature or upload a signature image. A required independent witness must sign in separately. + + + Signature drawing area. Alternatively upload an image. + + + Draw a signature first. + + + Site / building / room + + + Reported latitude (required) + + + Location is unavailable. Enter coordinates manually. + + + Location could not be read. Enter coordinates manually. + + + Earlier progress saved; unsaved changes remain. + + + Progress saved. + + + The request could not be completed. Your changes are still on this page. + + + The request could not be completed. + + + Evidence must be at most 10 MB. + + + Protected checklist data + + + Protected checklists + + + Unlock checklist data + + + Verify your identity to view the checklist, answers and evidence. + + + Verify and open + + + Use this template + + + Manage checklists + + + View checklist results + + + Create, edit, publish and retire free checklists. + + + View results from other members. Members retain their own history. + + + Checks + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx new file mode 100644 index 000000000..7b571db88 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Nueva lista + + + No hay listas en esta página. + + + Nombre + + + Versión + + + Estado + + + Retirada + + + Borrador + + + Publicada + + + Anterior + + + Siguiente + + + Editor de listas + + + Guarde un borrador y publíquelo. Las ejecuciones existentes conservan su versión original. No introduzca datos de pacientes. + + + Guardar borrador + + + Versión publicada + + + Activa + + + Editar borrador + + + Publicar borrador + + + La publicación crea una versión inmutable para nuevas ejecuciones. + + + Retirar + + + Eliminar borrador + + + Destino + + + Seleccione un destino + + + Iniciar lista + + + Historial + + + Inicio + + + Puntuación + + + Resultado + + + Aprobado + + + No aprobado + + + Incompleto + + + Responda cada elemento aplicable. No aplicable requiere un motivo. Guarde el progreso para continuar después. Las respuestas y pruebas enviadas no se pueden cambiar. + + + Guardar progreso + + + Enviar lista + + + Sin puntuación aplicable + + + Autor + + + Enviada + + + Testigo + + + Elemento + + + Respuesta + + + Nota + + + Prueba + + + Sin respuesta + + + Ubicación indicada + + + Otro miembro autenticado debe verificar de forma independiente las respuestas y pruebas enviadas. Comparta el enlace de esta página con esa persona. + + + Declaración de verificación independiente + + + Confirmar y completar + + + Exportar JSON + + + Imprimir + + + Nombre de la lista + + + Instrucciones (sin datos de pacientes) + + + Categoría + + + Tipo de destino + + + Puntuación mínima (%) + + + Exigir ubicación indicada + + + Exigir otro miembro autenticado como testigo del envío + + + Nombre de la sección + + + Pregunta / comprobación + + + Instrucciones del elemento + + + Tipo de respuesta + + + Obligatorio + + + Un fallo crítico prevalece sobre la puntuación + + + Permitir no aplicable con motivo + + + Exigir nota en caso de fallo + + + Exigir foto en caso de fallo + + + Peso de puntuación (0 excluye el elemento) + + + Respuesta aprobatoria + + + Unidades + + + Valor mínimo aprobado (opcional si hay máximo) + + + Valor máximo aprobado (opcional si hay mínimo) + + + Opciones (una por línea) + + + Opción exacta aprobatoria + + + Mostrar solo cuando + + + También obligatorio cuando + + + Siempre / sin condición + + + Subir sección + + + Bajar sección + + + Eliminar sección + + + Subir elemento + + + Bajar elemento + + + Eliminar elemento + + + Añadir elemento + + + Añadir sección + + + Estado de respuesta + + + Motivo de no aplicable + + + Nota (obligatoria si falla) + + + Nota de finalización / relevo + + + Latitud indicada + + + Longitud indicada + + + Usar ubicación actual + + + Borrar firma + + + Guardar imagen de firma + + + Eliminar + + + Listas de verificación + + + Plantillas de listas + + + Las listas son gratuitas para todos los departamentos. + + + Adapte las plantillas a su equipo, los procedimientos del fabricante y los requisitos locales antes de usarlas. + + + Buscar plantilla + + + Buscar + + + Vista previa + + + No hay plantillas que coincidan. + + + Volver + + + Crítico + + + Una comprobación fallida requiere una nota. + + + El autor autenticado y otro miembro autenticado deben verificar el resultado de forma independiente. + + + Mantenimiento y órdenes de trabajo (Readiness Pro) + + + Aprobado / No aprobado + + + Sí / No + + + Casilla de verificación + + + Lectura numérica + + + Cantidad + + + Texto libre + + + Lista de opciones + + + Fecha + + + Foto + + + Firma + + + Inicio de turno + + + Revisión de unidad + + + Equipo personal + + + Revisión anual + + + Instalaciones + + + Auditoría de seguridad + + + Revisión de equipos + + + Otra + + + Departamento + + + Unidad + + + Grupo / estación + + + Personal + + + Sección {0} + + + Elemento {0} + + + ¿Eliminar esta sección y sus elementos del borrador? + + + ¿Eliminar este elemento del borrador? + + + Sí / marcada + + + No / desmarcada + + + Elemento anterior sin nombre + + + Respuesta que activa esta condición + + + Opcional + + + Respondido + + + No aplica + + + Seleccione + + + Aprobado + + + No aprobado + + + + + + No + + + Marcada + + + Desmarcada + + + Rango aprobado: de {0} a {1} + + + Sin mínimo + + + Sin máximo + + + Imagen de evidencia (PNG/JPEG, hasta 10 MB; requiere análisis de seguridad) + + + Dibuje su firma o cargue una imagen de ella. Si se requiere un testigo independiente, debe iniciar sesión por separado. + + + Área para dibujar la firma. También puede cargar una imagen. + + + Primero dibuje una firma. + + + Sitio / edificio / habitación + + + Latitud indicada (obligatoria) + + + La ubicación no está disponible. Ingrese las coordenadas manualmente. + + + No se pudo obtener la ubicación. Ingrese las coordenadas manualmente. + + + Se guardó el progreso anterior; aún hay cambios sin guardar. + + + Progreso guardado. + + + No se pudo completar la solicitud. Sus cambios siguen en esta página. + + + No se pudo completar la solicitud. + + + La evidencia no debe superar los 10 MB. + + + En curso + + + Pendiente de testigo + + + Datos protegidos de la lista de verificación + + + Listas de verificación protegidas + + + Desbloquear los datos de la lista + + + Verifique su identidad para ver la lista, las respuestas y las evidencias. + + + Verificar y abrir + + + Usar esta plantilla + + + Administrar listas de verificación + + + Ver resultados de las listas + + + Crear, editar, publicar y retirar listas de verificación gratuitas. + + + Ver los resultados de otros miembros. Cada miembro conserva el acceso a su propio historial. + + + Verificaciones + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx new file mode 100644 index 000000000..db6008857 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Nouvelle liste + + + Aucune liste sur cette page. + + + Nom + + + Version + + + État + + + Retirée + + + Brouillon + + + Publiée + + + Précédent + + + Suivant + + + Éditeur de listes + + + Enregistrez un brouillon, puis publiez-le. Les exécutions existantes conservent leur version initiale. Ne saisissez aucune donnée de patient. + + + Enregistrer le brouillon + + + Version publiée + + + Active + + + Modifier le brouillon + + + Publier le brouillon + + + La publication crée une version immuable pour les nouvelles exécutions. + + + Retirer + + + Supprimer le brouillon + + + Cible + + + Choisir une cible + + + Démarrer la liste + + + Historique + + + Début + + + Score + + + Résultat + + + Réussi + + + Échoué + + + Incomplet + + + Répondez à chaque élément applicable. Non applicable exige un motif. Enregistrez la progression pour reprendre plus tard. Les réponses et preuves soumises sont immuables. + + + Enregistrer la progression + + + Soumettre la liste + + + Aucun score applicable + + + Auteur + + + Soumise + + + Témoin + + + Élément + + + Réponse + + + Note + + + Preuve + + + Sans réponse + + + Position déclarée + + + Un autre membre authentifié doit vérifier indépendamment les réponses et preuves soumises. Partagez le lien de cette page avec cette personne. + + + Déclaration de vérification indépendante + + + Attester et terminer + + + Exporter en JSON + + + Imprimer + + + Nom de la liste + + + Instructions (sans données de patients) + + + Catégorie + + + Type de cible + + + Score minimal (%) + + + Exiger la position déclarée + + + Exiger un autre membre authentifié comme témoin de la soumission + + + Nom de la section + + + Question / contrôle + + + Instructions de l'élément + + + Type de réponse + + + Obligatoire + + + Un échec critique prévaut sur le score + + + Autoriser non applicable avec un motif + + + Exiger une note en cas d'échec + + + Exiger une photo en cas d'échec + + + Poids du score (0 exclut l'élément) + + + Réponse de réussite + + + Unités + + + Valeur minimale de réussite (facultative si maximum) + + + Valeur maximale de réussite (facultative si minimum) + + + Choix (un par ligne) + + + Choix exact de réussite + + + Afficher uniquement si + + + Également obligatoire si + + + Toujours / sans condition + + + Monter la section + + + Descendre la section + + + Supprimer la section + + + Monter l'élément + + + Descendre l'élément + + + Supprimer l'élément + + + Ajouter un élément + + + Ajouter une section + + + État de réponse + + + Motif de non-applicabilité + + + Note (obligatoire en cas d'échec) + + + Note de clôture / relève + + + Latitude déclarée + + + Longitude déclarée + + + Utiliser la position actuelle + + + Effacer la signature + + + Enregistrer l'image de signature + + + Supprimer + + + Listes de contrôle + + + Modèles de listes + + + Les listes sont gratuites pour tous les services. + + + Adaptez ces modèles à votre équipement, aux instructions du fabricant et aux exigences locales avant utilisation. + + + Trouver un modèle + + + Rechercher + + + Aperçu du modèle + + + Aucun modèle correspondant. + + + Retour + + + Critique + + + Un contrôle échoué exige une note. + + + L'auteur authentifié et un autre membre authentifié doivent vérifier le résultat indépendamment. + + + Maintenance et ordres de travail (Readiness Pro) + + + Réussi / Échoué + + + Oui / Non + + + Case à cocher + + + Relevé numérique + + + Quantité + + + Texte libre + + + Liste de choix + + + Date + + + Photo + + + Signature + + + Début de relève + + + Contrôle d’unité + + + Équipement individuel + + + Revue annuelle + + + Installation + + + Audit de sécurité + + + Contrôle du matériel + + + Autre + + + Service + + + Unité + + + Groupe / caserne + + + Personnel + + + Section {0} + + + Élément {0} + + + Supprimer cette section et ses éléments du brouillon ? + + + Supprimer cet élément du brouillon ? + + + Oui / cochée + + + Non / décochée + + + Élément précédent sans nom + + + Réponse qui active cette condition + + + Facultatif + + + Répondu + + + Sans objet + + + Choisir + + + Réussi + + + Échoué + + + Oui + + + Non + + + Cochée + + + Décochée + + + Plage de réussite : de {0} à {1} + + + Sans minimum + + + Sans maximum + + + Image justificative (PNG/JPEG, 10 Mo maximum ; analyse antivirus requise) + + + Dessinez votre signature ou importez une image de celle-ci. Si un témoin indépendant est requis, il doit se connecter séparément. + + + Zone de dessin de la signature. Vous pouvez également importer une image. + + + Dessinez d’abord une signature. + + + Site / bâtiment / pièce + + + Latitude déclarée (obligatoire) + + + La position est indisponible. Saisissez les coordonnées manuellement. + + + Impossible de déterminer la position. Saisissez les coordonnées manuellement. + + + La progression précédente a été enregistrée ; des modifications restent à enregistrer. + + + Progression enregistrée. + + + La demande n’a pas pu aboutir. Vos modifications sont conservées sur cette page. + + + La demande n’a pas pu aboutir. + + + Les justificatifs ne doivent pas dépasser 10 Mo. + + + En cours + + + En attente d’un témoin + + + Données protégées de la liste de contrôle + + + Listes de contrôle protégées + + + Déverrouiller les données de la liste + + + Vérifiez votre identité pour consulter la liste, les réponses et les justificatifs. + + + Vérifier et ouvrir + + + Utiliser ce modèle + + + Gérer les listes de contrôle + + + Consulter les résultats des listes + + + Créer, modifier, publier et retirer des listes de contrôle gratuites. + + + Consulter les résultats des autres membres. Chaque membre conserve l’accès à son propre historique. + + + Contrôles + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx new file mode 100644 index 000000000..84c2dc289 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Nuova lista + + + Nessuna lista in questa pagina. + + + Nome + + + Versione + + + Stato + + + Ritirata + + + Bozza + + + Pubblicata + + + Precedente + + + Successivo + + + Editor delle liste + + + Salva una bozza e pubblicala. Le esecuzioni esistenti mantengono la versione originale. Non inserire dati dei pazienti. + + + Salva bozza + + + Versione pubblicata + + + Attiva + + + Modifica bozza + + + Pubblica bozza + + + La pubblicazione crea una versione immutabile per le nuove esecuzioni. + + + Ritira + + + Elimina bozza + + + Destinazione + + + Seleziona una destinazione + + + Avvia lista + + + Cronologia + + + Avvio + + + Punteggio + + + Risultato + + + Superato + + + Non superato + + + Incompleto + + + Rispondi a ogni elemento applicabile. Non applicabile richiede un motivo. Salva i progressi per riprendere in seguito. Le risposte e le prove inviate non possono essere modificate. + + + Salva progressi + + + Invia lista + + + Nessun punteggio applicabile + + + Autore + + + Inviata + + + Testimone + + + Elemento + + + Risposta + + + Nota + + + Prova + + + Senza risposta + + + Posizione dichiarata + + + Un altro membro autenticato deve verificare in modo indipendente le risposte e le prove inviate. Condividi il collegamento a questa pagina con tale persona. + + + Dichiarazione di verifica indipendente + + + Attesta e completa + + + Esporta JSON + + + Stampa + + + Nome della lista + + + Istruzioni (senza dati dei pazienti) + + + Categoria + + + Tipo di destinazione + + + Punteggio minimo (%) + + + Richiedi posizione dichiarata + + + Richiedi un altro membro autenticato come testimone dell'invio + + + Nome della sezione + + + Domanda / verifica + + + Istruzioni dell'elemento + + + Tipo di risposta + + + Obbligatorio + + + Un errore critico prevale sul punteggio + + + Consenti non applicabile con motivo + + + Richiedi nota in caso di errore + + + Richiedi foto in caso di errore + + + Peso del punteggio (0 esclude l'elemento) + + + Risposta valida + + + Unità + + + Valore minimo valido (facoltativo se presente il massimo) + + + Valore massimo valido (facoltativo se presente il minimo) + + + Opzioni (una per riga) + + + Opzione esatta valida + + + Mostra solo quando + + + Obbligatorio anche quando + + + Sempre / nessuna condizione + + + Sposta sezione in alto + + + Sposta sezione in basso + + + Rimuovi sezione + + + Sposta elemento in alto + + + Sposta elemento in basso + + + Rimuovi elemento + + + Aggiungi elemento + + + Aggiungi sezione + + + Stato della risposta + + + Motivo di non applicabilità + + + Nota (obbligatoria in caso di errore) + + + Nota di completamento / consegna + + + Latitudine dichiarata + + + Longitudine dichiarata + + + Usa posizione attuale + + + Cancella firma + + + Salva immagine della firma + + + Rimuovi + + + Liste di controllo + + + Modelli di liste + + + Le liste sono gratuite per tutti i reparti. + + + Adatta i modelli alle attrezzature, alle procedure del produttore e ai requisiti locali prima dell'uso. + + + Trova un modello + + + Cerca + + + Anteprima del modello + + + Nessun modello corrispondente. + + + Indietro + + + Critico + + + Una verifica non superata richiede una nota. + + + L'autore autenticato e un altro membro autenticato devono verificare il risultato in modo indipendente. + + + Manutenzione e ordini di lavoro (Readiness Pro) + + + Superato / Non superato + + + Sì / No + + + Casella di controllo + + + Valore misurato + + + Quantità + + + Testo libero + + + Elenco di opzioni + + + Data + + + Foto + + + Firma + + + Inizio turno + + + Controllo dell’unità + + + Equipaggiamento personale + + + Revisione annuale + + + Struttura + + + Verifica della sicurezza + + + Controllo dell’attrezzatura + + + Altro + + + Reparto + + + Unità operativa + + + Gruppo / stazione + + + Personale + + + Sezione {0} + + + Elemento {0} + + + Rimuovere questa sezione e i suoi elementi dalla bozza? + + + Rimuovere questo elemento dalla bozza? + + + Sì / selezionata + + + No / non selezionata + + + Elemento precedente senza nome + + + Risposta che attiva questa condizione + + + Facoltativo + + + Con risposta + + + Non applicabile + + + Seleziona + + + Superato + + + Non superato + + + + + + No + + + Selezionata + + + Non selezionata + + + Intervallo valido: da {0} a {1} + + + Nessun minimo + + + Nessun massimo + + + Immagine di prova (PNG/JPEG, fino a 10 MB; scansione antivirus obbligatoria) + + + Disegna la firma o carica un’immagine della firma. Se è richiesto un testimone indipendente, deve accedere separatamente. + + + Area per disegnare la firma. In alternativa, carica un’immagine. + + + Disegna prima una firma. + + + Sede / edificio / stanza + + + Latitudine dichiarata (obbligatoria) + + + La posizione non è disponibile. Inserisci le coordinate manualmente. + + + Impossibile rilevare la posizione. Inserisci le coordinate manualmente. + + + I progressi precedenti sono stati salvati; restano modifiche non salvate. + + + Progressi salvati. + + + Impossibile completare la richiesta. Le modifiche sono ancora presenti in questa pagina. + + + Impossibile completare la richiesta. + + + Le prove non devono superare 10 MB. + + + In corso + + + In attesa del testimone + + + Dati protetti della lista di controllo + + + Liste di controllo protette + + + Sblocca i dati della lista + + + Verifica la tua identità per visualizzare la lista, le risposte e le prove. + + + Verifica e apri + + + Usa questo modello + + + Gestisci le liste di controllo + + + Visualizza i risultati delle liste + + + Crea, modifica, pubblica e ritira le liste di controllo gratuite. + + + Visualizza i risultati degli altri membri. Ogni membro conserva l’accesso alla propria cronologia. + + + Controlli + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx new file mode 100644 index 000000000..98ebc38f2 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Nowa lista + + + Brak list na tej stronie. + + + Nazwa + + + Wersja + + + Stan + + + Wycofana + + + Wersja robocza + + + Opublikowana + + + Poprzednia + + + Następna + + + Edytor list + + + Zapisz wersję roboczą, a następnie ją opublikuj. Istniejące wykonania zachowują pierwotną wersję. Nie wprowadzaj danych pacjentów. + + + Zapisz wersję roboczą + + + Opublikowana wersja + + + Aktywna + + + Edytuj wersję roboczą + + + Opublikuj wersję roboczą + + + Publikacja tworzy niezmienną wersję dla nowych wykonań. + + + Wycofaj + + + Usuń wersję roboczą + + + Cel + + + Wybierz cel + + + Rozpocznij listę + + + Historia + + + Rozpoczęto + + + Punktacja + + + Wynik + + + Zaliczono + + + Nie zaliczono + + + Niekompletne + + + Odpowiedz na każde mające zastosowanie pytanie. Nie dotyczy wymaga uzasadnienia. Zapisz postęp, aby wrócić później. Przesłanych odpowiedzi i dowodów nie można zmieniać. + + + Zapisz postęp + + + Prześlij listę + + + Brak punktacji + + + Autor + + + Przesłano + + + Świadek + + + Element + + + Odpowiedź + + + Notatka + + + Dowód + + + Bez odpowiedzi + + + Podana lokalizacja + + + Inny uwierzytelniony członek musi niezależnie sprawdzić przesłane odpowiedzi i dowody. Udostępnij mu link do tej strony. + + + Oświadczenie o niezależnej weryfikacji + + + Potwierdź i zakończ + + + Eksportuj JSON + + + Drukuj + + + Nazwa listy + + + Instrukcje (bez danych pacjentów) + + + Kategoria + + + Typ celu + + + Wymagany wynik (%) + + + Wymagaj podanej lokalizacji + + + Wymagaj innego uwierzytelnionego członka jako świadka + + + Nazwa sekcji + + + Pytanie / kontrola + + + Instrukcje elementu + + + Typ odpowiedzi + + + Wymagane + + + Błąd krytyczny ma pierwszeństwo przed punktacją + + + Zezwalaj na odpowiedź „Nie dotyczy” z uzasadnieniem + + + Wymagaj notatki przy błędzie + + + Wymagaj zdjęcia przy błędzie + + + Waga punktacji (0 wyklucza element) + + + Odpowiedź zaliczająca + + + Jednostki + + + Minimalna wartość zaliczająca (opcjonalna przy maksimum) + + + Maksymalna wartość zaliczająca (opcjonalna przy minimum) + + + Opcje (jedna w wierszu) + + + Dokładna opcja zaliczająca + + + Pokazuj tylko gdy + + + Wymagane także gdy + + + Zawsze / bez warunku + + + Przesuń sekcję w górę + + + Przesuń sekcję w dół + + + Usuń sekcję + + + Przesuń element w górę + + + Przesuń element w dół + + + Usuń element + + + Dodaj element + + + Dodaj sekcję + + + Stan odpowiedzi + + + Uzasadnienie nie dotyczy + + + Notatka (wymagana przy błędzie) + + + Notatka końcowa / przekazania + + + Podana szerokość geograficzna + + + Podana długość geograficzna + + + Użyj bieżącej lokalizacji + + + Wyczyść podpis + + + Zapisz obraz podpisu + + + Usuń + + + Listy kontrolne + + + Szablony list + + + Listy są bezpłatne dla wszystkich jednostek. + + + Przed użyciem dostosuj szablony do sprzętu, procedur producenta i lokalnych wymagań. + + + Znajdź szablon + + + Szukaj + + + Podgląd szablonu + + + Brak pasujących szablonów. + + + Wstecz + + + Krytyczne + + + Nieudana kontrola wymaga notatki. + + + Uwierzytelniony autor i inny uwierzytelniony członek muszą niezależnie zweryfikować wynik. + + + Konserwacja i zlecenia pracy (Readiness Pro) + + + Zaliczono / Nie zaliczono + + + Tak / Nie + + + Pole wyboru + + + Odczyt liczbowy + + + Ilość + + + Tekst swobodny + + + Lista wyboru + + + Data + + + Zdjęcie + + + Podpis + + + Początek zmiany + + + Kontrola jednostki + + + Wyposażenie osobiste + + + Przegląd roczny + + + Obiekt + + + Audyt bezpieczeństwa + + + Kontrola sprzętu + + + Inne + + + Jednostka organizacyjna + + + Jednostka + + + Grupa / stacja + + + Personel + + + Sekcja {0} + + + Element {0} + + + Usunąć tę sekcję i jej elementy z wersji roboczej? + + + Usunąć ten element z wersji roboczej? + + + Tak / zaznaczone + + + Nie / niezaznaczone + + + Wcześniejszy element bez nazwy + + + Odpowiedź aktywująca ten warunek + + + Opcjonalne + + + Udzielono odpowiedzi + + + Nie dotyczy + + + Wybierz + + + Zaliczono + + + Nie zaliczono + + + Tak + + + Nie + + + Zaznaczone + + + Niezaznaczone + + + Zakres zaliczający: od {0} do {1} + + + Brak minimum + + + Brak maksimum + + + Zdjęcie dowodowe (PNG/JPEG, do 10 MB; wymagane skanowanie antywirusowe) + + + Narysuj podpis lub prześlij jego obraz. Wymagany niezależny świadek musi zalogować się osobno. + + + Obszar do rysowania podpisu. Możesz też przesłać obraz. + + + Najpierw narysuj podpis. + + + Lokalizacja / budynek / pomieszczenie + + + Podana szerokość geograficzna (wymagana) + + + Lokalizacja jest niedostępna. Wprowadź współrzędne ręcznie. + + + Nie udało się odczytać lokalizacji. Wprowadź współrzędne ręcznie. + + + Zapisano wcześniejszy postęp; pozostały niezapisane zmiany. + + + Postęp zapisany. + + + Nie udało się zrealizować żądania. Twoje zmiany nadal są na tej stronie. + + + Nie udało się zrealizować żądania. + + + Dowód nie może przekraczać 10 MB. + + + W trakcie + + + Oczekiwanie na świadka + + + Chronione dane listy kontrolnej + + + Chronione listy kontrolne + + + Odblokuj dane listy kontrolnej + + + Potwierdź tożsamość, aby zobaczyć listę, odpowiedzi i dowody. + + + Potwierdź i otwórz + + + Użyj tego szablonu + + + Zarządzaj listami kontrolnymi + + + Przeglądaj wyniki list kontrolnych + + + Twórz, edytuj, publikuj i wycofuj bezpłatne listy kontrolne. + + + Przeglądaj wyniki innych członków. Członkowie zachowują dostęp do własnej historii. + + + Kontrole + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx new file mode 100644 index 000000000..d32c76c91 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Checklists + + + Checklist templates + + + Checklists are free for all departments. + + + Adapt these starting points to your equipment, manufacturer procedures and local requirements before use. + + + Find a template + + + Search + + + Preview template + + + No templates match your search. + + + Back + + + Critical + + + Required + + + A failed check requires a note. + + + An authenticated author and a different authenticated member must independently verify the result. + + + Maintenance and Work Orders (Readiness Pro) + + + New checklist + + + No checklists on this page. + + + Name + + + Version + + + State + + + Retired + + + Draft + + + Published + + + Previous + + + Next + + + Checklist editor + + + Save a draft, then publish it. Existing runs keep their original published version. Do not enter patient data. + + + Save draft + + + Published version + + + Active + + + Edit draft + + + Publish draft + + + Publishing creates an immutable version for new runs. + + + Retire + + + Delete draft + + + Target + + + Select a target + + + Start checklist + + + History + + + Started + + + Score + + + Result + + + Passed + + + Failed + + + Incomplete + + + Answer each applicable item. N/A requires a reason. Save progress to resume later. Submitted answers and evidence cannot be changed. + + + Save progress + + + Submit checklist + + + No applicable score + + + Author + + + Submitted + + + Witness + + + Item + + + Answer + + + Note + + + Evidence + + + Unanswered + + + Reported location + + + This run needs a different authenticated member to independently verify the submitted answers and evidence. Share this page link with that member. + + + Independent verification statement + + + Attest and complete + + + Export JSON + + + Print + + + Checklist name + + + Instructions (do not include patient data) + + + Category + + + Target type + + + Passing score (%) + + + Require reported location + + + Require a different authenticated member to witness the submission + + + Section name + + + Question / check + + + Item instructions + + + Answer type + + + Critical failure overrides the score + + + Allow N/A with a reason + + + Require a note on failure + + + Require a photo on failure + + + Score weight (0 excludes this item from the score) + + + Passing answer + + + Units + + + Minimum passing value (optional if maximum is set) + + + Maximum passing value (optional if minimum is set) + + + Choices (one per line) + + + Exact passing choice + + + Show only when + + + Also required when + + + Always / no condition + + + Move section up + + + Move section down + + + Remove section + + + Move item up + + + Move item down + + + Remove item + + + Add item + + + Add section + + + Answer status + + + N/A reason + + + Note (required on failure) + + + Completion / handover note + + + Reported latitude + + + Reported longitude + + + Use current location + + + Clear signature + + + Save signature image + + + Remove + + + In progress + + + Awaiting witness + + + Pass / Fail + + + Yes / No + + + Checkbox + + + Numeric reading + + + Quantity + + + Free text + + + Select list + + + Date + + + Photo + + + Signature + + + Start of shift + + + Unit check + + + Personal gear + + + Annual review + + + Facility + + + Safety audit + + + Equipment check + + + Other + + + Department + + + Unit + + + Group / station + + + Personnel + + + Section {0} + + + Item {0} + + + Remove this section and its items from the draft? + + + Remove this item from the draft? + + + Yes / checked + + + No / unchecked + + + Unnamed earlier item + + + Answer that activates this condition + + + Optional + + + Answered + + + N/A + + + Choose + + + Pass + + + Fail + + + Yes + + + No + + + Checked + + + Unchecked + + + Passing range: {0} to {1} + + + No minimum + + + No maximum + + + Evidence image (PNG/JPEG, up to 10 MB; scanning required) + + + Draw your signature or upload a signature image. A required independent witness must sign in separately. + + + Signature drawing area. Alternatively upload an image. + + + Draw a signature first. + + + Site / building / room + + + Reported latitude (required) + + + Location is unavailable. Enter coordinates manually. + + + Location could not be read. Enter coordinates manually. + + + Earlier progress saved; unsaved changes remain. + + + Progress saved. + + + The request could not be completed. Your changes are still on this page. + + + The request could not be completed. + + + Evidence must be at most 10 MB. + + + Protected checklist data + + + Protected checklists + + + Unlock checklist data + + + Verify your identity to view the checklist, answers and evidence. + + + Verify and open + + + Use this template + + + Manage checklists + + + View checklist results + + + Create, edit, publish and retire free checklists. + + + View results from other members. Members retain their own history. + + + Checks + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx new file mode 100644 index 000000000..b49a5ac5e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Ny checklista + + + Inga checklistor på den här sidan. + + + Namn + + + Version + + + Status + + + Avvecklad + + + Utkast + + + Publicerad + + + Föregående + + + Nästa + + + Redigera checklista + + + Spara ett utkast och publicera det. Befintliga körningar behåller sin ursprungliga version. Ange inga patientuppgifter. + + + Spara utkast + + + Publicerad version + + + Aktiv + + + Redigera utkast + + + Publicera utkast + + + Publicering skapar en oföränderlig version för nya körningar. + + + Avveckla + + + Ta bort utkast + + + Mål + + + Välj ett mål + + + Starta checklista + + + Historik + + + Startad + + + Poäng + + + Resultat + + + Godkänd + + + Underkänd + + + Ofullständig + + + Besvara varje tillämplig punkt. Ej tillämplig kräver en motivering. Spara förloppet för att fortsätta senare. Inskickade svar och underlag kan inte ändras. + + + Spara förlopp + + + Skicka in checklista + + + Ingen tillämplig poäng + + + Utförare + + + Inskickad + + + Vittne + + + Punkt + + + Svar + + + Anteckning + + + Underlag + + + Obesvarad + + + Rapporterad plats + + + En annan inloggad medlem måste självständigt kontrollera inskickade svar och underlag. Dela länken till den här sidan med medlemmen. + + + Intyg om oberoende kontroll + + + Intyga och slutför + + + Exportera JSON + + + Skriv ut + + + Checklistans namn + + + Instruktioner (inga patientuppgifter) + + + Kategori + + + Måltyp + + + Godkänd poäng (%) + + + Kräv rapporterad plats + + + Kräv en annan inloggad medlem som vittne + + + Avsnittets namn + + + Fråga / kontroll + + + Punktens instruktioner + + + Svarstyp + + + Obligatorisk + + + Kritiskt fel åsidosätter poängen + + + Tillåt ej tillämplig med motivering + + + Kräv anteckning vid fel + + + Kräv foto vid fel + + + Poängvikt (0 utesluter punkten) + + + Godkänt svar + + + Enheter + + + Minsta godkända värde (valfritt om maximum finns) + + + Högsta godkända värde (valfritt om minimum finns) + + + Alternativ (ett per rad) + + + Exakt godkänt alternativ + + + Visa endast när + + + Även obligatorisk när + + + Alltid / inget villkor + + + Flytta avsnitt uppåt + + + Flytta avsnitt nedåt + + + Ta bort avsnitt + + + Flytta punkt uppåt + + + Flytta punkt nedåt + + + Ta bort punkt + + + Lägg till punkt + + + Lägg till avsnitt + + + Svarsstatus + + + Motivering för ej tillämplig + + + Anteckning (krävs vid fel) + + + Slut- / överlämningsanteckning + + + Rapporterad latitud + + + Rapporterad longitud + + + Använd aktuell plats + + + Rensa underskrift + + + Spara underskriftsbild + + + Ta bort + + + Checklistor + + + Checklistemallar + + + Checklistor är kostnadsfria för alla avdelningar. + + + Anpassa mallarna till utrustningen, tillverkarens rutiner och lokala krav före användning. + + + Hitta en mall + + + Sök + + + Förhandsgranska mall + + + Inga matchande mallar. + + + Tillbaka + + + Kritisk + + + En underkänd kontroll kräver en anteckning. + + + Den inloggade utföraren och en annan inloggad medlem måste självständigt verifiera resultatet. + + + Underhåll och arbetsorder (Readiness Pro) + + + Godkänd / Underkänd + + + Ja / Nej + + + Kryssruta + + + Mätvärde + + + Antal + + + Fritext + + + Vallista + + + Datum + + + Foto + + + Underskrift + + + Skiftstart + + + Enhetskontroll + + + Personlig utrustning + + + Årlig översyn + + + Anläggning + + + Säkerhetsgranskning + + + Utrustningskontroll + + + Övrigt + + + Avdelning + + + Enhet + + + Grupp / station + + + Personal + + + Avsnitt {0} + + + Punkt {0} + + + Ta bort det här avsnittet och dess punkter från utkastet? + + + Ta bort den här punkten från utkastet? + + + Ja / markerad + + + Nej / avmarkerad + + + Tidigare punkt utan namn + + + Svar som aktiverar det här villkoret + + + Valfri + + + Besvarad + + + Ej tillämplig + + + Välj + + + Godkänd + + + Underkänd + + + Ja + + + Nej + + + Markerad + + + Avmarkerad + + + Godkänt intervall: {0} till {1} + + + Ingen nedre gräns + + + Ingen övre gräns + + + Underlagsbild (PNG/JPEG, högst 10 MB; virusskanning krävs) + + + Rita din underskrift eller ladda upp en bild av den. Om ett oberoende vittne krävs måste vittnet logga in separat. + + + Rityta för underskrift. Du kan även ladda upp en bild. + + + Rita en underskrift först. + + + Plats / byggnad / rum + + + Rapporterad latitud (obligatorisk) + + + Platsen är inte tillgänglig. Ange koordinaterna manuellt. + + + Det gick inte att läsa platsen. Ange koordinaterna manuellt. + + + Tidigare förlopp har sparats; osparade ändringar återstår. + + + Förloppet har sparats. + + + Begäran kunde inte slutföras. Dina ändringar finns kvar på den här sidan. + + + Begäran kunde inte slutföras. + + + Underlaget får vara högst 10 MB. + + + Pågår + + + Inväntar vittne + + + Skyddade checklistedata + + + Skyddade checklistor + + + Lås upp checklistedata + + + Verifiera din identitet för att visa checklistan, svaren och underlagen. + + + Verifiera och öppna + + + Använd den här mallen + + + Hantera checklistor + + + Visa checklistornas resultat + + + Skapa, redigera, publicera och avveckla kostnadsfria checklistor. + + + Visa andra medlemmars resultat. Medlemmarna behåller åtkomsten till sin egen historik. + + + Kontroller + + diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx new file mode 100644 index 000000000..fe0348737 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx @@ -0,0 +1,537 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Новий список + + + На цій сторінці немає списків. + + + Назва + + + Версія + + + Стан + + + Виведено з використання + + + Чернетка + + + Опубліковано + + + Попередня + + + Наступна + + + Редактор списків + + + Збережіть чернетку, а потім опублікуйте її. Наявні виконання зберігають початкову версію. Не вводьте дані пацієнтів. + + + Зберегти чернетку + + + Опублікована версія + + + Активний + + + Редагувати чернетку + + + Опублікувати чернетку + + + Публікація створює незмінну версію для нових виконань. + + + Вивести з використання + + + Видалити чернетку + + + Ціль + + + Виберіть ціль + + + Розпочати список + + + Історія + + + Розпочато + + + Оцінка + + + Результат + + + Пройдено + + + Не пройдено + + + Незавершено + + + Дайте відповіді на всі застосовні пункти. Для відповіді «Не застосовується» потрібне пояснення. Збережіть хід виконання, щоб продовжити пізніше. Подані відповіді й підтвердження не можна змінювати. + + + Зберегти хід виконання + + + Подати список + + + Немає застосовної оцінки + + + Автор + + + Подано + + + Свідок + + + Пункт + + + Відповідь + + + Примітка + + + Доказ + + + Без відповіді + + + Заявлене місцезнаходження + + + Інший автентифікований учасник має незалежно перевірити подані відповіді й докази. Поділіться з ним посиланням на цю сторінку. + + + Заява про незалежну перевірку + + + Підтвердити та завершити + + + Експортувати JSON + + + Друк + + + Назва списку + + + Інструкції (без даних пацієнтів) + + + Категорія + + + Тип цілі + + + Прохідна оцінка (%) + + + Вимагати заявлене місцезнаходження + + + Вимагати іншого автентифікованого учасника як свідка + + + Назва розділу + + + Запитання / перевірка + + + Інструкції пункту + + + Тип відповіді + + + Обов'язково + + + Критична помилка переважає оцінку + + + Дозволити відповідь «Не застосовується» з поясненням + + + Вимагати примітку при помилці + + + Вимагати фото при помилці + + + Вага оцінки (0 виключає пункт) + + + Прохідна відповідь + + + Одиниці + + + Мінімальне прохідне значення (необов'язкове за наявності максимуму) + + + Максимальне прохідне значення (необов'язкове за наявності мінімуму) + + + Варіанти (по одному в рядку) + + + Точний прохідний варіант + + + Показувати лише коли + + + Також обов'язково коли + + + Завжди / без умови + + + Перемістити розділ угору + + + Перемістити розділ униз + + + Видалити розділ + + + Перемістити пункт угору + + + Перемістити пункт униз + + + Видалити пункт + + + Додати пункт + + + Додати розділ + + + Стан відповіді + + + Пояснення незастосовності + + + Примітка (обов'язкова при помилці) + + + Примітка завершення / передачі + + + Заявлена широта + + + Заявлена довгота + + + Використати поточне місцезнаходження + + + Очистити підпис + + + Зберегти зображення підпису + + + Видалити + + + Контрольні списки + + + Шаблони списків + + + Списки безкоштовні для всіх підрозділів. + + + Перед використанням адаптуйте шаблони до обладнання, процедур виробника та місцевих вимог. + + + Знайти шаблон + + + Пошук + + + Перегляд шаблону + + + Відповідних шаблонів не знайдено. + + + Назад + + + Критичний + + + Невдала перевірка вимагає примітки. + + + Автентифікований автор та інший автентифікований учасник мають незалежно перевірити результат. + + + Технічне обслуговування та наряди на роботи (Readiness Pro) + + + Пройдено / Не пройдено + + + Так / Ні + + + Прапорець + + + Числовий показник + + + Кількість + + + Довільний текст + + + Список вибору + + + Дата + + + Фото + + + Підпис + + + Початок зміни + + + Перевірка одиниці техніки + + + Особисте спорядження + + + Щорічний огляд + + + Об’єкт + + + Аудит безпеки + + + Перевірка обладнання + + + Інше + + + Підрозділ + + + Одиниця техніки + + + Група / станція + + + Персонал + + + Розділ {0} + + + Пункт {0} + + + Видалити цей розділ і його пункти з чернетки? + + + Видалити цей пункт із чернетки? + + + Так / позначено + + + Ні / не позначено + + + Попередній пункт без назви + + + Відповідь, яка активує цю умову + + + Необов’язково + + + Надано відповідь + + + Не застосовується + + + Виберіть + + + Пройдено + + + Не пройдено + + + Так + + + Ні + + + Позначено + + + Не позначено + + + Допустимий діапазон: від {0} до {1} + + + Без мінімуму + + + Без максимуму + + + Зображення для підтвердження (PNG/JPEG, до 10 МБ; потрібна перевірка на шкідливе ПЗ) + + + Намалюйте підпис або завантажте його зображення. Якщо потрібен незалежний свідок, він має увійти окремо. + + + Область для малювання підпису. Також можна завантажити зображення. + + + Спочатку намалюйте підпис. + + + Місце / будівля / приміщення + + + Заявлена широта (обов’язково) + + + Місцезнаходження недоступне. Введіть координати вручну. + + + Не вдалося визначити місцезнаходження. Введіть координати вручну. + + + Попередній хід виконання збережено; залишилися незбережені зміни. + + + Хід виконання збережено. + + + Не вдалося виконати запит. Ваші зміни залишилися на цій сторінці. + + + Не вдалося виконати запит. + + + Розмір файлу підтвердження не має перевищувати 10 МБ. + + + Триває + + + Очікує на свідка + + + Захищені дані контрольного списку + + + Захищені контрольні списки + + + Розблокувати дані контрольного списку + + + Підтвердьте свою особу, щоб переглянути список, відповіді та підтвердження. + + + Підтвердити й відкрити + + + Використати цей шаблон + + + Керувати контрольними списками + + + Переглядати результати контрольних списків + + + Створювати, редагувати, публікувати та виводити з використання безкоштовні контрольні списки. + + + Переглядати результати інших учасників. Учасники зберігають доступ до власної історії. + + + Перевірки + + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 737d29a35..31dbf409e 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -203,6 +203,17 @@ public enum AuditLogTypes ContactPreplanUpdated, ContactPreplanRemoved, ContactAttachmentAdded, - ContactAttachmentRemoved + ContactAttachmentRemoved, + ChecklistDefinitionAdded, + ChecklistDefinitionUpdated, + ChecklistDefinitionPublished, + ChecklistDefinitionRetired, + ChecklistDefinitionRemoved, + ChecklistCompletionStarted, + ChecklistProgressSaved, + ChecklistCompletionSubmitted, + ChecklistWitnessAttested, + ChecklistFileAdded, + ChecklistFileRemoved } } diff --git a/Core/Resgrid.Model/Checklists/ChecklistContracts.cs b/Core/Resgrid.Model/Checklists/ChecklistContracts.cs new file mode 100644 index 000000000..04c9f00db --- /dev/null +++ b/Core/Resgrid.Model/Checklists/ChecklistContracts.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.Checklists +{ + public enum ChecklistRunState { InProgress = 0, AwaitingWitness = 1, Submitted = 2 } + public enum ChecklistAnswerStatus { Unanswered = 0, Answered = 1, NotApplicable = 2 } + + public sealed class ChecklistForm + { + public string Name { get; set; } + public string Instructions { get; set; } + public ChecklistCategory Category { get; set; } + public ChecklistTargetType TargetType { get; set; } + public decimal PassThreshold { get; set; } = 100; + public bool RequireLocation { get; set; } + public bool RequiresIndependentWitness { get; set; } + public List Sections { get; set; } = new List(); + public static ChecklistForm FromTemplate(ChecklistTemplate template) + { + var form = new ChecklistForm + { + Name = template.Name, Instructions = template.Description, Category = template.SuggestedCategory, + TargetType = template.SuggestedTargetType == ChecklistTargetType.InventoryAsset ? ChecklistTargetType.Department : template.SuggestedTargetType, + RequiresIndependentWitness = template.RequiresIndependentWitness, + Sections = template.Sections.Select(s => new ChecklistSection { Name = s.Name, + Items = s.Items.Select(i => new ChecklistItem { Name = i.Name, Type = i.Type, Required = i.Required, + Critical = i.Critical, AllowNotApplicable = i.AllowNotApplicable, RequireNoteOnFail = i.RequireNoteOnFail, Weight = i.Type == ChecklistItemType.FreeText && !i.Required ? 0 : 1 }).ToList() }).ToList() + }; + // Linked inventory assets belong to P1-M2. On-demand equipment checks remain usable now, + // attributed to the responsible department/unit/group/person and an explicit equipment ID. + if (template.SuggestedTargetType == ChecklistTargetType.InventoryAsset) + form.Sections[0].Items.Insert(0, new ChecklistItem { Name = "Equipment identifier", Instructions = "Record the equipment label or serial number.", Type = ChecklistItemType.FreeText, Required = true, Weight = 0, RequireNoteOnFail = false }); + return form; + } + } + public sealed class ChecklistSection + { + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Name { get; set; } + public List Items { get; set; } = new List(); + } + public sealed class ChecklistCondition + { + /// Only earlier items can be referenced; the evaluator never executes expressions. + public string ItemId { get; set; } + public string EqualsValue { get; set; } + } + public sealed class ChecklistItem + { + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Name { get; set; } + public string Instructions { get; set; } + public ChecklistItemType Type { get; set; } + public bool Required { get; set; } = true; + public bool Critical { get; set; } + public bool AllowNotApplicable { get; set; } + public bool RequireNoteOnFail { get; set; } = true; + public bool RequirePhotoOnFail { get; set; } + public decimal Weight { get; set; } = 1; + public string Units { get; set; } + public decimal? Minimum { get; set; } + public decimal? Maximum { get; set; } + /// YesNo, Checkbox and SelectList use this explicit passing value. + public string PassingValue { get; set; } = "true"; + public List Options { get; set; } = new List(); + public ChecklistCondition VisibleWhen { get; set; } + public ChecklistCondition RequiredWhen { get; set; } + } + public sealed class ChecklistAnswer + { + public string ItemId { get; set; } + public ChecklistAnswerStatus Status { get; set; } + public string Value { get; set; } + public string Note { get; set; } + public string NotApplicableReason { get; set; } + } + public sealed class ChecklistRunInput + { + public int Revision { get; set; } + public List Answers { get; set; } = new List(); + public string Note { get; set; } + public string LocationDescription { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public DateTime? ClientCompletedOn { get; set; } + } + public sealed class ChecklistEvaluation + { + public decimal? Score { get; set; } + public bool Passed { get; set; } + public List FailedItemIds { get; set; } = new List(); + public List Errors { get; set; } = new List(); + } + public sealed class ChecklistTarget { public ChecklistTargetType Type { get; set; } public string Id { get; set; } public string Name { get; set; } public int? GroupId { get; set; } } + public sealed class ChecklistActor { public int DepartmentId { get; set; } public string UserId { get; set; } public string GrantToken { get; set; } } + public sealed class ChecklistDefinitionView { public ChecklistDefinition Definition { get; set; } public ChecklistForm Form { get; set; } public ChecklistForm PublishedForm { get; set; } } + public sealed class ChecklistHistoryEntry { public ChecklistCompletion Completion { get; set; } public string TargetName { get; set; } } + public sealed class ChecklistRunView + { + public int VersionNumber { get; set; } + public ChecklistCompletion Completion { get; set; } + public ChecklistForm Form { get; set; } + public ChecklistTarget Target { get; set; } + public ChecklistRunInput Input { get; set; } + public List Files { get; set; } = new List(); + } + public sealed class ChecklistException : Exception + { + public int StatusCode { get; } + public ChecklistException(int statusCode, string message) : base(message) { StatusCode = statusCode; } + } +} diff --git a/Core/Resgrid.Model/Checklists/ChecklistEntities.cs b/Core/Resgrid.Model/Checklists/ChecklistEntities.cs new file mode 100644 index 000000000..1ed8b1c7c --- /dev/null +++ b/Core/Resgrid.Model/Checklists/ChecklistEntities.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Checklists +{ + /// All content is a cataloged ADP slot. IDs and lifecycle metadata remain queryable. + public abstract class ChecklistRow : IEntity + { + [System.ComponentModel.DataAnnotations.Schema.NotMapped, Newtonsoft.Json.JsonIgnore] + public object IdValue { get => Id; set => Id = (string)value; } + [System.ComponentModel.DataAnnotations.Schema.NotMapped, Newtonsoft.Json.JsonIgnore] + public string TableName => ChecklistTables.All[GetType()]; + [System.ComponentModel.DataAnnotations.Schema.NotMapped, Newtonsoft.Json.JsonIgnore] + public string IdName => "Id"; + [System.ComponentModel.DataAnnotations.Schema.NotMapped, Newtonsoft.Json.JsonIgnore] + public int IdType => 1; + [System.ComponentModel.DataAnnotations.Schema.NotMapped, Newtonsoft.Json.JsonIgnore] + public IEnumerable IgnoredProperties => new[] { "IdValue", "TableName", "IdName", "IdType", "IgnoredProperties" }; + public string Id { get; set; } = Guid.NewGuid().ToString(); + public int DepartmentId { get; set; } + public string ParentId { 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 ChecklistDefinition : ChecklistRow + { + public string CurrentVersionId { get; set; } + public int PublishedVersion { get; set; } + public bool Retired { get; set; } + public DateTime? DeletedOn { get; set; } + } + public sealed class ChecklistDefinitionVersion : ChecklistRow { public int Version { get; set; } } + public sealed class ChecklistOccurrence : ChecklistRow + { + public string VersionId { get; set; } + public string CompletionId { get; set; } + public int TargetType { get; set; } + public string TargetId { get; set; } + public int State { get; set; } + } + public sealed class ChecklistCompletion : ChecklistRow + { + public string VersionId { get; set; } + public string OccurrenceId { get; set; } + public int TargetType { get; set; } + public string TargetId { get; set; } + public int State { get; set; } + public DateTime? SubmittedOn { get; set; } + public int? TargetGroupId { get; set; } + public string WitnessUserId { get; set; } + public DateTime? WitnessedOn { get; set; } + public decimal? Score { get; set; } + public bool Passed { get; set; } + public string SubmissionHash { get; set; } + } + public sealed class ChecklistCompletionItem : ChecklistRow { public string ItemId { get; set; } public bool IsFailure { get; set; } } + public sealed class ChecklistCompletionFile : ChecklistRow + { + public string ItemId { get; set; } + 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 sealed class DepartmentChecklistSettings : ChecklistRow { } + public static class ChecklistTables + { + public static readonly IReadOnlyDictionary All = new Dictionary + { + [typeof(ChecklistDefinition)] = "ChecklistDefinitions", [typeof(ChecklistDefinitionVersion)] = "ChecklistDefinitionVersions", + [typeof(ChecklistOccurrence)] = "ChecklistOccurrences", [typeof(ChecklistCompletion)] = "ChecklistCompletions", + [typeof(ChecklistCompletionItem)] = "ChecklistCompletionItems", [typeof(ChecklistCompletionFile)] = "ChecklistCompletionFiles", + [typeof(DepartmentChecklistSettings)] = "DepartmentChecklistSettings" + }; + public static IReadOnlyDictionary Get, Action Set)> Fields() where T : ChecklistRow => + new Dictionary, Action)> { [All[typeof(T)].ToLowerInvariant() + ".content"] = (x => x.Content, (x, v) => x.Content = v) }; + } +} diff --git a/Core/Resgrid.Model/Checklists/ChecklistEnums.cs b/Core/Resgrid.Model/Checklists/ChecklistEnums.cs new file mode 100644 index 000000000..85784767f --- /dev/null +++ b/Core/Resgrid.Model/Checklists/ChecklistEnums.cs @@ -0,0 +1,27 @@ +namespace Resgrid.Model.Checklists +{ + public enum ChecklistCategory + { + StartOfShift = 0, UnitCheck = 1, PersonalGear = 2, AnnualReview = 3, + Facility = 4, SafetyAudit = 5, EquipmentCheck = 6, Other = 7 + } + + public enum ChecklistItemType + { + PassFail = 0, YesNo = 1, Checkbox = 2, NumericReading = 3, Quantity = 4, + FreeText = 5, SelectList = 6, DateValue = 7, Photo = 8, Signature = 9 + } + + public enum ChecklistTargetType + { + Department = 0, Unit = 1, Group = 2, Personnel = 3, + // 4 is reserved for non-inventory equipment in the design contract. + InventoryAsset = 5 + } + + public enum ChecklistScheduleFrequency + { + OnDemand = 0, PerShift = 1, Daily = 2, Weekly = 3, Monthly = 4, + Quarterly = 5, SemiAnnual = 6, Annual = 7 + } +} diff --git a/Core/Resgrid.Model/Checklists/ChecklistPermissionCatalog.cs b/Core/Resgrid.Model/Checklists/ChecklistPermissionCatalog.cs new file mode 100644 index 000000000..de8a77b3c --- /dev/null +++ b/Core/Resgrid.Model/Checklists/ChecklistPermissionCatalog.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + public static class ChecklistPermissionCatalog + { + public static readonly IReadOnlyList All = new[] + { + new RecordPermissionDescriptor(PermissionTypes.ManageChecklists, PermissionActions.DepartmentAdminsOnly, false, "Create, edit, publish and retire checklists", false), + new RecordPermissionDescriptor(PermissionTypes.ViewChecklistResults, PermissionActions.DepartmentAndGroupAdmins, true, "View other members' checklist results") + }; + } +} diff --git a/Core/Resgrid.Model/Checklists/ChecklistTemplate.cs b/Core/Resgrid.Model/Checklists/ChecklistTemplate.cs new file mode 100644 index 000000000..5d6017141 --- /dev/null +++ b/Core/Resgrid.Model/Checklists/ChecklistTemplate.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model.Checklists +{ + /// Immutable starter content. Applying a template must create new department-owned IDs. + public sealed class ChecklistTemplate + { + public string Id { get; } + public string Name { get; } + public string Sector { get; } + public string Description { get; } + public ChecklistCategory SuggestedCategory { get; } + public ChecklistScheduleFrequency SuggestedFrequency { get; } + public ChecklistTargetType SuggestedTargetType { get; } + public bool RequiresIndependentWitness { get; } + public IReadOnlyList Sections { get; } + public IReadOnlyList Keywords { get; } + + [JsonIgnore] + public string SearchText => string.Join(" ", new[] { Name, Sector, Description } + .Concat(Keywords).Concat(Sections.SelectMany(s => s.Items).Select(i => i.Name))).ToLowerInvariant(); + + public ChecklistTemplate(string id, string name, string sector, string description, + ChecklistCategory category, ChecklistScheduleFrequency frequency, ChecklistTargetType target, + bool requiresIndependentWitness, IEnumerable keywords, IEnumerable sections) + { + Id = id; + Name = name; + Sector = sector; + Description = description; + SuggestedCategory = category; + SuggestedFrequency = frequency; + SuggestedTargetType = target; + RequiresIndependentWitness = requiresIndependentWitness; + Keywords = keywords.ToList().AsReadOnly(); + Sections = sections.ToList().AsReadOnly(); + } + } + + public sealed class ChecklistTemplateSection + { + public string SectionId { get; } + public string Name { get; } + public IReadOnlyList Items { get; } + + public ChecklistTemplateSection(string sectionId, string name, IEnumerable items) + { + SectionId = sectionId; + Name = name; + Items = items.ToList().AsReadOnly(); + } + } + + public sealed class ChecklistTemplateItem + { + public string ItemId { get; } + public string Name { get; } + public ChecklistItemType Type { get; } + public bool Required { get; } + public bool Critical { get; } + public bool AllowNotApplicable { get; } + public bool RequireNoteOnFail { get; } + + public ChecklistTemplateItem(string itemId, string name, ChecklistItemType type, bool required, + bool critical, bool allowNotApplicable, bool requireNoteOnFail) + { + ItemId = itemId; + Name = name; + Type = type; + Required = required; + Critical = critical; + AllowNotApplicable = allowNotApplicable; + RequireNoteOnFail = requireNoteOnFail; + } + } +} diff --git a/Core/Resgrid.Model/Checklists/ChecklistTemplateCatalog.cs b/Core/Resgrid.Model/Checklists/ChecklistTemplateCatalog.cs new file mode 100644 index 000000000..77dadd098 --- /dev/null +++ b/Core/Resgrid.Model/Checklists/ChecklistTemplateCatalog.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace Resgrid.Model.Checklists +{ + public static class ChecklistTemplateCatalog + { + public const string Guidance = "Adapt these starting points to your equipment, manufacturer procedures and local requirements before use."; + public static IReadOnlyList All { get; } = BuildAll().AsReadOnly(); + + public static ChecklistTemplate GetById(string id) => All.FirstOrDefault(t => + string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase)); + + public static IReadOnlyList Search(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return All; + var terms = query.ToLowerInvariant().Split(new[] { ' ', ',', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + return All.Where(t => terms.All(term => t.SearchText.Contains(term))).ToList().AsReadOnly(); + } + + public static IReadOnlyList GetByCategory(ChecklistCategory category) => + All.Where(t => t.SuggestedCategory == category).ToList().AsReadOnly(); + + private static (string Key, string Name, bool Critical) I(string key, string name) => (key, name, false); + private static (string Key, string Name, bool Critical) C(string key, string name) => (key, name, true); + + // Stable catalog identities survive reordering. These are identifiers, not security tokens. + private static string Id(string key) => new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("resgrid:checklist:" + key)).Take(16).ToArray()).ToString(); + + private static ChecklistTemplate T(string id, string name, string sector, string description, + ChecklistCategory category, ChecklistScheduleFrequency frequency, ChecklistTargetType target, + params (string Key, string Name, bool Critical)[] items) + { + var checks = items.Select(i => new ChecklistTemplateItem(Id(id + ":" + i.Key), i.Name, + ChecklistItemType.PassFail, true, i.Critical, !i.Critical, true)); + return new ChecklistTemplate(id, name, sector, description, category, frequency, target, + id == "ems-controlled-count", new[] { category.ToString(), target.ToString() }, new[] + { + new ChecklistTemplateSection(Id(id + ":inspection"), "Readiness checks", checks), + new ChecklistTemplateSection(Id(id + ":handover"), "Findings and handover", new[] + { + new ChecklistTemplateItem(Id(id + ":notes"), "Findings, restrictions and handover notes", + ChecklistItemType.FreeText, false, false, false, false) + }) + }); + } + + private static List BuildAll() => new List + { + T("fire-apparatus-daily", "Apparatus Daily Check", "Fire", "Start-of-shift readiness for an engine or truck.", + ChecklistCategory.UnitCheck, ChecklistScheduleFrequency.Daily, ChecklistTargetType.Unit, + C("brakes", "Braking and steering checks meet the approved procedure"), C("tires", "Tires, wheels and visible leaks checked"), + I("warning", "Warning lights and audible devices checked"), I("communications", "Radios and communication equipment checked"), + C("equipment", "Required equipment secured and ready"), I("fluids", "Fuel and fluid levels checked")), + T("fire-apparatus-weekly", "Apparatus Weekly Check", "Fire", "Expanded checks alongside the daily apparatus procedure.", + ChecklistCategory.UnitCheck, ChecklistScheduleFrequency.Weekly, ChecklistTargetType.Unit, + C("pump", "Pump and related systems checked using the approved procedure"), C("ladders", "Ladders and mounting restraints inspected"), + I("tools", "Powered tools and auxiliary equipment checked"), I("battery", "Battery and charging systems checked"), + I("documents", "Service dates and unresolved defects reviewed")), + T("fire-scba", "SCBA Inspection", "Fire", "Equipment-specific inspection using manufacturer instructions.", + ChecklistCategory.EquipmentCheck, ChecklistScheduleFrequency.PerShift, ChecklistTargetType.InventoryAsset, + C("cylinder", "Cylinder condition, pressure and service dates checked"), C("facepiece", "Facepiece and harness inspected"), + C("regulator", "Regulator and connections checked"), C("alarms", "Alarms checked using the approved procedure"), + I("clean", "Cleaning and storage condition checked")), + T("fire-ppe", "PPE Routine Inspection", "Fire", "Personal protective equipment condition and retirement review.", + ChecklistCategory.PersonalGear, ChecklistScheduleFrequency.PerShift, ChecklistTargetType.Personnel, + C("damage", "Protective ensemble inspected for damage"), C("contamination", "Contamination and cleaning status checked"), + I("closures", "Closures, seams and accessories inspected"), I("dates", "Inspection and retirement dates reviewed")), + T("wildland-engine", "Wildland Engine Pre-Use", "Wildland / Contractors", "Agency deployment readiness; adapt to the agency's current check-in requirements.", + ChecklistCategory.UnitCheck, ChecklistScheduleFrequency.OnDemand, ChecklistTargetType.Unit, + C("vehicle", "Vehicle pre-use inspection completed"), C("pump", "Pump, hose and water systems checked"), + I("equipment", "Required tools and equipment inventoried"), I("communications", "Incident communications checked"), + I("documents", "Assignment and equipment documentation ready")), + T("wildland-tender", "Water Tender Pre-Use", "Wildland / Contractors", "Water tender deployment and delivery readiness.", + ChecklistCategory.UnitCheck, ChecklistScheduleFrequency.OnDemand, ChecklistTargetType.Unit, + C("vehicle", "Vehicle and load safety checked"), C("tank", "Tank, valves and discharge systems inspected"), + I("fill", "Fill fittings and adapters present"), I("communications", "Communications and deployment documents ready")), + T("ems-ambulance", "Ambulance Daily Rig Check", "EMS", "Vehicle, patient compartment and response equipment readiness; no patient data.", + ChecklistCategory.UnitCheck, ChecklistScheduleFrequency.Daily, ChecklistTargetType.Unit, + C("vehicle", "Vehicle pre-use inspection completed"), C("restraints", "Stretcher, mounts and restraints checked"), + C("devices", "Required clinical devices checked to manufacturer instructions"), I("stock", "Supplies and expiry dates checked"), + I("clean", "Cleaning and infection-control supplies checked")), + T("ems-jump-bag", "EMS Jump Bag Check", "EMS", "Compare contents to the department-approved stock list and par levels.", + ChecklistCategory.EquipmentCheck, ChecklistScheduleFrequency.PerShift, ChecklistTargetType.InventoryAsset, + I("seal", "Bag, seals and inventory identity checked"), C("stock", "Required stock meets local par levels"), + C("expiry", "Expiry dates and packaging checked"), I("restock", "Shortages recorded and reported")), + T("ems-controlled-count", "Controlled Supply Count Review", "EMS", "Requires two independently authenticated witnesses and the department's controlled-substance protocol before execution.", + ChecklistCategory.SafetyAudit, ChecklistScheduleFrequency.PerShift, ChecklistTargetType.Unit, + C("security", "Storage security and seals checked"), C("count", "Count reconciled against the authorized ledger"), + C("discrepancy", "Discrepancies escalated under the approved protocol"), I("expiry", "Expiry and storage conditions checked")), + T("ems-aed", "AED Readiness Check", "EMS", "External readiness check; follow the device-specific instructions and interval.", + ChecklistCategory.EquipmentCheck, ChecklistScheduleFrequency.Weekly, ChecklistTargetType.InventoryAsset, + C("indicator", "Device readiness indicator checked"), C("consumables", "Pads, battery and expiry dates checked"), + I("access", "Device location, access and accessories checked"), I("service", "Outstanding service notices reviewed")), + T("sar-rope-cache", "SAR Rope and Equipment Cache", "SAR", "Cache identity, condition and service-life review.", + ChecklistCategory.EquipmentCheck, ChecklistScheduleFrequency.OnDemand, ChecklistTargetType.Group, + C("rope", "Ropes and textiles inspected under the approved procedure"), C("hardware", "Hardware condition and function inspected"), + I("inventory", "Inventory and equipment identifiers reconciled"), C("retirement", "Quarantine and retirement limits reviewed")), + T("sar-personal-pack", "Personal 24-Hour Pack", "SAR", "Adapt pack contents to the mission, season and local requirements.", + ChecklistCategory.PersonalGear, ChecklistScheduleFrequency.OnDemand, ChecklistTargetType.Personnel, + I("supplies", "Mission supplies and personal provisions checked"), C("communications", "Communications and navigation equipment checked"), + I("environment", "Clothing and shelter appropriate to conditions"), I("lighting", "Lighting and spare power checked")), + T("post-deployment", "Post-Deployment Rehabilitation", "Emergency Management", "Equipment return, decontamination, replenishment and defect handover.", + ChecklistCategory.EquipmentCheck, ChecklistScheduleFrequency.OnDemand, ChecklistTargetType.Unit, + I("inventory", "Returned and missing equipment reconciled"), C("contamination", "Contaminated or damaged equipment isolated"), + I("replenish", "Consumables and power replenished"), C("release", "Restrictions and required inspections handed over")), + T("station-daily", "Station Daily Walkthrough", "Facilities", "Station access, housekeeping and shared facilities.", + ChecklistCategory.Facility, ChecklistScheduleFrequency.Daily, ChecklistTargetType.Group, + C("exits", "Emergency exits and access routes clear"), I("housekeeping", "Work and living areas checked"), + I("utilities", "Utilities and visible leaks checked"), I("security", "Security and shared equipment checked")), + T("station-monthly", "Facility Monthly Safety Review", "Facilities", "Site safety systems and overdue service review.", + ChecklistCategory.SafetyAudit, ChecklistScheduleFrequency.Monthly, ChecklistTargetType.Group, + C("egress", "Egress routes and emergency lighting checked"), I("fire", "Fire protection equipment service status reviewed"), + I("electrical", "Visible electrical and storage hazards checked"), I("actions", "Prior findings and corrective actions reviewed")), + T("personnel-shift", "Start-of-Shift Readiness", "Personnel", "Role, communications, assigned equipment and handover.", + ChecklistCategory.StartOfShift, ChecklistScheduleFrequency.PerShift, ChecklistTargetType.Personnel, + I("assignment", "Assignment and role acknowledged"), I("handover", "Previous shift handover reviewed"), + C("equipment", "Required personal equipment ready"), I("communications", "Communications checked")), + T("personnel-annual", "Annual Member Readiness Review", "Personnel", "Review qualification and equipment records in their systems of record.", + ChecklistCategory.AnnualReview, ChecklistScheduleFrequency.Annual, ChecklistTargetType.Personnel, + I("qualification", "Role qualifications and training status reviewed"), I("equipment", "Issued equipment reconciled"), + I("contact", "Contact and emergency information reviewed"), I("actions", "Required follow-up assigned")), + T("equipment-generator", "Pump / Generator Weekly Check", "Equipment", "Use the manufacturer's safe test procedure and recording limits.", + ChecklistCategory.EquipmentCheck, ChecklistScheduleFrequency.Weekly, ChecklistTargetType.InventoryAsset, + C("condition", "Damage, leaks and guards inspected"), C("operation", "Approved operational check completed"), + I("readings", "Required run-hour and performance readings recorded in the equipment log"), I("service", "Service interval and fuel status checked")), + T("equipment-chainsaw", "Chainsaw Monthly Inspection", "Equipment", "Inspection and service readiness using manufacturer procedures.", + ChecklistCategory.EquipmentCheck, ChecklistScheduleFrequency.Monthly, ChecklistTargetType.InventoryAsset, + C("safety", "Safety devices and guards checked"), C("cutting", "Cutting assembly condition inspected"), + I("fluids", "Fuel, lubrication and leaks checked"), I("storage", "Storage and service dates reviewed")), + T("industrial-safety", "Workplace Safety Walkthrough", "Industry / Business", "Adapt hazards and inspection scope to the workplace.", + ChecklistCategory.SafetyAudit, ChecklistScheduleFrequency.Weekly, ChecklistTargetType.Group, + C("egress", "Emergency access and exits clear"), C("guards", "Required machine guards and barriers in place"), + I("storage", "Materials and chemical storage inspected"), I("housekeeping", "Trip and housekeeping hazards checked"), + I("actions", "Worker-reported hazards and previous findings reviewed")), + T("equipment-extinguisher", "Fire Extinguisher Visual Check", "Facilities", "Visual readiness and service-label review; adapt the interval to local requirements.", + ChecklistCategory.EquipmentCheck, ChecklistScheduleFrequency.Monthly, ChecklistTargetType.InventoryAsset, + C("access", "Extinguisher accessible in its designated location"), C("condition", "Visible condition and readiness indicator checked"), + I("seal", "Seal and instructions inspected"), I("service", "Inspection and service dates reviewed")), + T("industrial-forklift", "Forklift Pre-Operation", "Industry", "Pre-use inspection; use each shift for continuous operations and follow the approved procedure.", + ChecklistCategory.EquipmentCheck, ChecklistScheduleFrequency.PerShift, ChecklistTargetType.InventoryAsset, + C("vehicle", "Tires, forks, mast and visible damage inspected"), C("leaks", "Fluid leaks and energy source condition checked"), + C("controls", "Brakes, steering and safety controls checked"), C("defects", "Unsafe defects reported and equipment withheld from use")), + T("business-vehicle", "Vehicle Pre-Trip", "Business / Fleet", "Vehicle-specific inspection and documentation before a trip.", + ChecklistCategory.UnitCheck, ChecklistScheduleFrequency.OnDemand, ChecklistTargetType.Unit, + C("brakes", "Braking, steering and tires checked"), C("load", "Load and equipment restraints checked"), + I("lighting", "Lights and visibility checked"), I("documents", "Required documents and prior defects reviewed")), + T("em-eoc", "EOC Activation and Handover", "Emergency Management", "Operational-period readiness for an emergency operations center.", + ChecklistCategory.StartOfShift, ChecklistScheduleFrequency.OnDemand, ChecklistTargetType.Group, + I("roles", "Operational roles and contact roster confirmed"), C("communications", "Primary and backup communications checked"), + I("systems", "Situation displays and information systems available"), I("handover", "Objectives, open actions and handover recorded"), + I("power", "Power and facility support arrangements checked")), + T("em-shelter", "Shelter Opening Readiness", "Emergency Management", "Facility, accessibility, staffing and support service readiness.", + ChecklistCategory.Facility, ChecklistScheduleFrequency.OnDemand, ChecklistTargetType.Group, + C("facility", "Facility access, exits and hazards checked"), C("accessibility", "Accessibility and support arrangements checked"), + I("supplies", "Supplies, sanitation and communications available"), I("roles", "Staffing, referrals and escalation contacts confirmed")), + T("em-cache", "Emergency Cache Deployment / Return", "Emergency Management", "Resource condition and accountability before deployment and on return.", + ChecklistCategory.EquipmentCheck, ChecklistScheduleFrequency.OnDemand, ChecklistTargetType.Group, + I("inventory", "Resource inventory and custody reconciled"), C("condition", "Equipment condition and service status checked"), + I("expiry", "Consumable expiry and stock levels reviewed"), I("routing", "Destination, custodian and missing resources recorded")), + T("business-opening", "Business Opening / Closing", "Business", "Site access, safety and handover for routine operations.", + ChecklistCategory.Facility, ChecklistScheduleFrequency.Daily, ChecklistTargetType.Group, + C("access", "Site security and emergency access checked"), I("utilities", "Utilities and operating equipment checked"), + I("staffing", "Coverage and escalation contacts confirmed"), I("handover", "Open issues and closing handover recorded")), + T("business-continuity", "Continuity Readiness Review", "Business / Emergency Management", "Exercise communications and continuity arrangements on the organization's chosen cycle.", + ChecklistCategory.SafetyAudit, ChecklistScheduleFrequency.Quarterly, ChecklistTargetType.Department, + I("contacts", "Emergency contacts and notification process reviewed"), C("alternates", "Alternate operating and communications arrangements checked"), + I("suppliers", "Critical supplier and service dependencies reviewed"), I("exercise", "Exercise findings and corrective actions assigned")) + }; + } +} diff --git a/Core/Resgrid.Model/Checklists/ChecklistValidation.cs b/Core/Resgrid.Model/Checklists/ChecklistValidation.cs new file mode 100644 index 000000000..e164c08c0 --- /dev/null +++ b/Core/Resgrid.Model/Checklists/ChecklistValidation.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Resgrid.Model.Checklists +{ + public static class ChecklistValidation + { + public static List Validate(ChecklistForm form) + { + var errors = new List(); + if (form == null) { errors.Add("A definition is required."); return errors; } + if (string.IsNullOrWhiteSpace(form.Name) || form.Name.Length > 200) errors.Add("Name is required and must be at most 200 characters."); + if (form.Instructions?.Length > 10000) errors.Add("Instructions are too long."); + if (!Enum.IsDefined(typeof(ChecklistCategory), form.Category) || !new[] { ChecklistTargetType.Department, ChecklistTargetType.Unit, ChecklistTargetType.Group, ChecklistTargetType.Personnel }.Contains(form.TargetType)) errors.Add("Choose a supported category and target type."); + if (form.PassThreshold < 0 || form.PassThreshold > 100) errors.Add("Pass threshold must be between 0 and 100."); + if (form.Sections == null || form.Sections.Count == 0 || form.Sections.Count > 30) { errors.Add("Use between 1 and 30 sections."); return errors; } + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var prior = new Dictionary(StringComparer.OrdinalIgnoreCase); + int count = 0; + foreach (var section in form.Sections) + { + if (section == null || !Guid.TryParseExact(section.Id, "D", out _) || !seen.Add(section.Id) || string.IsNullOrWhiteSpace(section.Name) || section.Name.Length > 200) { errors.Add("Sections need unique GUIDs and names (at most 200 characters)."); continue; } + if (section.Items == null || section.Items.Count == 0) { errors.Add("Every section needs an item."); continue; } + foreach (var item in section.Items) + { + count++; + if (item == null || !Guid.TryParseExact(item.Id, "D", out _) || !seen.Add(item.Id)) { errors.Add("Items need unique GUIDs."); continue; } + if (string.IsNullOrWhiteSpace(item.Name) || item.Name.Length > 300 || item.Instructions?.Length > 5000 || item.Units?.Length > 50) errors.Add("Item names, instructions or units exceed their limits."); + if (!Enum.IsDefined(typeof(ChecklistItemType), item.Type) || item.Weight < 0 || item.Weight > 1000 || item.Critical && item.Weight == 0) errors.Add("Choose a valid item type and weight; critical items need a positive weight."); + if (item.Minimum > item.Maximum) errors.Add("Minimum must not exceed maximum."); + if ((item.Type == ChecklistItemType.NumericReading || item.Type == ChecklistItemType.Quantity) && !item.Minimum.HasValue && !item.Maximum.HasValue) errors.Add("Numeric items need at least one passing bound."); + if ((item.Type == ChecklistItemType.YesNo || item.Type == ChecklistItemType.Checkbox) && item.PassingValue != "true" && item.PassingValue != "false") errors.Add("Boolean items need an explicit true or false passing value."); + if (item.Options == null || item.Options.Count > 50 || item.Options.Any(o => string.IsNullOrWhiteSpace(o) || o.Length > 200) || item.Options.Distinct(StringComparer.Ordinal).Count() != item.Options.Count) errors.Add("Options must be distinct, nonempty and bounded."); + else if (item.Type == ChecklistItemType.SelectList && (item.Options.Count < 2 || !item.Options.Contains(item.PassingValue))) errors.Add("Select lists need options and a passing option."); + foreach (var condition in new[] { item.VisibleWhen, item.RequiredWhen }.Where(c => c != null)) + { + if (condition.ItemId == null || !prior.TryGetValue(condition.ItemId, out var dependency) || string.IsNullOrEmpty(condition.EqualsValue) || condition.EqualsValue.Length > 200) errors.Add("Conditions must reference an earlier item and an explicit value."); + else if (dependency.VisibleWhen != null || dependency.RequiredWhen != null) errors.Add("Conditional chains are limited to one level."); + else if (!ConditionValueValid(dependency, condition.EqualsValue)) errors.Add("A condition must match a value its source item can produce."); + } + prior[item.Id] = item; + } + } + if (count > 250) errors.Add("A checklist can have at most 250 items."); + return errors.Distinct().ToList(); + } + + private static bool ConditionValueValid(ChecklistItem item, string value) + { + switch (item.Type) + { + case ChecklistItemType.PassFail: return value == "pass" || value == "fail"; + case ChecklistItemType.YesNo: case ChecklistItemType.Checkbox: return value == "true" || value == "false"; + case ChecklistItemType.SelectList: return item.Options?.Contains(value) == true; + case ChecklistItemType.NumericReading: case ChecklistItemType.Quantity: return decimal.TryParse(value, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var number) && (item.Type != ChecklistItemType.Quantity || number >= 0); + case ChecklistItemType.DateValue: return DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); + case ChecklistItemType.FreeText: return !string.IsNullOrWhiteSpace(value); + default: return false; + } + } + + public static bool Matches(ChecklistCondition condition, IReadOnlyDictionary answers) => condition == null || + answers.TryGetValue(condition.ItemId, out var answer) && answer.Status == ChecklistAnswerStatus.Answered && string.Equals(answer.Value, condition.EqualsValue, StringComparison.Ordinal); + + public static ChecklistEvaluation Evaluate(ChecklistForm form, ChecklistRunInput input, ISet evidenceItems, bool final) + { + var result = new ChecklistEvaluation(); + if (input?.Answers == null || input.Answers.Count > 250 || input.Answers.Any(a => a == null || a.ItemId == null) || input.Answers.Select(a => a.ItemId).Distinct(StringComparer.OrdinalIgnoreCase).Count() != input.Answers.Count) + { result.Errors.Add("Answers must have distinct item IDs and contain at most 250 items."); return result; } + var answers = input.Answers.ToDictionary(a => a.ItemId, StringComparer.OrdinalIgnoreCase); + var items = form.Sections.SelectMany(s => s.Items).ToList(); + if (answers.Keys.Any(id => !items.Any(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)))) result.Errors.Add("An answer does not belong to this published version."); + if (input.Note?.Length > 10000 || input.LocationDescription?.Length > 500 || input.Latitude < -90 || input.Latitude > 90 || input.Longitude < -180 || input.Longitude > 180 || input.Latitude.HasValue != input.Longitude.HasValue) result.Errors.Add("Notes or coordinates are invalid."); + if (final && form.RequireLocation && !input.Latitude.HasValue) result.Errors.Add("Location is required; it is recorded as user-supplied evidence."); + decimal denominator = 0, numerator = 0, answeredWeight = 0; bool criticalFailure = false; + foreach (var item in items) + { + answers.TryGetValue(item.Id, out var answer); + if (!Matches(item.VisibleWhen, answers)) + { + if (answer != null && (answer.Status != ChecklistAnswerStatus.Unanswered || !string.IsNullOrEmpty(answer.Value))) result.Errors.Add(item.Name + ": hidden items cannot be answered."); + continue; + } + var required = item.Required || item.RequiredWhen != null && Matches(item.RequiredWhen, answers); + if (answer != null && (!Enum.IsDefined(typeof(ChecklistAnswerStatus), answer.Status) || answer.Value?.Length > 10000 || answer.Note?.Length > 5000 || answer.NotApplicableReason?.Length > 2000)) { result.Errors.Add(item.Name + ": answer exceeds its limits."); continue; } + if (answer?.Status == ChecklistAnswerStatus.NotApplicable) + { + if (!item.AllowNotApplicable || string.IsNullOrWhiteSpace(answer.NotApplicableReason) || !string.IsNullOrEmpty(answer.Value)) result.Errors.Add(item.Name + ": N/A needs permission, a reason and no value."); + continue; + } + denominator += item.Weight; + if (answer == null || answer.Status == ChecklistAnswerStatus.Unanswered) + { + if (final && (required || item.Critical)) result.Errors.Add(item.Name + ": an answer is required."); + if (answer != null && !string.IsNullOrEmpty(answer.Value)) result.Errors.Add(item.Name + ": select Answered before entering a value."); + continue; + } + bool passed = false, valid = true; + switch (item.Type) + { + case ChecklistItemType.PassFail: valid = answer.Value == "pass" || answer.Value == "fail"; passed = answer.Value == "pass"; break; + case ChecklistItemType.YesNo: case ChecklistItemType.Checkbox: valid = answer.Value == "true" || answer.Value == "false"; passed = answer.Value == item.PassingValue; break; + case ChecklistItemType.NumericReading: case ChecklistItemType.Quantity: + valid = decimal.TryParse(answer.Value, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var value); + if (item.Type == ChecklistItemType.Quantity) valid &= value >= 0; + passed = valid && (!item.Minimum.HasValue || value >= item.Minimum) && (!item.Maximum.HasValue || value <= item.Maximum); break; + case ChecklistItemType.SelectList: valid = item.Options.Contains(answer.Value); passed = answer.Value == item.PassingValue; break; + case ChecklistItemType.DateValue: valid = DateTime.TryParseExact(answer.Value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); passed = valid; break; + case ChecklistItemType.Photo: case ChecklistItemType.Signature: valid = evidenceItems.Contains(item.Id); passed = valid; break; + case ChecklistItemType.FreeText: valid = !string.IsNullOrWhiteSpace(answer.Value); passed = valid; break; + default: valid = false; break; + } + if (!valid) result.Errors.Add(item.Name + ": provide a valid value or accepted evidence."); + if (valid) answeredWeight += item.Weight; + if (valid && passed) numerator += item.Weight; + else if (valid) + { + result.FailedItemIds.Add(item.Id); criticalFailure |= item.Critical; + if (final && item.RequireNoteOnFail && string.IsNullOrWhiteSpace(answer.Note)) result.Errors.Add(item.Name + ": explain the failure."); + if (final && item.RequirePhotoOnFail && !evidenceItems.Contains(item.Id)) result.Errors.Add(item.Name + ": failure photo is required."); + } + } + result.Score = denominator > 0 ? Math.Round(100 * numerator / denominator, 2, MidpointRounding.AwayFromZero) : (decimal?)null; + result.Passed = result.Errors.Count == 0 && !criticalFailure && answeredWeight > 0 && result.Score.HasValue && result.Score >= form.PassThreshold; + return result; + } + } +} diff --git a/Core/Resgrid.Model/DepartmentModuleSettings.cs b/Core/Resgrid.Model/DepartmentModuleSettings.cs index a224f38db..6df8ae8e4 100644 --- a/Core/Resgrid.Model/DepartmentModuleSettings.cs +++ b/Core/Resgrid.Model/DepartmentModuleSettings.cs @@ -61,5 +61,8 @@ public class DepartmentModuleSettings public bool MaintenanceDisabled { get; set; } [ProtoMember(22)] public string MaintenanceNameOverride { get; set; } + + [ProtoMember(23)] + public bool ChecklistsDisabled { get; set; } } } diff --git a/Core/Resgrid.Model/EventingTypes.cs b/Core/Resgrid.Model/EventingTypes.cs index e9717c7f5..b9d4815a0 100644 --- a/Core/Resgrid.Model/EventingTypes.cs +++ b/Core/Resgrid.Model/EventingTypes.cs @@ -1,4 +1,4 @@ -namespace Resgrid.Model +namespace Resgrid.Model { public enum EventingTypes { @@ -11,6 +11,7 @@ public enum EventingTypes PersonnelLocationUpdated = 7, UnitLocationUpdated = 8, IncidentCommandUpdated = 9, - ChatEvent = 10 + ChatEvent = 10, + ChecklistUpdated = 11 } } diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs index e428edc03..447fb118f 100644 --- a/Core/Resgrid.Model/FeatureFlagKeys.cs +++ b/Core/Resgrid.Model/FeatureFlagKeys.cs @@ -7,6 +7,12 @@ namespace Resgrid.Model /// public static class FeatureFlagKeys { + /// Free checklists rollout gate. Independent of paid plans and Maintenance.WorkOrders. Seeded off by M0189. + public const string ChecklistsSystem = "Checklists.System"; + + /// Maintenance and work orders rollout gate. Also requires a Readiness Pro entitlement. Seeded off by M0189. + public const string MaintenanceWorkOrders = "Maintenance.WorkOrders"; + /// /// Routes inbound Twilio SMS through the new chatbot ingress pipeline. When off (globally or for a /// specific department) the original text-command handling in TwilioController is used instead. diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs index a049a39ff..7126282b8 100644 --- a/Core/Resgrid.Model/PermissionTypes.cs +++ b/Core/Resgrid.Model/PermissionTypes.cs @@ -1,4 +1,4 @@ -namespace Resgrid.Model +namespace Resgrid.Model { public enum PermissionTypes { @@ -148,7 +148,10 @@ public enum PermissionTypes /// released on 2026-08-27; 68 is Unified Search's ManageSearchIndex). Reading prevention data needs only /// Record_View; investigations use ViewRestrictedRecords (59) plus case membership, never this value. /// - RecordsPreventionAdmin = 69 + RecordsPreventionAdmin = 69, + + ManageChecklists = 112, + ViewChecklistResults = 113 } } diff --git a/Core/Resgrid.Model/PlanAddon.cs b/Core/Resgrid.Model/PlanAddon.cs index 16f0853c0..37b5f64c7 100644 --- a/Core/Resgrid.Model/PlanAddon.cs +++ b/Core/Resgrid.Model/PlanAddon.cs @@ -42,6 +42,13 @@ public class PlanAddon : IEntity public string GetExternalKey() { + if (AddonType == (int)PlanAddonTypes.ReadinessPro) + { + // Readiness Pro has separate live/test prices; never use a live price in test mode. + var priceId = Config.PaymentProviderConfig.IsTestMode ? TestExternalId : ExternalId; + return string.IsNullOrWhiteSpace(priceId) ? null : priceId.Trim(); + } + if (!string.IsNullOrEmpty(ExternalId)) return ExternalId; @@ -73,6 +80,11 @@ public object IdValue public DateTime GetEndDateFromNow() { + // Readiness Pro has its own monthly interval, even on an annual base plan. + // Actual paid access uses the reconciled PaymentAddon interval, never this estimate. + if (AddonType == (int)PlanAddonTypes.ReadinessPro) + return DateTime.UtcNow.AddMonths(1); + if (Plan != null) { switch ((PlanFrequency)Plan.Frequency) @@ -100,6 +112,8 @@ public string GetAddonName() return "Push-To-Talk"; case PlanAddonTypes.ADP: return "Advanced Data Protection"; + case PlanAddonTypes.ReadinessPro: + return "Readiness Pro"; default: throw new ArgumentOutOfRangeException(); } diff --git a/Core/Resgrid.Model/PlanAddonTypes.cs b/Core/Resgrid.Model/PlanAddonTypes.cs index f5b96aa17..b2b35ecf4 100644 --- a/Core/Resgrid.Model/PlanAddonTypes.cs +++ b/Core/Resgrid.Model/PlanAddonTypes.cs @@ -3,6 +3,7 @@ public enum PlanAddonTypes { PTT = 1, - ADP = 2 + ADP = 2, + ReadinessPro = 3 } } diff --git a/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs b/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs index f69b504f5..8be790293 100644 --- a/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs +++ b/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs @@ -1,4 +1,4 @@ -using Resgrid.Model.Events; +using Resgrid.Model.Events; using System; using System.Threading.Tasks; @@ -28,5 +28,6 @@ void RegisterForEvents(Func personnelStatusChanged, /// need no changes. The callback receives (departmentId, ChatEventRaised JSON payload). /// void RegisterForChatEvents(Func chatEvent); + void RegisterForChecklistEvents(Func checklistEvent); } } diff --git a/Core/Resgrid.Model/Repositories/IChecklistRepository.cs b/Core/Resgrid.Model/Repositories/IChecklistRepository.cs new file mode 100644 index 000000000..4e9e1e87e --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IChecklistRepository.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; + +namespace Resgrid.Model.Repositories +{ + public interface IChecklistRepository + { + Task LockDepartmentAsync(int departmentId, 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 WriteAsync(T row, bool insert, CancellationToken ct = default) where T : ChecklistRow; + Task ReplaceAnswersAsync(int departmentId, string completionId, IEnumerable items, CancellationToken ct = default); + Task DeleteFileAsync(int departmentId, string id, CancellationToken ct = default); + Task GetFileMetadataAsync(int departmentId, string id); + } +} diff --git a/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs b/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs new file mode 100644 index 000000000..0d662cc9c --- /dev/null +++ b/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; + +namespace Resgrid.Model.Services +{ + public interface IChecklistAuthorizationService + { + Task RequireMemberAsync(ChecklistActor actor); + Task CanManageAsync(ChecklistActor actor); + Task CanReadAsync(ChecklistActor actor, ChecklistCompletion completion); + Task TargetAsync(ChecklistActor actor, ChecklistTargetType type, string id); + Task> TargetsAsync(ChecklistActor actor, ChecklistTargetType type); + } +} diff --git a/Core/Resgrid.Model/Services/IChecklistTemplateService.cs b/Core/Resgrid.Model/Services/IChecklistTemplateService.cs new file mode 100644 index 000000000..832bca8eb --- /dev/null +++ b/Core/Resgrid.Model/Services/IChecklistTemplateService.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; + +namespace Resgrid.Model.Services +{ + public interface IChecklistTemplateService + { + /// Returns null when Checklists is unavailable for this department. + Task> SearchAsync(int departmentId, string query = null); + /// Returns null when the feature or template is unavailable. + Task GetByIdAsync(int departmentId, string templateId); + } +} diff --git a/Core/Resgrid.Model/Services/IChecklistsService.cs b/Core/Resgrid.Model/Services/IChecklistsService.cs new file mode 100644 index 000000000..46e6d6c8b --- /dev/null +++ b/Core/Resgrid.Model/Services/IChecklistsService.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; + +namespace Resgrid.Model.Services +{ + public interface IChecklistsService + { + Task CanManageAsync(ChecklistActor actor); + Task> ListAsync(ChecklistActor actor, int page = 0); + Task GetDefinitionAsync(ChecklistActor actor, string id); + Task SaveDefinitionAsync(ChecklistActor actor, string id, int revision, ChecklistForm form); + Task PublishAsync(ChecklistActor actor, string id, int revision); + 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 GetRunAsync(ChecklistActor actor, string id); + Task> HistoryAsync(ChecklistActor actor, string definitionId, int page = 0); + Task SaveRunAsync(ChecklistActor actor, string id, ChecklistRunInput input, bool submit); + Task WitnessAsync(ChecklistActor actor, string id, string submissionHash, string attestation); + 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); + } +} diff --git a/Core/Resgrid.Model/Services/IReadinessAccessService.cs b/Core/Resgrid.Model/Services/IReadinessAccessService.cs new file mode 100644 index 000000000..d28f8c8e8 --- /dev/null +++ b/Core/Resgrid.Model/Services/IReadinessAccessService.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IReadinessAccessService + { + /// Free checklist operation gate. Never queries billing. + Task CanUseChecklistsAsync(int departmentId); + + /// + /// Gate for new maintenance/work order operations: rollout, module and a current paid entitlement. + /// Not a gate for historical evidence reads or releasing an existing safety hold after expiry. + /// + Task CanUseMaintenanceAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index cb34fc64a..8be28a02e 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Resgrid.Model { @@ -386,6 +386,11 @@ public static IReadOnlyList GetVariableCatalog(Workf switch (eventType) { + case WorkflowTriggerEventType.ChecklistCompleted: + case WorkflowTriggerEventType.ChecklistFailed: + foreach (var field in new[] { "completion_id", "definition_id", "version_id", "target_type", "target_id", "score", "passed", "item_id", "url" }) + list.Add(new TemplateVariableDescriptor("checklist." + field, "Checklist " + field, field == "passed" ? "bool" : field == "score" ? "decimal" : field == "target_type" ? "int" : "string", false)); + break; case WorkflowTriggerEventType.CommandEstablished: case WorkflowTriggerEventType.CommandTransferred: case WorkflowTriggerEventType.IncidentClosed: diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 46844af1e..eec815af5 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -1,7 +1,9 @@ -namespace Resgrid.Model +namespace Resgrid.Model { public enum WorkflowTriggerEventType { + ChecklistCompleted = 67, + ChecklistFailed = 68, CallAdded = 0, CallUpdated = 1, CallClosed = 2, diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index 38278bc7a..5cb665723 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -61,7 +61,7 @@ AdpColumnSpec Companion(string table, string column) => new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ProtectedFieldStorageKind.CompanionColumn, $"Protected{column}Envelope"); - return new List + var bindings = new List { AdpTableBinding.Direct("Calls", "CallId", pkIsNumeric: true, "DepartmentId", new[] { @@ -497,6 +497,9 @@ AdpColumnSpec Companion(string table, string column) => Text("RmsPreventionAttachments", "FileName"), Text("RmsPreventionAttachments", "Description"), Binary("RmsPreventionAttachments", "Data") }) with { ProtectedMarkerColumn = "IsProtected" } }; + return bindings.Concat(Resgrid.Model.Checklists.ChecklistTables.All.Values.Select(table => + AdpTableBinding.Direct(table, "Id", false, "DepartmentId", table == "ChecklistCompletionFiles" + ? new[] { Text(table, "Content"), Binary(table, "Data") } : new[] { Text(table, "Content") }) with { ProtectedMarkerColumn = "IsProtected" })).ToList(); } } } diff --git a/Core/Resgrid.Services/ChecklistAuthorizationService.cs b/Core/Resgrid.Services/ChecklistAuthorizationService.cs new file mode 100644 index 000000000..41445018a --- /dev/null +++ b/Core/Resgrid.Services/ChecklistAuthorizationService.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class ChecklistAuthorizationService : IChecklistAuthorizationService + { + private readonly IDepartmentsService _departments; + private readonly IDepartmentGroupsService _groups; + private readonly IPersonnelRolesService _roles; + private readonly IPermissionsService _permissions; + private readonly IUnitsService _units; + private readonly IAuthorizationService _authorization; + private readonly IUserProfileService _profiles; + public ChecklistAuthorizationService(IDepartmentsService departments, IDepartmentGroupsService groups, IPersonnelRolesService roles, IPermissionsService permissions, IUnitsService units, IAuthorizationService authorization, IUserProfileService profiles) + { _departments = departments; _groups = groups; _roles = roles; _permissions = permissions; _units = units; _authorization = authorization; _profiles = profiles; } + public async Task RequireMemberAsync(ChecklistActor actor) + { + if (actor == null || actor.DepartmentId <= 0 || string.IsNullOrWhiteSpace(actor.UserId)) throw new ChecklistException(403, "Active department membership is required."); + var member = await _departments.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true); + 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 RequireMemberAsync(actor); + var member = await _departments.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true); + var department = await _departments.GetDepartmentByIdAsync(actor.DepartmentId, true); + 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 lockToGroup = permission?.LockToGroup ?? type == PermissionTypes.ViewChecklistResults; + return admin || !lockToGroup || targetGroup.HasValue && targetGroup == group?.DepartmentGroupId; + } + public Task CanManageAsync(ChecklistActor actor) => AllowedAsync(actor, PermissionTypes.ManageChecklists, PermissionActions.DepartmentAdminsOnly); + public async Task CanReadAsync(ChecklistActor actor, ChecklistCompletion completion) + { + 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); + } + public async Task TargetAsync(ChecklistActor actor, ChecklistTargetType type, string id) + { + await RequireMemberAsync(actor); + if (id == null || id.Length > 128) throw new ChecklistException(400, "Select a target."); + string name = null; int? targetGroupId = null; + if (type == ChecklistTargetType.Department && id == actor.DepartmentId.ToString()) name = (await _departments.GetDepartmentByIdAsync(actor.DepartmentId, true))?.Name; + else if (type == ChecklistTargetType.Unit && int.TryParse(id, out var unitId)) + { + var unit = await _units.GetUnitByIdAsync(unitId); + if (unit?.DepartmentId == actor.DepartmentId && await _authorization.CanUserViewUnitAsync(actor.UserId, unitId)) { name = unit.Name; targetGroupId = unit.StationGroupId; } + } + else if (type == ChecklistTargetType.Group && int.TryParse(id, out var groupId)) + { + var group = await _groups.GetGroupByIdAsync(groupId, true); + var own = await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId); + if (group?.DepartmentId == actor.DepartmentId && (groupId == own?.DepartmentGroupId || await CanManageAsync(actor))) { name = group.Name; targetGroupId = group.DepartmentGroupId; } + } + else if (type == ChecklistTargetType.Personnel) + { + var member = await _departments.GetDepartmentMemberAsync(id, actor.DepartmentId, true); + if (member != null && !member.IsDeleted && !member.IsDisabled.GetValueOrDefault() && (id == actor.UserId || await _authorization.CanUserViewPersonAsync(actor.UserId, id, actor.DepartmentId))) { name = (await _profiles.GetProfileByUserIdAsync(id))?.FullName?.AsFirstNameLastName ?? id; targetGroupId = (await _groups.GetGroupForUserAsync(id, actor.DepartmentId))?.DepartmentGroupId; } + } + if (name == null) throw new ChecklistException(404, "Target is unavailable."); + return new ChecklistTarget { Type = type, Id = id, Name = name, GroupId = targetGroupId }; + } + public async Task> TargetsAsync(ChecklistActor actor, ChecklistTargetType type) + { + await RequireMemberAsync(actor); + IEnumerable ids; + switch (type) + { + case ChecklistTargetType.Department: ids = new[] { actor.DepartmentId.ToString() }; break; + case ChecklistTargetType.Unit: ids = (await _units.GetUnitsForDepartmentAsync(actor.DepartmentId)).Select(u => u.UnitId.ToString()); break; + case ChecklistTargetType.Group: ids = (await _groups.GetAllGroupsForDepartmentAsync(actor.DepartmentId)).Select(g => g.DepartmentGroupId.ToString()); break; + case ChecklistTargetType.Personnel: ids = (await _departments.GetAllMembersForDepartmentAsync(actor.DepartmentId)).Where(m => !m.IsDeleted && !m.IsDisabled.GetValueOrDefault()).Select(m => m.UserId); break; + default: return new List(); + } + var result = new List(); + foreach (var id in ids) + try { result.Add(await TargetAsync(actor, type, id)); } catch (ChecklistException ex) when (ex.StatusCode == 404) { } + return result; + } + } +} diff --git a/Core/Resgrid.Services/ChecklistTemplateService.cs b/Core/Resgrid.Services/ChecklistTemplateService.cs new file mode 100644 index 000000000..35c2afbf2 --- /dev/null +++ b/Core/Resgrid.Services/ChecklistTemplateService.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class ChecklistTemplateService : IChecklistTemplateService + { + private readonly IReadinessAccessService _access; + public ChecklistTemplateService(IReadinessAccessService access) => _access = access; + + public async Task> SearchAsync(int departmentId, string query = null) + { + if (!await _access.CanUseChecklistsAsync(departmentId)) + return null; + if (query?.Length > 256) + throw new ArgumentException("Search must be 256 characters or fewer.", nameof(query)); + return ChecklistTemplateCatalog.Search(query); + } + + public async Task GetByIdAsync(int departmentId, string templateId) + { + if (!await _access.CanUseChecklistsAsync(departmentId)) + return null; + return ChecklistTemplateCatalog.GetById(templateId); + } + } +} diff --git a/Core/Resgrid.Services/ChecklistsService.cs b/Core/Resgrid.Services/ChecklistsService.cs new file mode 100644 index 000000000..b73f06f18 --- /dev/null +++ b/Core/Resgrid.Services/ChecklistsService.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +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.Services.Records; + +namespace Resgrid.Services +{ + public class ChecklistsService : IChecklistsService + { + private readonly IChecklistRepository _store; + private readonly IChecklistAuthorizationService _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; + public ChecklistsService(IChecklistRepository store, IChecklistAuthorizationService authorization, IReadinessAccessService access, + IUnitOfWork uow, IAuditLogsRepository audit, IDomainEventOutboxService outbox, Lazy read, + Lazy write, IRecordAttachmentScanner scanner) + { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; } + + public Task CanManageAsync(ChecklistActor actor) => _authorization.CanManageAsync(actor); + private async Task RequireWriteAsync(ChecklistActor actor, bool manage = false) + { + await _authorization.RequireMemberAsync(actor); + if (!await _access.CanUseChecklistsAsync(actor.DepartmentId)) throw new ChecklistException(404, "Checklists are disabled for this department."); + if (manage && !await _authorization.CanManageAsync(actor)) throw new ChecklistException(403, "Checklist management permission is required."); + } + private static void Id(string id) { if (!Guid.TryParseExact(id, "D", out _)) throw new ChecklistException(404, "Checklist item is unavailable."); } + private static void Revision(ChecklistRow row, int revision) { if (row.Revision != revision) throw new ChecklistException(409, "This item changed. Reload before saving."); } + private static void Valid(IEnumerable errors) { if (errors.Any()) throw new ChecklistException(400, string.Join(" ", errors)); } + private static T Decode(string content) => JsonConvert.DeserializeObject(content ?? "{}"); + private static T New(ChecklistActor actor, string parentId = null) where T : ChecklistRow, new() => new T + { DepartmentId = actor.DepartmentId, ParentId = parentId, CreatedBy = actor.UserId, CreatedOn = DateTime.UtcNow, UpdatedOn = DateTime.UtcNow }; + private async Task RevealAsync(ChecklistActor actor, T row) where T : ChecklistRow + { + if (row == null) throw new ChecklistException(404, "Checklist item is unavailable."); + var result = await _read.Value.ResolveRecordsEntitiesForReadAsync(actor.DepartmentId, new[] { (row, row.Id) }, ChecklistTables.Fields(), actor.GrantToken, actor.UserId); + if (result == null || result.RedactedFields.Count > 0) throw new ChecklistException(403, "Unlock protected data to use this checklist."); + return row; + } + private async Task SealAsync(ChecklistActor actor, T row) where T : ChecklistRow + { + var result = await _write.Value.PrepareRecordsEntityWriteAsync(actor.DepartmentId, row, (T)null, row.Id, ChecklistTables.Fields(), () => row.IsProtected = true, actor.GrantToken, actor.UserId, false); + if (result?.Success != true) 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); } + private async Task AuditAsync(ChecklistActor actor, ChecklistRow row, AuditLogTypes type, object detail = null) + { + await _audit.InsertAsync(new AuditLog { DepartmentId = actor.DepartmentId, ObjectDepartmentId = actor.DepartmentId, UserId = actor.UserId, + ObjectId = row.Id, LogType = (int)type, LoggedOn = DateTime.UtcNow, Successful = true, Message = type.ToString(), + Data = JsonConvert.SerializeObject(new { row.Id, row.Revision, Detail = detail }), ServerName = Environment.MachineName }, CancellationToken.None); + } + private async Task TransactionAsync(ChecklistActor actor, Func, Task> action) + { + if (_uow.Transaction != null) throw new InvalidOperationException("Checklist commands own their transaction."); + var events = new List(); T result; + try + { + await _uow.CreateOrGetConnectionAsync(CancellationToken.None); + await _store.LockDepartmentAsync(actor.DepartmentId); + result = await action(events); + _uow.CommitChanges(); + } + catch { _uow.DiscardChanges(); throw; } + await _outbox.DispatchAfterCommitAsync(events); + return result; + } + public async Task> ListAsync(ChecklistActor actor, int page = 0) + { + await _authorization.RequireMemberAsync(actor); + if (page < 0 || page > 10000) throw new ChecklistException(400, "Invalid page."); + var manage = await CanManageAsync(actor); var result = new List(); + foreach (var row in await _store.ListAsync(actor.DepartmentId, skip: page * 50, take: 50)) + { + if (row.DeletedOn.HasValue || !manage && row.CurrentVersionId == null) continue; + result.Add(await DefinitionViewAsync(actor, row, manage)); + } + return result; + } + private async Task DefinitionViewAsync(ChecklistActor actor, ChecklistDefinition row, bool manage) + { + if (row == null || row.DeletedOn.HasValue) throw new ChecklistException(404, "Checklist definition is unavailable."); + string content; + if (manage) content = (await RevealAsync(actor, row)).Content; + else + { + if (row.CurrentVersionId == null) throw new ChecklistException(404, "Checklist is not published."); + content = (await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.CurrentVersionId))).Content; + row.Content = null; + } + return new ChecklistDefinitionView { Definition = row, Form = Decode(content), PublishedForm = row.CurrentVersionId == null ? null : Decode((await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.CurrentVersionId))).Content) }; + } + public async Task GetDefinitionAsync(ChecklistActor actor, string id) + { + Id(id); await _authorization.RequireMemberAsync(actor); + return await DefinitionViewAsync(actor, await _store.GetAsync(actor.DepartmentId, id), await CanManageAsync(actor)); + } + public async Task SaveDefinitionAsync(ChecklistActor actor, string id, int revision, ChecklistForm form) + { + await RequireWriteAsync(actor, true); Valid(ChecklistValidation.Validate(form)); if (id != null) Id(id); + foreach (var section in form.Sections) + { + section.Id = Guid.Parse(section.Id).ToString("D"); + foreach (var item in section.Items) + { + item.Id = Guid.Parse(item.Id).ToString("D"); + if (item.VisibleWhen != null) item.VisibleWhen.ItemId = Guid.Parse(item.VisibleWhen.ItemId).ToString("D"); + if (item.RequiredWhen != null) item.RequiredWhen.ItemId = Guid.Parse(item.RequiredWhen.ItemId).ToString("D"); + } + } + return await TransactionAsync(actor, async events => + { + var insert = id == null; + var row = insert ? New(actor) : await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, id)); + if (!insert) { Revision(row, revision); if (row.DeletedOn.HasValue) throw new ChecklistException(409, "Deleted definitions cannot be edited."); row.Revision++; } + row.Content = JsonConvert.SerializeObject(form); + await PersistAsync(actor, row, insert); + await AuditAsync(actor, row, insert ? AuditLogTypes.ChecklistDefinitionAdded : AuditLogTypes.ChecklistDefinitionUpdated); + return row.Id; + }); + } + public async Task PublishAsync(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.DeletedOn.HasValue) throw new ChecklistException(409, "Deleted definitions cannot be published."); + Valid(ChecklistValidation.Validate(Decode(row.Content))); + var version = New(actor, row.Id); version.Version = row.PublishedVersion + 1; version.Content = row.Content; + await PersistAsync(actor, version, true); + row.CurrentVersionId = version.Id; row.PublishedVersion = version.Version; row.Retired = false; row.Revision++; + await PersistAsync(actor, row, false); await AuditAsync(actor, row, AuditLogTypes.ChecklistDefinitionPublished, new { version.Version, VersionId = version.Id }); + return true; + }); + } + public async Task RetireAsync(ChecklistActor actor, string id, int revision, bool delete = false) + { + 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); + // Published definitions retain their history. Delete only removes never-published drafts. + if (delete && row.PublishedVersion > 0) throw new ChecklistException(409, "Retire a published checklist to preserve its history."); + row.Retired = true; row.Revision++; if (delete) row.DeletedOn = DateTime.UtcNow; + await PersistAsync(actor, row, false); await AuditAsync(actor, row, delete ? AuditLogTypes.ChecklistDefinitionRemoved : AuditLogTypes.ChecklistDefinitionRetired); return true; + }); + } + 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) + { + Id(definitionId); Id(completionId); await RequireWriteAsync(actor); + 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."); + 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 form = Decode(version.Content); + var target = await _authorization.TargetAsync(actor, form.TargetType, targetId); + var occurrence = New(actor, definition.Id); + occurrence.VersionId = version.Id; occurrence.CompletionId = completionId; occurrence.TargetType = (int)target.Type; occurrence.TargetId = target.Id; + occurrence.Content = JsonConvert.SerializeObject(target); await PersistAsync(actor, occurrence, true); + var completion = New(actor, definition.Id); completion.Id = completionId; + completion.TargetGroupId = target.GroupId; completion.VersionId = version.Id; completion.OccurrenceId = occurrence.Id; completion.TargetId = target.Id; completion.TargetType = (int)target.Type; + completion.Content = "{}"; await PersistAsync(actor, completion, true); await AuditAsync(actor, completion, AuditLogTypes.ChecklistCompletionStarted); return completion.Id; + }); + } + private async Task RequireRunReadAsync(ChecklistActor actor, ChecklistCompletion row) + { + if (row == null) throw new ChecklistException(404, "Checklist run is unavailable."); + if (await _authorization.CanReadAsync(actor, row)) return; + throw new ChecklistException(404, "Checklist run is unavailable."); + } + private async Task RunViewAsync(ChecklistActor actor, ChecklistCompletion row) + { + await RequireRunReadAsync(actor, row); await RevealAsync(actor, row); + var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.VersionId)); + var occurrence = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.OccurrenceId)); + var input = Decode(row.Content); input.Revision = row.Revision; input.Answers = new List(); + foreach (var item in await _store.ListAsync(actor.DepartmentId, row.Id, take: 250)) input.Answers.Add(Decode((await RevealAsync(actor, item)).Content)); + var files = await _store.ListAsync(actor.DepartmentId, row.Id, take: 500); + foreach (var file in files) await RevealAsync(actor, file); + return new ChecklistRunView { VersionNumber = version.Version, Completion = row, Form = Decode(version.Content), Target = Decode(occurrence.Content), Input = input, Files = files }; + } + public async Task GetRunAsync(ChecklistActor actor, string id) + { Id(id); await _authorization.RequireMemberAsync(actor); return await RunViewAsync(actor, await _store.GetAsync(actor.DepartmentId, id)); } + public async Task> HistoryAsync(ChecklistActor actor, string definitionId, int page = 0) + { + Id(definitionId); await _authorization.RequireMemberAsync(actor); + if (page < 0 || page > 10000) throw new ChecklistException(400, "Invalid page."); + var result = new List(); + foreach (var row in await _store.ListAsync(actor.DepartmentId, definitionId, page * 50, 50)) + if (await _authorization.CanReadAsync(actor, row)) + { + var occurrence = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.OccurrenceId)); + row.Content = null; result.Add(new ChecklistHistoryEntry { Completion = row, TargetName = Decode(occurrence.Content).Name }); + } + return result; + } + private static string SubmissionHash(ChecklistRunInput input, IEnumerable files) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new + { + input.Note, input.LocationDescription, input.Latitude, input.Longitude, input.ClientCompletedOn, + Answers = input.Answers.OrderBy(a => a.ItemId, StringComparer.Ordinal).ToList(), Files = files.OrderBy(f => f.Id, StringComparer.Ordinal).Select(f => new { f.Id, f.Sha256 }).ToList() + })))); + private static HashSet Evidence(IEnumerable files) => files.Where(f => f.ScanState == (int)RmsAttachmentScanState.Clean).Select(f => f.ItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); + public async Task SaveRunAsync(ChecklistActor actor, string id, ChecklistRunInput input, bool submit) + { + Id(id); await RequireWriteAsync(actor); + return await TransactionAsync(actor, async events => + { + var row = await _store.GetAsync(actor.DepartmentId, id); + if (row == null || row.CreatedBy != actor.UserId) throw new ChecklistException(404, "Only the author can edit this run."); + var view = await RunViewAsync(actor, row); + 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); + if (row.State != (int)ChecklistRunState.InProgress) + { + if (submit && row.SubmissionHash == hash) return row.Revision; + throw new ChecklistException(409, "Submitted answers and evidence are immutable."); + } + Revision(row, input.Revision); + var items = new List(); + foreach (var answer in input.Answers) + { + var item = New(actor, row.Id); item.ItemId = answer.ItemId; item.Content = JsonConvert.SerializeObject(answer); + item.IsFailure = evaluation.FailedItemIds.Contains(answer.ItemId); await SealAsync(actor, item); items.Add(item); + } + await _store.ReplaceAnswersAsync(actor.DepartmentId, row.Id, items); + row.Content = JsonConvert.SerializeObject(new { input.Note, input.LocationDescription, input.Latitude, input.Longitude, input.ClientCompletedOn }); row.Revision++; + if (submit) + { + row.SubmittedOn = DateTime.UtcNow; row.Score = evaluation.Score; row.Passed = evaluation.Passed; row.SubmissionHash = hash; + row.State = (int)(view.Form.RequiresIndependentWitness ? ChecklistRunState.AwaitingWitness : ChecklistRunState.Submitted); + await AdvanceOccurrenceAsync(actor, row); + foreach (var failed in evaluation.FailedItemIds) await EventAsync(actor, row, WorkflowTriggerEventType.ChecklistFailed, failed, events); + if (row.State == (int)ChecklistRunState.Submitted) await EventAsync(actor, row, WorkflowTriggerEventType.ChecklistCompleted, null, events); + } + await PersistAsync(actor, row, false); await AuditAsync(actor, row, submit ? AuditLogTypes.ChecklistCompletionSubmitted : AuditLogTypes.ChecklistProgressSaved, + new { row.State, row.Score, row.Passed, FailedItemIds = evaluation.FailedItemIds }); return row.Revision; + }); + } + private async Task AdvanceOccurrenceAsync(ChecklistActor actor, ChecklistCompletion completion) + { + var occurrence = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, completion.OccurrenceId)); + occurrence.State = completion.State; occurrence.Revision++; await PersistAsync(actor, occurrence, false); + } + private async Task EventAsync(ChecklistActor actor, ChecklistCompletion row, WorkflowTriggerEventType trigger, string itemId, List events) + { + var entry = await _outbox.EnqueueAsync(actor.DepartmentId, "Checklists", new DomainEventEnvelope + { + EventName = trigger == WorkflowTriggerEventType.ChecklistCompleted ? "ChecklistCompleted" : "ChecklistItemFailed", AggregateType = "ChecklistCompletion", + AggregateId = row.Id, AggregateVersion = row.Revision, Trigger = trigger, OccurredOn = DateTime.UtcNow, + Payload = new { CompletionId = row.Id, DefinitionId = row.ParentId, row.VersionId, row.TargetType, row.TargetId, row.Score, row.Passed, ItemId = itemId }, CorrelationId = row.Id + }); + events.Add(entry.DomainEventOutboxId); + } + public async Task WitnessAsync(ChecklistActor actor, string id, string submissionHash, string attestation) + { + Id(id); await RequireWriteAsync(actor); + if (string.IsNullOrWhiteSpace(attestation) || attestation.Length > 2000) throw new ChecklistException(400, "An independent witness attestation is required (at most 2000 characters)."); + await TransactionAsync(actor, async events => + { + var row = await _store.GetAsync(actor.DepartmentId, id); + if (row == null || row.CreatedBy == actor.UserId) throw new ChecklistException(403, "The witness must be a different authenticated department member."); + // A witness needs the results permission and its group scope, in addition to membership. + var view = await RunViewAsync(actor, row); + if (row.SubmissionHash != submissionHash) throw new ChecklistException(409, "The submitted evidence changed. Reload before attesting."); + if (row.State == (int)ChecklistRunState.Submitted && row.WitnessUserId == actor.UserId && (string)JObject.Parse(row.Content)["WitnessAttestation"] == attestation) return true; + if (row.State != (int)ChecklistRunState.AwaitingWitness || !view.Form.RequiresIndependentWitness) throw new ChecklistException(409, "This run is not awaiting a witness."); + var content = JObject.Parse(row.Content); content["WitnessAttestation"] = attestation; row.Content = content.ToString(Formatting.None); + row.WitnessUserId = actor.UserId; row.WitnessedOn = DateTime.UtcNow; row.State = (int)ChecklistRunState.Submitted; row.Revision++; + await AdvanceOccurrenceAsync(actor, row); await PersistAsync(actor, row, false); + await AuditAsync(actor, row, AuditLogTypes.ChecklistWitnessAttested, new { row.SubmissionHash }); + await EventAsync(actor, row, WorkflowTriggerEventType.ChecklistCompleted, null, events); return true; + }); + } + public async Task AddFileAsync(ChecklistActor actor, string id, string itemId, 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."); + 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 + { + var info = SixLabors.ImageSharp.Image.Identify(data); + if (info == null || (long)info.Width * info.Height > RecordAttachmentHygiene.MaxPixels) throw new ChecklistException(400, "Evidence image dimensions exceed the limit."); + clean = RecordAttachmentHygiene.Sanitize(fileName, contentType, data); + } + catch (Exception ex) when (ex is ArgumentException || ex is SixLabors.ImageSharp.UnknownImageFormatException || ex is SixLabors.ImageSharp.InvalidImageContentException || ex is NotSupportedException) + { throw new ChecklistException(400, "Evidence could not be decoded as a supported image."); } + if (!clean.IsImage || clean.ContentType != "image/png" && clean.ContentType != "image/jpeg" || clean.Data.Length > 10 * 1024 * 1024) throw new ChecklistException(400, "Evidence must decode as a PNG or JPEG up to 10 MB."); + var scan = await _scanner.ScanAsync(clean.FileName, clean.ContentType, clean.Data); + if (scan?.State != RmsAttachmentScanState.Clean) throw new ChecklistException(409, "Evidence was not accepted by the scanner. Retry when scanning is available."); + await TransactionAsync(actor, async events => + { + var row = await _store.GetAsync(actor.DepartmentId, id); + if (row?.CreatedBy != actor.UserId || row.State != (int)ChecklistRunState.InProgress) throw new ChecklistException(409, "Submitted evidence is immutable."); + 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.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; + var protection = await _write.Value.PrepareRecordsBinaryWriteAsync(actor.DepartmentId, "checklistcompletionfiles.data", file.Id, file.Data, bytes => file.Data = bytes, () => file.IsProtected = true, actor.GrantToken, actor.UserId, false); + if (protection?.Success != true) throw new ChecklistException(403, "Protected evidence could not be saved."); + await PersistAsync(actor, file, true); row.Revision++; await PersistAsync(actor, row, false); + await AuditAsync(actor, file, AuditLogTypes.ChecklistFileAdded, new { CompletionId = row.Id, itemId, file.Size, file.ScanState }); return true; + }); + } + public async Task GetFileAsync(ChecklistActor actor, string id) + { + Id(id); await _authorization.RequireMemberAsync(actor); + var file = await _store.GetFileMetadataAsync(actor.DepartmentId, id); + if (file == null) throw new ChecklistException(404, "Evidence is unavailable."); + await RequireRunReadAsync(actor, await _store.GetAsync(actor.DepartmentId, file.ParentId)); + file = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, id)); + if (file.ScanState != (int)RmsAttachmentScanState.Clean) throw new ChecklistException(404, "Evidence is unavailable."); + var result = await _read.Value.ResolveRecordsBinaryForReadAsync(actor.DepartmentId, "checklistcompletionfiles.data", file.Id, file.Data, data => file.Data = data, actor.GrantToken, actor.UserId); + if (result == null || result.RedactedFields.Count > 0 || file.Data == null) throw new ChecklistException(403, "Unlock protected data to download this evidence."); + 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) + { + Id(id); await RequireWriteAsync(actor); + await TransactionAsync(actor, async events => + { + var file = await _store.GetFileMetadataAsync(actor.DepartmentId, id); + 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."); + 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/GdprDataExportService.cs b/Core/Resgrid.Services/GdprDataExportService.cs index b4968ba89..ece4a4b5b 100644 --- a/Core/Resgrid.Services/GdprDataExportService.cs +++ b/Core/Resgrid.Services/GdprDataExportService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; @@ -34,6 +34,7 @@ public class GdprDataExportService : IGdprDataExportService private readonly ITrainingService _trainingService; private readonly IShiftsService _shiftsService; private readonly IEmailService _emailService; + private readonly IChecklistRepository _checklists; public GdprDataExportService( IGdprDataExportRequestRepository repository, @@ -49,7 +50,7 @@ public GdprDataExportService( ICertificationService certificationService, ITrainingService trainingService, IShiftsService shiftsService, - IEmailService emailService) + IEmailService emailService, IChecklistRepository checklists = null) { _repository = repository; _userProfileService = userProfileService; @@ -65,6 +66,7 @@ public GdprDataExportService( _trainingService = trainingService; _shiftsService = shiftsService; _emailService = emailService; + _checklists = checklists; } public async Task CreateExportRequestAsync(string userId, int departmentId, CancellationToken cancellationToken = default) @@ -179,6 +181,7 @@ private async Task BuildExportZipAsync(string userId, int departmentId) await AddJsonEntry(archive, "certifications.json", await BuildCertificationsDataAsync(userId), ledger); await AddJsonEntry(archive, "trainings.json", await BuildTrainingsDataAsync(userId), ledger); await AddJsonEntry(archive, "shifts.json", await BuildShiftsDataAsync(userId), ledger); + if (_checklists != null) await AddJsonEntry(archive, "checklists.json", await BuildChecklistDataAsync(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 @@ -220,6 +223,23 @@ public void Record(string fileName, string path) } } + private async Task BuildChecklistDataAsync(string userId, int departmentId) + { + var records = new List(); + for (var skip = 0; ; skip += 100) + { + var batch = await _checklists.ListAsync(departmentId, skip: skip); + foreach (var completion in batch.Where(c => c.CreatedBy == userId || c.WitnessUserId == userId || c.TargetType == (int)Resgrid.Model.Checklists.ChecklistTargetType.Personnel && c.TargetId == userId)) + { + var files = await _checklists.ListAsync(departmentId, completion.Id, take: 500); + records.Add(new { Completion = completion, Answers = await _checklists.ListAsync(departmentId, completion.Id, take: 250), Files = files }); + } + if (batch.Count < 100) break; + } + // AddJsonEntry applies the existing recursive ADP redaction and records omissions in the manifest. + return records; + } + private static async Task AddJsonEntry(ZipArchive archive, string fileName, object data, RedactionLedger ledger) { var entry = archive.CreateEntry(fileName, CompressionLevel.Optimal); diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index a31171f31..d200c3162 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -683,6 +683,11 @@ void Prevention(string table, string column, ProtectedFieldClassification classi Prevention("RmsPreventionAttachments", "Description", ProtectedFieldClassification.Sensitive); Prevention("RmsPreventionAttachments", "Data", ProtectedFieldClassification.Phi, ProtectedFieldStorageKind.Binary); + 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, 14)); + list.Add(new ProtectedFieldDefinition("checklistcompletionfiles.data", OperationalFamily, "ChecklistCompletionFiles", "Data", ProtectedFieldStorageKind.Binary, + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 14)); return list; } } diff --git a/Core/Resgrid.Services/ReadinessAccessService.cs b/Core/Resgrid.Services/ReadinessAccessService.cs new file mode 100644 index 000000000..5341d5f21 --- /dev/null +++ b/Core/Resgrid.Services/ReadinessAccessService.cs @@ -0,0 +1,86 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class ReadinessAccessService : IReadinessAccessService + { + private readonly IFeatureToggleService _flags; + private readonly IDepartmentSettingsService _settings; + private readonly ISubscriptionsService _subscriptions; + + public ReadinessAccessService(IFeatureToggleService flags, IDepartmentSettingsService settings, + ISubscriptionsService subscriptions) + { + _flags = flags; + _settings = settings; + _subscriptions = subscriptions; + } + + public async Task CanUseChecklistsAsync(int departmentId) + { + if (departmentId <= 0) + return false; + + try + { + if (!await _flags.IsEnabledAsync(FeatureFlagKeys.ChecklistsSystem, departmentId)) + return false; + + var settings = await _settings.GetDepartmentModuleSettingsAsync(departmentId); + return settings != null && !settings.ChecklistsDisabled; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return false; + } + } + + public async Task CanUseMaintenanceAsync(int departmentId) + { + if (departmentId <= 0) + return false; + + try + { + if (!await _flags.IsEnabledAsync(FeatureFlagKeys.MaintenanceWorkOrders, departmentId)) + return false; + + var settings = await _settings.GetDepartmentModuleSettingsAsync(departmentId); + if (settings == null || settings.MaintenanceDisabled) + return false; + + // Generic billing helpers synthesize free forever PTT payments when billing is + // unconfigured. Readiness Pro must not grant paid access through that fallback. + if (string.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) || + string.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) + return false; + + var plans = await _subscriptions.GetAllAddonPlansByTypeAsync(PlanAddonTypes.ReadinessPro); + var ids = plans?.Where(x => x != null && x.AddonType == (int)PlanAddonTypes.ReadinessPro && + !string.IsNullOrWhiteSpace(x.PlanAddonId)).Select(x => x.PlanAddonId).Distinct().ToList(); + if (ids == null || ids.Count == 0) + return false; + + // No entitlement cache: cancellation and renewal take effect on the next write. + var payments = await _subscriptions.GetCurrentPaymentAddonsForDepartmentAsync(departmentId, ids); + var now = DateTime.UtcNow; + return payments != null && payments.Any(x => x != null && x.DepartmentId == departmentId && + ids.Contains(x.PlanAddonId) && x.EffectiveOn != default && x.EffectiveOn <= now && x.EndingOn > now && + !string.Equals(x.TransactionId, "SYSTEM", StringComparison.OrdinalIgnoreCase) && + x.EndingOn != DateTime.MaxValue); + // IsCancelled represents renewal cancellation in the shared model; access lasts + // through EndingOn. Immediate revocation must shorten EndingOn at reconciliation. + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return false; + } + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs index ee9cbb4f3..ca3aa0bdb 100644 --- a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs +++ b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs @@ -484,8 +484,15 @@ await SectionAsync(result, "Queues and obligations", async () => private async Task WentOverdueAsync(Context c, Dataset data, DateTime start, DateTime end) { - var rows = ((await _dueStates.GetLastEmittedInRangeAsync(c.DepartmentId, start, end, RecordsAnalyticsLimits.RowCap)) ?? Enumerable.Empty()) - .Where(d => d.OverdueCount > 0 && d.LastEmittedOn.HasValue && d.LastEmittedOn >= start && d.LastEmittedOn < end && (d.LastEmittedState == (int)RmsDueState.Overdue || d.LastEmittedState == (int)RmsDueState.Cleared)); + // One row past the cap says whether the read was cut short. The due-state read is its own input, so it carries + // its own warning: Finish's Truncated flag speaks for the Records read and would not name this figure. + var read = ((await _dueStates.GetLastEmittedInRangeAsync(c.DepartmentId, start, end, RecordsAnalyticsLimits.RowCap + 1)) ?? Enumerable.Empty()).ToList(); + if (read.Count > RecordsAnalyticsLimits.RowCap) + { + read = read.Take(RecordsAnalyticsLimits.RowCap).ToList(); + c.Warnings.Add($"More than {RecordsAnalyticsLimits.RowCap:N0} due-state changes fall in this window; the count of obligations that went overdue covers the first {RecordsAnalyticsLimits.RowCap:N0} by emission. Narrow the window."); + } + var rows = read.Where(d => d.OverdueCount > 0 && d.LastEmittedOn.HasValue && d.LastEmittedOn >= start && d.LastEmittedOn < end && (d.LastEmittedState == (int)RmsDueState.Overdue || d.LastEmittedState == (int)RmsDueState.Cleared)); if (!c.GroupScoped) return rows.Count(); // A group-scoped viewer only counts obligations on Records they can open. var mine = data.Records.Select(r => r.RmsOperationalRecordId).Concat(data.Reports.Select(r => r.RmsIncidentReportId)).ToHashSet(StringComparer.Ordinal); diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 76ac39ff1..b149a5cd3 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -15,6 +15,10 @@ public class ServicesModule : 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(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/SubscriptionsService.cs b/Core/Resgrid.Services/SubscriptionsService.cs index f56d00e56..c9beb087e 100644 --- a/Core/Resgrid.Services/SubscriptionsService.cs +++ b/Core/Resgrid.Services/SubscriptionsService.cs @@ -1155,6 +1155,9 @@ public async Task GetActivePTTStripeSubscriptionA public async Task ModifyPTTAddonSubscriptionAsync(string stripeCustomerId, long quantity, PlanAddon planAddon) { + if (planAddon?.AddonType == (int)PlanAddonTypes.ReadinessPro) + return false; + if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { if (string.IsNullOrWhiteSpace(stripeCustomerId)) @@ -1463,6 +1466,9 @@ public async Task ChangePaddleSubscriptionAsync(string paddleCustomerId, s public async Task ModifyPaddlePTTAddonSubscriptionAsync(string paddleCustomerId, long quantity, PlanAddon planAddon) { + if (planAddon?.AddonType == (int)PlanAddonTypes.ReadinessPro) + return false; + if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { if (string.IsNullOrWhiteSpace(paddleCustomerId)) diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 79c8066fe..1d5bce772 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -1,4 +1,4 @@ -using System; +using System; using Resgrid.Model; using Scriban.Runtime; @@ -78,6 +78,10 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve { switch (eventType) { + case WorkflowTriggerEventType.ChecklistCompleted: + case WorkflowTriggerEventType.ChecklistFailed: + obj["checklist"] = new ScriptObject { ["completion_id"] = "11111111-1111-1111-1111-111111111111", ["definition_id"] = "22222222-2222-2222-2222-222222222222", ["version_id"] = "33333333-3333-3333-3333-333333333333", ["target_type"] = 1, ["target_id"] = "12", ["score"] = 75m, ["passed"] = false, ["item_id"] = "44444444-4444-4444-4444-444444444444", ["url"] = "/User/Checklists/CompletionDetail/11111111-1111-1111-1111-111111111111" }; + break; case WorkflowTriggerEventType.CallAdded: case WorkflowTriggerEventType.CallUpdated: case WorkflowTriggerEventType.CallClosed: diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 3de89201b..f2aea5403 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -400,6 +400,18 @@ public async Task BuildContextAsync( } break; } + case WorkflowTriggerEventType.ChecklistCompleted: + case WorkflowTriggerEventType.ChecklistFailed: + { + var checklistEvent = TryDeserialize(eventPayloadJson); + var payload = checklistEvent?.Payload ?? new JObject(); + var checklist = new ScriptObject(); + foreach (var pair in new[] { ("completion_id", "CompletionId"), ("definition_id", "DefinitionId"), ("version_id", "VersionId"), ("target_type", "TargetType"), ("target_id", "TargetId"), ("score", "Score"), ("passed", "Passed"), ("item_id", "ItemId") }) + checklist[pair.Item1] = ToScriptValue(payload[pair.Item2]); + checklist["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Checklists/CompletionDetail/{(string)payload["CompletionId"]}"; + scriptObject["checklist"] = checklist; + break; + } case WorkflowTriggerEventType.RecordCreated: case WorkflowTriggerEventType.RecordSubmittedForReview: case WorkflowTriggerEventType.RecordReturnedForCorrection: diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 000000000..cbad7b736 --- /dev/null +++ b/MEMORY.md @@ -0,0 +1,10 @@ +# Project Memory + +## Localization + +- Use the languages in `SupportedLocales.SupportedLanguagesMap` for every new resource family. Provide real translations for every key, including Arabic; never populate non-English dictionaries with English placeholders. Translate interface labels and choices while preserving persisted response codes, user-authored content, product names and standard file-format identifiers. Verify key coverage, formatting placeholders and browser behavior across all supported languages. + +## Integration and data protection + +- Readiness features must emit registered Workflow domain events through the transactional outbox. Project only reviewed metadata before serialization; ADP-enforced Workflows receive REDACTED values and redaction metadata, never protected plaintext or a user grant. +- Inventory every readiness model, free-text/JSON slot, attachment and derived copy for PII/PHI and integrate it with the existing ADP catalog/read/write/lifecycle system. Durable protection state survives billing or feature-flag changes; unattended generation must use approved metadata and protected source references without a decryption bypass. diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs index 26df73b1c..06bfc84f4 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text; using System.Threading; using System.Threading.Channels; @@ -29,6 +29,7 @@ public class RabbitInboundEventProvider : IRabbitInboundEventProvider public Func UnitLocationUpdated; public Func ProcessIncidentCommandUpdated; public Func ProcessChatEvent; + public Func ProcessChecklistEvent; public async Task Start(string clientName, string queueName) { @@ -172,6 +173,9 @@ await _channel.QueueBindAsync(queue: queue.QueueName, if (ProcessIncidentCommandUpdated != null) await ProcessIncidentCommandUpdated.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); break; + case EventingTypes.ChecklistUpdated: + if (ProcessChecklistEvent != null) await ProcessChecklistEvent.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); + break; case EventingTypes.ChatEvent: if (ProcessChatEvent != null) await ProcessChatEvent.Invoke(eventingMessage.DepartmentId, eventingMessage.Payload); @@ -236,6 +240,8 @@ public void RegisterForEvents(Func personnelStatusChanged, ProcessIncidentCommandUpdated = incidentCommandUpdated; } + public void RegisterForChecklistEvents(Func checklistEvent) => ProcessChecklistEvent = checklistEvent; + public void RegisterForChatEvents(Func chatEvent) { ProcessChatEvent = chatEvent; diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs index c546ad30a..be39e97b3 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using RabbitMQ.Client; using Resgrid.Config; using Resgrid.Framework; @@ -81,6 +81,11 @@ public async Task CallUpdated(CallUpdatedEvent message) }.SerializeJson()); } + public Task ChecklistUpdated(int departmentId, string completionId) => SendMessage(Topics.EventingTopic, new EventingMessage + { + Id = Guid.NewGuid(), Type = (int)EventingTypes.ChecklistUpdated, TimeStamp = DateTime.UtcNow, DepartmentId = departmentId, ItemId = completionId + }.SerializeJson()); + public async Task IncidentCommandUpdated(IncidentCommandUpdatedEvent message) { return await SendMessage(Topics.EventingTopic, new EventingMessage diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs index bf2af5dd2..a27ce34d1 100644 --- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs @@ -1,4 +1,4 @@ -using Resgrid.Config; +using Resgrid.Config; using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Queue; @@ -60,6 +60,13 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro _eventAggregator.AddListener(personnelLocationUpdatedTopicHandler); _eventAggregator.AddAsyncListener(unitLocationUpdatedTopicHandler); _eventAggregator.AddListener(chatEventTopicHandler); + _eventAggregator.AddAsyncListener(async message => + { + if (message.ProducerSubsystem != "Checklists" || message.IsReplay) 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."); + }); } public Action unitStatusHandler = async delegate (UnitStatusEvent message) diff --git a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs index 63b15c49e..c728e0ba5 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; @@ -1670,7 +1670,7 @@ public static void AddRecordClaims(ClaimsIdentity identity, bool isAdmin, List

+ /// Readiness Pro: Stripe USD 150/month, product prod_VDtkPNAa2qNBx3. + /// No base PlanId: the add-on has an independent monthly interval. Paddle EUR 195/month + /// is configured in PaymentProviderConfig.PaddleReadinessProAddon (product + /// pro_01m20xwmzpnkxzp7mm7nwwxp7p). Test IDs are deliberately unset. + /// + [Migration(190)] + public class M0190_SeedReadinessProAddon : Migration + { + private const string ReadinessProAddonId = "8a82f517-13db-4950-a514-d990248a67e6"; + + public override void Up() + { + Execute.Sql( + "IF NOT EXISTS (SELECT 1 FROM [PlanAddons] WHERE [PlanAddonId] = '" + ReadinessProAddonId + "' OR [AddonType] = 3) " + + "INSERT INTO [PlanAddons] ([PlanAddonId], [AddonType], [Cost], [ExternalId], [TestExternalId]) " + + "VALUES ('" + ReadinessProAddonId + "', 3, 150, 'price_0UDRwaqJFDZJcnkVnYP8bAcd', '');"); + } + + public override void Down() + { + // Do not remove an operator-owned or potentially billed row on rollback. Up preserves + // pre-existing Readiness Pro catalogs and safely tolerates reapplication. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0191_AddChecklistWorkflow.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0191_AddChecklistWorkflow.cs new file mode 100644 index 000000000..dbf1abe3e --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0191_AddChecklistWorkflow.cs @@ -0,0 +1,94 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(191)] + public class M0191_AddChecklistWorkflow : Migration + { + private static string N(string value) => value; + public override void Up() + { + foreach (var name in new[] { "ChecklistDefinitions", "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions", "ChecklistCompletionItems", "ChecklistCompletionFiles", "DepartmentChecklistSettings" }) + { + if (Schema.Table(N(name)).Exists()) continue; + var table = Create.Table(N(name)) + .WithColumn(N("Id")).AsString(36).PrimaryKey() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("ParentId")).AsString(36).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 "ChecklistDefinitions": + table.WithColumn(N("CurrentVersionId")).AsString(36).Nullable() + .WithColumn(N("PublishedVersion")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("Retired")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("DeletedOn")).AsDateTime2().Nullable(); break; + case "ChecklistDefinitionVersions": table.WithColumn(N("Version")).AsInt32().NotNullable(); break; + case "ChecklistOccurrences": + table.WithColumn(N("VersionId")).AsString(36).NotNullable() + .WithColumn(N("CompletionId")).AsString(36).NotNullable() + .WithColumn(N("TargetType")).AsInt32().NotNullable() + .WithColumn(N("TargetId")).AsString(128).NotNullable() + .WithColumn(N("State")).AsInt32().NotNullable(); break; + case "ChecklistCompletions": + table.WithColumn(N("VersionId")).AsString(36).NotNullable() + .WithColumn(N("OccurrenceId")).AsString(36).NotNullable() + .WithColumn(N("TargetType")).AsInt32().NotNullable() + .WithColumn(N("TargetId")).AsString(128).NotNullable() + .WithColumn(N("State")).AsInt32().NotNullable() + .WithColumn(N("SubmittedOn")).AsDateTime2().Nullable() + .WithColumn(N("WitnessUserId")).AsString(128).Nullable() + .WithColumn(N("TargetGroupId")).AsInt32().Nullable() + .WithColumn(N("WitnessedOn")).AsDateTime2().Nullable() + .WithColumn(N("Score")).AsDecimal(7,2).Nullable() + .WithColumn(N("Passed")).AsBoolean().NotNullable() + .WithColumn(N("SubmissionHash")).AsString(64).Nullable(); break; + case "ChecklistCompletionItems": table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("IsFailure")).AsBoolean().NotNullable(); break; + case "ChecklistCompletionFiles": + table.WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("ContentType")).AsString(100).NotNullable() + .WithColumn(N("Size")).AsInt32().NotNullable() + .WithColumn(N("Sha256")).AsString(64).NotNullable() + .WithColumn(N("Data")).AsBinary(int.MaxValue).NotNullable() + .WithColumn(N("ScanState")).AsInt32().NotNullable(); break; + } + Create.Index(N("UX_" + name + "_TenantId")).OnTable(N(name)).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("Id")).Ascending().WithOptions().Unique(); + Create.Index(N("IX_" + name + "_ParentDate")).OnTable(N(name)).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("ParentId")).Ascending().OnColumn(N("CreatedOn")).Descending(); + } + Unique("ChecklistDefinitionVersions", "Version", "ParentId", "Version"); + Unique("ChecklistCompletions", "Occurrence", "OccurrenceId"); + Unique("ChecklistOccurrences", "Completion", "CompletionId"); + Unique("ChecklistCompletionItems", "Item", "ParentId", "ItemId"); + Unique("DepartmentChecklistSettings", "Department"); + foreach (var child in new[] { "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions" }) Foreign(child, "ParentId", "ChecklistDefinitions"); + foreach (var child in new[] { "ChecklistOccurrences", "ChecklistCompletions" }) Foreign(child, "VersionId", "ChecklistDefinitionVersions"); + Foreign("ChecklistCompletions", "OccurrenceId", "ChecklistOccurrences"); + Foreign("ChecklistCompletionItems", "ParentId", "ChecklistCompletions"); + Foreign("ChecklistCompletionFiles", "ParentId", "ChecklistCompletions"); + } + private void Unique(string table, string suffix, params string[] columns) + { + var name = N("UX_" + table + "_" + suffix); + if (Schema.Table(N(table)).Index(name).Exists()) return; + var index = Create.Index(name).OnTable(N(table)).OnColumn(N("DepartmentId")).Ascending(); + foreach (var column in columns) index = index.OnColumn(N(column)).Ascending(); + index.WithOptions().Unique(); + } + private void Foreign(string child, string column, string parent) + { + var name = N("FK_" + child + "_" + column); + if (!Schema.Table(N(child)).Constraint(name).Exists()) + Create.ForeignKey(name).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N(column)).ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + } + public override void Down() + { + foreach (var name in new[] { "DepartmentChecklistSettings", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistDefinitionVersions", "ChecklistDefinitions" }) + if (Schema.Table(N(name)).Exists()) Delete.Table(N(name)); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0189_SeedReadinessFeatureFlagsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0189_SeedReadinessFeatureFlagsPg.cs new file mode 100644 index 000000000..9dfb956b8 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0189_SeedReadinessFeatureFlagsPg.cs @@ -0,0 +1,19 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(189)] + public class M0189_SeedReadinessFeatureFlagsPg : Migration + { + public override void Up() + { + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Checklists.System', 'Checklists', 'Free checklists for all departments. Independent of paid plans and Readiness Pro. Seeded off.', 'Readiness', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Checklists.System');"); + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Maintenance.WorkOrders', 'Readiness Pro', 'Maintenance and work orders rollout gate. Requires a separate active monthly Readiness Pro entitlement. Independent of Checklists.System. Seeded off.', 'Readiness', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Maintenance.WorkOrders');"); + } + + public override void Down() + { + // Preserve operator-owned settings, as in the SQL Server twin. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0190_SeedReadinessProAddonPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0190_SeedReadinessProAddonPg.cs new file mode 100644 index 000000000..80acb189a --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0190_SeedReadinessProAddonPg.cs @@ -0,0 +1,29 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + ///

+ /// Readiness Pro: Stripe USD 150/month, product prod_VDtkPNAa2qNBx3. + /// No base PlanId: the add-on has an independent monthly interval. Paddle EUR 195/month + /// is configured in PaymentProviderConfig.PaddleReadinessProAddon (product + /// pro_01m20xwmzpnkxzp7mm7nwwxp7p). Test IDs are deliberately unset. + /// + [Migration(190)] + public class M0190_SeedReadinessProAddonPg : Migration + { + private const string ReadinessProAddonId = "8a82f517-13db-4950-a514-d990248a67e6"; + + public override void Up() + { + Execute.Sql( + "INSERT INTO planaddons (planaddonid, addontype, cost, externalid, testexternalid) " + + "SELECT '" + ReadinessProAddonId + "', 3, 150, 'price_0UDRwaqJFDZJcnkVnYP8bAcd', '' " + + "WHERE NOT EXISTS (SELECT 1 FROM planaddons WHERE planaddonid = '" + ReadinessProAddonId + "' OR addontype = 3);"); + } + + public override void Down() + { + // Preserve the catalog and any billed references, as in the SQL Server twin. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0191_AddChecklistWorkflowPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0191_AddChecklistWorkflowPg.cs new file mode 100644 index 000000000..9ac08bdfd --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0191_AddChecklistWorkflowPg.cs @@ -0,0 +1,94 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(191)] + public class M0191_AddChecklistWorkflowPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + public override void Up() + { + foreach (var name in new[] { "ChecklistDefinitions", "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions", "ChecklistCompletionItems", "ChecklistCompletionFiles", "DepartmentChecklistSettings" }) + { + if (Schema.Table(N(name)).Exists()) continue; + var table = Create.Table(N(name)) + .WithColumn(N("Id")).AsString(36).PrimaryKey() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("ParentId")).AsString(36).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 "ChecklistDefinitions": + table.WithColumn(N("CurrentVersionId")).AsString(36).Nullable() + .WithColumn(N("PublishedVersion")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("Retired")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("DeletedOn")).AsDateTime2().Nullable(); break; + case "ChecklistDefinitionVersions": table.WithColumn(N("Version")).AsInt32().NotNullable(); break; + case "ChecklistOccurrences": + table.WithColumn(N("VersionId")).AsString(36).NotNullable() + .WithColumn(N("CompletionId")).AsString(36).NotNullable() + .WithColumn(N("TargetType")).AsInt32().NotNullable() + .WithColumn(N("TargetId")).AsString(128).NotNullable() + .WithColumn(N("State")).AsInt32().NotNullable(); break; + case "ChecklistCompletions": + table.WithColumn(N("VersionId")).AsString(36).NotNullable() + .WithColumn(N("OccurrenceId")).AsString(36).NotNullable() + .WithColumn(N("TargetType")).AsInt32().NotNullable() + .WithColumn(N("TargetId")).AsString(128).NotNullable() + .WithColumn(N("State")).AsInt32().NotNullable() + .WithColumn(N("SubmittedOn")).AsDateTime2().Nullable() + .WithColumn(N("WitnessUserId")).AsString(128).Nullable() + .WithColumn(N("TargetGroupId")).AsInt32().Nullable() + .WithColumn(N("WitnessedOn")).AsDateTime2().Nullable() + .WithColumn(N("Score")).AsDecimal(7,2).Nullable() + .WithColumn(N("Passed")).AsBoolean().NotNullable() + .WithColumn(N("SubmissionHash")).AsString(64).Nullable(); break; + case "ChecklistCompletionItems": table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("IsFailure")).AsBoolean().NotNullable(); break; + case "ChecklistCompletionFiles": + table.WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("ContentType")).AsString(100).NotNullable() + .WithColumn(N("Size")).AsInt32().NotNullable() + .WithColumn(N("Sha256")).AsString(64).NotNullable() + .WithColumn(N("Data")).AsBinary(int.MaxValue).NotNullable() + .WithColumn(N("ScanState")).AsInt32().NotNullable(); break; + } + Create.Index(N("UX_" + name + "_TenantId")).OnTable(N(name)).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("Id")).Ascending().WithOptions().Unique(); + Create.Index(N("IX_" + name + "_ParentDate")).OnTable(N(name)).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("ParentId")).Ascending().OnColumn(N("CreatedOn")).Descending(); + } + Unique("ChecklistDefinitionVersions", "Version", "ParentId", "Version"); + Unique("ChecklistCompletions", "Occurrence", "OccurrenceId"); + Unique("ChecklistOccurrences", "Completion", "CompletionId"); + Unique("ChecklistCompletionItems", "Item", "ParentId", "ItemId"); + Unique("DepartmentChecklistSettings", "Department"); + foreach (var child in new[] { "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions" }) Foreign(child, "ParentId", "ChecklistDefinitions"); + foreach (var child in new[] { "ChecklistOccurrences", "ChecklistCompletions" }) Foreign(child, "VersionId", "ChecklistDefinitionVersions"); + Foreign("ChecklistCompletions", "OccurrenceId", "ChecklistOccurrences"); + Foreign("ChecklistCompletionItems", "ParentId", "ChecklistCompletions"); + Foreign("ChecklistCompletionFiles", "ParentId", "ChecklistCompletions"); + } + private void Unique(string table, string suffix, params string[] columns) + { + var name = N("UX_" + table + "_" + suffix); + if (Schema.Table(N(table)).Index(name).Exists()) return; + var index = Create.Index(name).OnTable(N(table)).OnColumn(N("DepartmentId")).Ascending(); + foreach (var column in columns) index = index.OnColumn(N(column)).Ascending(); + index.WithOptions().Unique(); + } + private void Foreign(string child, string column, string parent) + { + var name = N("FK_" + child + "_" + column); + if (!Schema.Table(N(child)).Constraint(name).Exists()) + Create.ForeignKey(name).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N(column)).ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + } + public override void Down() + { + foreach (var name in new[] { "DepartmentChecklistSettings", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistDefinitionVersions", "ChecklistDefinitions" }) + if (Schema.Table(N(name)).Exists()) Delete.Table(N(name)); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs new file mode 100644 index 000000000..6d12c643c --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// Only the closed checklist table map can form identifiers. Every read/write is tenant scoped. + public class ChecklistRepository : RmsRepositoryBase, IChecklistRepository + { + public ChecklistRepository(IConnectionProvider connection, SqlConfiguration config, IUnitOfWork uow, IQueryFactory queries) : base(connection, config, uow, queries) { } + public Task LockDepartmentAsync(int departmentId, CancellationToken ct = default) => LockRecordsDepartmentAsync(departmentId, ct); + private static string Table() where T : ChecklistRow => ChecklistTables.All[typeof(T)]; + private static string[] Columns(bool includeData = true) where T : ChecklistRow => typeof(T).GetProperties() + .Where(p => p.CanWrite && !Attribute.IsDefined(p, typeof(NotMappedAttribute)) && (includeData || p.Name != "Data")).Select(p => p.Name).ToArray(); + public Task GetAsync(int departmentId, string id, CancellationToken ct = default) where T : ChecklistRow => + QueryFirstOrDefaultAsync($"SELECT {Cols(Columns())} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }, ct); + public async Task> ListAsync(int departmentId, string parentId = null, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow + { + if (skip < 0 || take < 1 || take > 500) throw new ArgumentOutOfRangeException(nameof(take)); + 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 WriteAsync(T row, bool insert, CancellationToken ct = default) where T : ChecklistRow + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist writes require a transaction."); + if (!insert && typeof(T) == typeof(ChecklistDefinitionVersion)) throw new InvalidOperationException("Published versions are immutable."); + var columns = Columns(); + var sql = insert ? $"INSERT INTO {Tbl(Table())} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})" + : $"UPDATE {Tbl(Table())} SET {string.Join(",", columns.Where(c => c != "Id" && c != "DepartmentId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id"; + if (await ExecuteAsync(sql, row, ct) != 1) throw new InvalidOperationException("Checklist row could not be saved."); + } + public async Task ReplaceAnswersAsync(int departmentId, string completionId, IEnumerable items, CancellationToken ct = default) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist writes require a transaction."); + await ExecuteAsync($"DELETE FROM {Tbl("ChecklistCompletionItems")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ParentId")}={P}Id", new { DepartmentId = departmentId, Id = completionId }, ct); + foreach (var item in items) + { + if (item.DepartmentId != departmentId || item.ParentId != completionId) throw new InvalidOperationException("Answer ownership mismatch."); + await WriteAsync(item, true, ct); + } + } + public Task DeleteFileAsync(int departmentId, string id, CancellationToken ct = default) => ExecuteAsync($"DELETE FROM {Tbl("ChecklistCompletionFiles")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }, ct); + public Task GetFileMetadataAsync(int departmentId, string id) => QueryFirstOrDefaultAsync( + $"SELECT {Cols(Columns(false))} FROM {Tbl("ChecklistCompletionFiles")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }); + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs index 901917e06..57c71e2bc 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Configuration; using System.Data; using Microsoft.Data.SqlClient; @@ -31,7 +31,17 @@ public async Task DeleteDepartmentAndUsersAsync(int departmentId) using (var transaction = db.BeginTransaction()) { var result = await db.ExecuteAsync(@" - DECLARE @UserId NVARCHAR(128) + IF OBJECT_ID('dbo.ChecklistDefinitions', 'U') IS NOT NULL + BEGIN + DELETE FROM [dbo].[ChecklistCompletionFiles] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[ChecklistCompletionItems] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[ChecklistCompletions] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[ChecklistOccurrences] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[ChecklistDefinitionVersions] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[ChecklistDefinitions] WHERE DepartmentId = @DepartmentId + DELETE FROM [dbo].[DepartmentChecklistSettings] WHERE DepartmentId = @DepartmentId + END + DECLARE @UserId NVARCHAR(128) DECLARE @UnitId INT DECLARE @ManagingUserId NVARCHAR(128) diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index 57103be56..99226f540 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -1,4 +1,4 @@ -using Autofac; +using Autofac; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; @@ -15,6 +15,7 @@ public class ApiDataModule : Module { protected override void Load(ContainerBuilder builder) { + 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 96b776299..043fb84c3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -1,4 +1,4 @@ -using Autofac; +using Autofac; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; @@ -15,6 +15,7 @@ public class DataModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); if (Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres) diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index d7dfdbeed..b71d900e8 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -1,4 +1,4 @@ -using Autofac; +using Autofac; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; @@ -15,6 +15,7 @@ public class NonWebDataModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); if (Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres) diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 6a09fef1d..4fdcabe23 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -15,6 +15,7 @@ public class TestingDataModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); if (Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres) diff --git a/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs b/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs index 44d83a09d..3a7d94ae5 100644 --- a/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs +++ b/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs @@ -22,6 +22,8 @@ public class IdentifierAllocationTests #region Registry test 1 — no duplicate values in append-only enums [TestCase(typeof(PermissionTypes))] + [TestCase(typeof(EventingTypes))] + [TestCase(typeof(PlanAddonTypes))] [TestCase(typeof(WorkflowTriggerEventType))] [TestCase(typeof(DepartmentSettingTypes))] [TestCase(typeof(Resgrid.Model.Events.EventTypes))] diff --git a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs index b29fc92c6..3c8b399db 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs @@ -254,6 +254,14 @@ public async Task Gate_flag_viewer_window_clamp_and_row_cap_apply_to_every_dashb (wide.End - wide.Start).TotalDays.Should().Be(RecordsAnalyticsLimits.MaxWindowDays); wide.Warnings.Should().ContainSingle(w => w.Contains("clamped")); + // The due-state read is capped on its own, so the overdue figure says it was cut short in its own words. + _dueStates.Setup(d => d.GetLastEmittedInRangeAsync(Dept, Start, End, It.IsAny())).ReturnsAsync((int dept, DateTime s, DateTime e, int take) => + Enumerable.Range(0, Math.Min(take, RecordsAnalyticsLimits.RowCap + 1)) + .Select(i => new RmsRecordDueState { RecordId = "d" + i, OverdueCount = 1, LastEmittedState = (int)RmsDueState.Overdue, LastEmittedOn = T0 }).ToList()); + var cappedDue = await _svc.GetExecutiveSummaryAsync(Dept, Admin, Q()); + cappedDue.WentOverdue.Should().Be(RecordsAnalyticsLimits.RowCap); + cappedDue.Warnings.Should().ContainSingle(w => w.Contains("due-state changes")); + for (var i = 0; i < RecordsAnalyticsLimits.RowCap + 1; i++) _records.Add(new RmsOperationalRecord { RmsOperationalRecordId = "x" + i, DepartmentId = Dept, DefinitionKey = RmsDefinitionKeys.Run, RecordType = 1, State = (int)RmsRecordState.Finalized, CurrentRevisionId = "rx" + i, StartedOn = T0, CreatedOn = T0, FinalizedOn = T0 }); var capped = await _svc.GetWorkloadAsync(Dept, Member, Q()); capped.Truncated.Should().BeTrue(); diff --git a/Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs b/Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs new file mode 100644 index 000000000..4800ced47 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ChecklistAuthorizationTests + { + private ChecklistAuthorizationService _service; + private Mock _departments; + private Mock _groups; + private Mock _permissions; + private Mock _units; + private DepartmentMember _member; + private readonly ChecklistActor _actor = new ChecklistActor { DepartmentId = 77, UserId = "member" }; + [SetUp] + public void Setup() + { + _member = new DepartmentMember { DepartmentId = 77, UserId = "member" }; + _departments = new Mock(); _groups = new Mock(); _permissions = new Mock(); _units = new Mock(); + _departments.Setup(d => d.GetDepartmentMemberAsync("member", 77, true)).ReturnsAsync(() => _member); + _departments.Setup(d => d.GetDepartmentByIdAsync(77, true)).ReturnsAsync(new Department { DepartmentId = 77, ManagingUserId = "owner", Name = "Department" }); + _groups.Setup(g => g.GetGroupForUserAsync("member", 77)).ReturnsAsync(new DepartmentGroup { DepartmentId = 77, DepartmentGroupId = 10, Members = new List { new DepartmentGroupMember { UserId = "member", IsAdmin = true } } }); + var roles = new Mock(); roles.Setup(r => r.GetRolesForUserAsync("member", 77)).ReturnsAsync(new List()); + _service = new ChecklistAuthorizationService(_departments.Object, _groups.Object, roles.Object, _permissions.Object, _units.Object, new Mock().Object, new Mock().Object); + } + [Test] + public async Task Group_admin_default_is_limited_to_their_group_and_does_not_allow_definition_management() + { + (await _service.CanManageAsync(_actor)).Should().BeFalse(); + var run = new ChecklistCompletion { DepartmentId = 77, CreatedBy = "other", TargetType = (int)ChecklistTargetType.Group, TargetId = "10" }; + (await _service.CanReadAsync(_actor, run)).Should().BeTrue(); run.TargetId = "20"; (await _service.CanReadAsync(_actor, run)).Should().BeFalse(); + _member.IsAdmin = true; (await _service.CanReadAsync(_actor, run)).Should().BeTrue(); (await _service.CanManageAsync(_actor)).Should().BeTrue(); + } + [Test] + public async Task Moving_or_removing_a_unit_does_not_reassign_its_historical_results_to_another_group() + { + var run = new ChecklistCompletion { DepartmentId = 77, CreatedBy = "other", TargetType = (int)ChecklistTargetType.Unit, TargetId = "9", TargetGroupId = 10 }; + _units.Setup(u => u.GetUnitByIdAsync(9)).ReturnsAsync(new Unit { UnitId = 9, DepartmentId = 77, StationGroupId = 20 }); + (await _service.CanReadAsync(_actor, run)).Should().BeTrue(); + _units.Verify(u => u.GetUnitByIdAsync(It.IsAny()), Times.Never); + run.TargetGroupId = 20; (await _service.CanReadAsync(_actor, run)).Should().BeFalse(); + } + [Test] + public async Task Explicit_permission_can_widen_results_but_cannot_widen_department_membership() + { + _permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(77, PermissionTypes.ViewChecklistResults)).ReturnsAsync(new Permission { Action = (int)PermissionActions.Everyone, LockToGroup = false }); + var run = new ChecklistCompletion { DepartmentId = 77, CreatedBy = "other", TargetType = 0, TargetId = "77" }; + (await _service.CanReadAsync(_actor, run)).Should().BeTrue(); run.DepartmentId = 88; (await _service.CanReadAsync(_actor, run)).Should().BeFalse(); + } + [Test] + public async Task Disabled_or_deleted_members_cannot_use_old_claims_or_read_their_own_history() + { + _member.IsDisabled = true; _member.IsAdmin = true; + Func read = () => _service.CanReadAsync(_actor, new ChecklistCompletion { DepartmentId = 77, CreatedBy = "member" }); + (await read.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + _member.IsDisabled = false; _member.IsDeleted = true; + (await read.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + } + [Test] + public async Task Unit_and_group_target_ids_cannot_cross_departments() + { + _member.IsAdmin = true; _units.Setup(u => u.GetUnitByIdAsync(9)).ReturnsAsync(new Unit { UnitId = 9, DepartmentId = 88, Name = "Foreign" }); + _groups.Setup(g => g.GetGroupByIdAsync(9, true)).ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 9, DepartmentId = 88, Name = "Foreign" }); + 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); + } + [TestCase(false, false, false, false)] + [TestCase(false, true, false, true)] + [TestCase(true, false, true, true)] + public void Existing_claim_issuers_get_checklist_defaults_from_the_shared_catalog(bool admin, bool groupAdmin, bool manage, bool view) + { + var identity = new ClaimsIdentity(); ClaimsLogic.AddRecordClaims(identity, admin, new List(), groupAdmin, new List()); + identity.HasClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update).Should().Be(manage); + identity.HasClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View).Should().Be(view); + } + [Test] + public void Permission_screen_defaults_match_the_service_group_boundary() + { + var rows = Resgrid.Web.Areas.User.Models.Security.RecordsPermissionRows.Build(new List(), ChecklistPermissionCatalog.All); + rows.Should().ContainSingle(r => r.Type == PermissionTypes.ViewChecklistResults && r.LockToGroup && r.ShowLockToGroup); + rows.Should().ContainSingle(r => r.Type == PermissionTypes.ManageChecklists && r.Value == (int)PermissionActions.DepartmentAdminsOnly); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistDatabaseTests.cs b/Tests/Resgrid.Tests/Services/ChecklistDatabaseTests.cs new file mode 100644 index 000000000..ba75b870f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistDatabaseTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator; +using FluentMigrator.Runner; +using FluentMigrator.Runner.Initialization; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Npgsql; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model.Checklists; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Providers.Migrations.Migrations; +using Resgrid.Providers.MigrationsPg.Migrations; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; +using Resgrid.Repositories.DataRepository.Transactions; + +namespace Resgrid.Tests.Services +{ + [TestFixture(DatabaseTypes.SqlServer), TestFixture(DatabaseTypes.Postgres), NonParallelizable] + public class ChecklistDatabaseTests + { + private readonly DatabaseTypes _type; + private DatabaseTypes _previous; + private string _master, _connection, _database; + private ServiceProvider _runner; + public ChecklistDatabaseTests(DatabaseTypes type) { _type = type; } + private DbConnection Connect(string connection) => _type == DatabaseTypes.Postgres ? new NpgsqlConnection(connection) : new SqlConnection(connection); + [OneTimeSetUp] + public async Task CreateIsolatedDatabaseAndMigrate() + { + _master = Environment.GetEnvironmentVariable(_type == DatabaseTypes.Postgres ? "RESGRID_CHECKLIST_POSTGRES_TEST_CONNECTION" : "RESGRID_CHECKLIST_SQLSERVER_TEST_CONNECTION"); + if (string.IsNullOrWhiteSpace(_master)) Assert.Ignore("Set the checklist test connection for " + _type + " to run real database verification."); + _previous = DataConfig.DatabaseType; DataConfig.DatabaseType = _type; + _database = "checklist_verification_" + Guid.NewGuid().ToString("N"); + await using var master = Connect(_master); await master.ExecuteAsync("CREATE DATABASE " + _database); + if (_type == DatabaseTypes.Postgres) { var builder = new NpgsqlConnectionStringBuilder(_master) { Database = _database }; _connection = builder.ConnectionString; } + else { var builder = new SqlConnectionStringBuilder(_master) { InitialCatalog = _database }; _connection = builder.ConnectionString; } + await using var db = Connect(_connection); + await db.ExecuteAsync(_type == DatabaseTypes.Postgres ? "CREATE TABLE departments(departmentid integer PRIMARY KEY); INSERT INTO departments VALUES(77),(88);" : "CREATE TABLE Departments(DepartmentId int PRIMARY KEY); INSERT INTO Departments VALUES(77),(88);"); + var source = new Mock(); source.Setup(s => s.GetMigrations()).Returns(new IMigration[] { _type == DatabaseTypes.Postgres ? new M0191_AddChecklistWorkflowPg() : new M0191_AddChecklistWorkflow() }); + _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => + { + if (_type == DatabaseTypes.Postgres) r.AddPostgres(); else r.AddSqlServer(); + r.WithGlobalConnectionString(_connection); + }).AddSingleton(source.Object).BuildServiceProvider(); + _runner.GetRequiredService().MigrateUp(); + } + [OneTimeTearDown] + public async Task RemoveOnlyThisFixturesDatabase() + { + if (_database == null) return; + _runner?.Dispose(); DataConfig.DatabaseType = _previous; + if (!_database.StartsWith("checklist_verification_", StringComparison.Ordinal) || !Guid.TryParseExact(_database.Substring(23), "N", out _)) throw new InvalidOperationException("Unexpected test database name."); + if (_type == DatabaseTypes.Postgres) NpgsqlConnection.ClearAllPools(); else SqlConnection.ClearAllPools(); + await using var master = Connect(_master); + await master.ExecuteAsync(_type == DatabaseTypes.Postgres ? "DROP DATABASE " + _database + " WITH (FORCE)" : "ALTER DATABASE " + _database + " SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE " + _database); + } + private IConnectionProvider Connections() + { var provider = new Mock(); provider.Setup(p => p.Create()).Returns(() => Connect(_connection)); return provider.Object; } + private SqlConfiguration Configuration() => _type == DatabaseTypes.Postgres ? new PostgreSqlConfiguration() : new SqlServerConfiguration(); + private ChecklistRepository Repository(IConnectionProvider provider, IUnitOfWork uow) => new ChecklistRepository(provider, Configuration(), uow, new Mock().Object); + private static T Row(string parent = null) where T : ChecklistRow, new() => new T { DepartmentId = 77, ParentId = parent, CreatedBy = "author", CreatedOn = DateTime.UtcNow, UpdatedOn = DateTime.UtcNow, Content = "{}" }; + [Test, Order(0)] + public void Migration_can_roll_back_and_reapply_in_an_empty_isolated_database() + { + var runner = _runner.GetRequiredService(); + runner.MigrateDown(0); runner.MigrateUp(); runner.MigrateUp(); + } + [Test] + public async Task Round_trip_versions_occurrences_answers_and_files_and_enforce_tenant_foreign_keys() + { + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + 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); + var completion = Row(definition.Id); completion.VersionId = version.Id; completion.TargetId = "77"; + var occurrence = Row(definition.Id); occurrence.VersionId = version.Id; occurrence.CompletionId = completion.Id; occurrence.TargetId = "77"; await store.WriteAsync(occurrence, true); + completion.OccurrenceId = occurrence.Id; await store.WriteAsync(completion, true); + var answer = Row(completion.Id); answer.ItemId = Guid.NewGuid().ToString(); await store.ReplaceAnswersAsync(77, completion.Id, new[] { answer }); + var file = Row(completion.Id); file.ItemId = answer.ItemId; file.ContentType = "image/png"; file.Data = new byte[] { 1, 2, 3 }; file.Size = 3; file.Sha256 = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(file.Data)); file.ScanState = 1; await store.WriteAsync(file, true); + uow.CommitChanges(); + (await store.GetAsync(88, completion.Id)).Should().BeNull(); + (await store.GetAsync(77, completion.Id)).VersionId.Should().Be(version.Id); + (await store.ListAsync(77, completion.Id)).Should().ContainSingle(); + (await store.ListAsync(77, completion.Id)).Single().Data.Should().BeNull("list reads must never fetch blobs"); + (await store.GetAsync(77, file.Id)).Data.Should().Equal(1, 2, 3); + await uow.CreateOrGetConnectionAsync(); var foreign = Row(definition.Id); foreign.DepartmentId = 88; foreign.Version = 2; + Func insert = () => store.WriteAsync(foreign, true); await insert.Should().ThrowAsync(); uow.DiscardChanges(); + } + [Test] + public async Task Rollback_removes_partial_data_and_duplicate_version_is_rejected() + { + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + var row = Row(); await store.WriteAsync(row, true); uow.DiscardChanges(); (await store.GetAsync(77, row.Id)).Should().BeNull(); + await uow.CreateOrGetConnectionAsync(); await store.WriteAsync(row, true); + var first = Row(row.Id); first.Version = 1; await store.WriteAsync(first, true); uow.CommitChanges(); + await uow.CreateOrGetConnectionAsync(); var duplicate = Row(row.Id); duplicate.Version = 1; + Func insert = () => store.WriteAsync(duplicate, true); await insert.Should().ThrowAsync(); uow.DiscardChanges(); + } + [Test] + public async Task Department_lock_serializes_two_writers_until_commit() + { + var connections = Connections(); using var first = new UnitOfWork(connections); using var second = new UnitOfWork(connections); + await first.CreateOrGetConnectionAsync(); await second.CreateOrGetConnectionAsync(); + await Repository(connections, first).LockDepartmentAsync(77); + var waiting = Repository(connections, second).LockDepartmentAsync(77); + (await Task.WhenAny(waiting, Task.Delay(200))).Should().NotBe(waiting); + first.CommitChanges(); await waiting.WaitAsync(TimeSpan.FromSeconds(10)); second.CommitChanges(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistLocalizationTests.cs b/Tests/Resgrid.Tests/Services/ChecklistLocalizationTests.cs new file mode 100644 index 000000000..e8de45e7d --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistLocalizationTests.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Resources; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Localization; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ChecklistLocalizationTests + { + public static IEnumerable Cultures => SupportedLocales.GetSupportedCultures(); + private static string ResourceDirectory() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) directory = directory.Parent; + return Path.Combine(directory?.FullName ?? throw new DirectoryNotFoundException("Repository root unavailable."), "Core", "Resgrid.Localization", "Areas", "User", "Checklists"); + } + private static Dictionary Read(string file) + { + var entries = XDocument.Load(file).Root.Elements("data").ToList(); + entries.Select(e => (string)e.Attribute("name")).Should().OnlyHaveUniqueItems(); + return entries.ToDictionary(e => (string)e.Attribute("name"), e => (string)e.Element("value"), StringComparer.Ordinal); + } + // These words have the same spelling in both languages; they are reviewed translations. + private static readonly Dictionary SharedSpellings = new Dictionary + { + ["de"] = new[] { "Name", "Version", "Optional" }, + ["es"] = new[] { "No" }, + ["fr"] = new[] { "Version", "Active", "Score", "Note", "Date", "Photo", "Signature", "Personnel", "Section {0}" }, + ["it"] = new[] { "No" }, + ["sv"] = new[] { "Version" } + }; + [TestCaseSource(nameof(Cultures))] + public void Supported_culture_has_complete_compiled_translations_without_English_placeholders(string culture) + { + var baseline = Read(Path.Combine(ResourceDirectory(), "Checklists.resx")); + var file = Path.Combine(ResourceDirectory(), "Checklists." + culture + ".resx"); + File.Exists(file).Should().BeTrue("every supported language needs its own dictionary, including Arabic"); + var translated = Read(file); + translated.Keys.Should().BeEquivalentTo(baseline.Keys); + var manager = new ResourceManager("Resgrid.Localization.Areas.User.Checklists.Checklists", typeof(SupportedLocales).Assembly); + var compiled = manager.GetResourceSet(CultureInfo.GetCultureInfo(culture), true, false); + compiled.Should().NotBeNull("the language resource must be included in the built assembly"); + var allowed = SharedSpellings.TryGetValue(culture, out var entries) ? entries : Array.Empty(); + foreach (var entry in translated) + { + entry.Value.Should().NotBeNullOrWhiteSpace(culture + ": " + entry.Key); + compiled.GetString(entry.Key).Should().Be(entry.Value, culture + ": " + entry.Key); + Regex.Matches(entry.Value, @"\{\d+\}").Select(m => m.Value).Should().BeEquivalentTo(Regex.Matches(baseline[entry.Key], @"\{\d+\}").Select(m => m.Value), "format arguments must survive translation: " + entry.Key); + if (culture != "en" && !allowed.Contains(entry.Key)) entry.Value.Should().NotBe(baseline[entry.Key], culture + " must translate " + entry.Key); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistTemplateServiceTests.cs b/Tests/Resgrid.Tests/Services/ChecklistTemplateServiceTests.cs new file mode 100644 index 000000000..ad6e77feb --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistTemplateServiceTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ChecklistTemplateServiceTests + { + [Test] + public void Catalog_has_stable_unique_item_and_section_identities_and_valid_types() + { + var templates = ChecklistTemplateCatalog.All; + templates.Count.Should().BeGreaterThanOrEqualTo(22); + templates.Select(t => t.Id).Should().OnlyHaveUniqueItems(); + var sections = templates.SelectMany(t => t.Sections).ToList(); + sections.Select(s => s.SectionId).Should().OnlyHaveUniqueItems(); + var items = sections.SelectMany(s => s.Items).ToList(); + items.Select(i => i.ItemId).Should().OnlyHaveUniqueItems(); + foreach (var template in templates) + { + template.Name.Should().NotBeNullOrWhiteSpace(); + template.Sections.Should().NotBeEmpty(); + Enum.IsDefined(template.SuggestedFrequency).Should().BeTrue(); + Enum.IsDefined(template.SuggestedTargetType).Should().BeTrue(); + } + foreach (var item in items) + { + Guid.TryParse(item.ItemId, out _).Should().BeTrue(); + item.Name.Should().NotBeNullOrWhiteSpace(); + Enum.IsDefined(item.Type).Should().BeTrue(); + if (item.Critical) + { + item.Required.Should().BeTrue(); + item.AllowNotApplicable.Should().BeFalse(); + item.RequireNoteOnFail.Should().BeTrue(); + } + } + } + + [TestCase("Fire")] + [TestCase("EMS")] + [TestCase("SAR")] + [TestCase("Emergency Management")] + [TestCase("Industry")] + [TestCase("Business")] + public void Catalog_covers_each_requested_sector(string sector) => ChecklistTemplateCatalog.Search(sector).Should().NotBeEmpty(); + + [Test] + public void Search_uses_all_terms_and_case_insensitive_identity() + { + ChecklistTemplateCatalog.Search("FoRkLiFt, brakes").Select(t => t.Id).Should().Equal("industrial-forklift"); + ChecklistTemplateCatalog.Search("forklift shelter").Should().BeEmpty(); + ChecklistTemplateCatalog.GetById("EMS-CONTROLLED-COUNT").RequiresIndependentWitness.Should().BeTrue(); + ChecklistTemplateCatalog.GetById("unknown").Should().BeNull(); + } + + [Test] + public async Task Service_denies_catalog_and_direct_template_when_department_is_disabled() + { + var access = new Mock(); + var service = new ChecklistTemplateService(access.Object); + (await service.SearchAsync(77)).Should().BeNull(); + (await service.GetByIdAsync(77, "fire-apparatus-daily")).Should().BeNull(); + access.Verify(x => x.CanUseChecklistsAsync(77), Times.Exactly(2)); + access.VerifyNoOtherCalls(); + } + + [Test] + public async Task Enabled_service_returns_catalog_without_a_maintenance_gate() + { + var access = new Mock(MockBehavior.Strict); + access.Setup(x => x.CanUseChecklistsAsync(77)).ReturnsAsync(true); + var service = new ChecklistTemplateService(access.Object); + (await service.SearchAsync(77, "shelter opening")).Single().Id.Should().Be("em-shelter"); + (await service.GetByIdAsync(77, "em-eoc")).Should().NotBeNull(); + Func oversized = () => service.SearchAsync(77, new string('x', 257)); + await oversized.Should().ThrowAsync(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistValidationTests.cs b/Tests/Resgrid.Tests/Services/ChecklistValidationTests.cs new file mode 100644 index 000000000..e7f5412a3 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistValidationTests.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ChecklistValidationTests + { + private static ChecklistForm Form(params ChecklistItem[] items) => new ChecklistForm { Name = "Readiness", Sections = { new ChecklistSection { Name = "Checks", Items = items.ToList() } } }; + private static ChecklistItem Item(ChecklistItemType type = ChecklistItemType.PassFail) => new ChecklistItem { Name = "Check", Type = type }; + private static ChecklistRunInput Answer(ChecklistItem item, string value, ChecklistAnswerStatus status = ChecklistAnswerStatus.Answered) => new ChecklistRunInput { Answers = { new ChecklistAnswer { ItemId = item.Id, Value = value, Status = status, Note = "Failure reason" } } }; + [TestCase(ChecklistItemType.PassFail, "pass", true)] + [TestCase(ChecklistItemType.PassFail, "fail", false)] + [TestCase(ChecklistItemType.YesNo, "true", true)] + [TestCase(ChecklistItemType.Checkbox, "false", false)] + [TestCase(ChecklistItemType.NumericReading, "5.5", true)] + [TestCase(ChecklistItemType.Quantity, "12", false)] + [TestCase(ChecklistItemType.FreeText, "Checked", true)] + [TestCase(ChecklistItemType.SelectList, "true", true)] + [TestCase(ChecklistItemType.DateValue, "2026-09-08", true)] + [TestCase(ChecklistItemType.Photo, null, true)] + [TestCase(ChecklistItemType.Signature, null, true)] + public void Every_answer_type_has_explicit_server_side_pass_semantics(ChecklistItemType type, string value, bool passed) + { + var item = Item(type); item.Minimum = 1; item.Maximum = 10; item.Options = new List { "true", "false" }; + var result = ChecklistValidation.Evaluate(Form(item), Answer(item, value), new HashSet { item.Id }, true); + result.Errors.Should().BeEmpty(); result.Passed.Should().Be(passed); + } + [Test] + public void Missing_answer_never_passes_and_all_na_has_no_score() + { + var item = Item(); item.Required = false; item.AllowNotApplicable = true; + var result = ChecklistValidation.Evaluate(Form(item), new ChecklistRunInput(), new HashSet(), true); result.Score.Should().Be(0); result.Passed.Should().BeFalse(); + var answer = Answer(item, null, ChecklistAnswerStatus.NotApplicable); answer.Answers[0].NotApplicableReason = "Not fitted"; + result = ChecklistValidation.Evaluate(Form(item), answer, new HashSet(), true); result.Errors.Should().BeEmpty(); result.Score.Should().BeNull(); result.Passed.Should().BeFalse(); + } + [Test] + public void Critical_failure_overrides_an_otherwise_passing_weighted_score() + { + var critical = Item(); critical.Critical = true; var other = Item(); other.Weight = 99; + var form = Form(critical, other); form.PassThreshold = 90; + var input = Answer(critical, "fail"); input.Answers.Add(Answer(other, "pass").Answers[0]); + var result = ChecklistValidation.Evaluate(form, input, new HashSet(), true); result.Score.Should().Be(99); result.Passed.Should().BeFalse(); + } + [Test] + public void Zero_threshold_does_not_turn_an_unanswered_checklist_into_a_pass() + { + var item = Item(); item.Required = false; + var form = Form(item); form.PassThreshold = 0; + ChecklistValidation.Evaluate(form, new ChecklistRunInput(), new HashSet(), true).Passed.Should().BeFalse(); + } + [Test] + public void Na_requires_a_reason_and_permission_and_cannot_carry_a_value() + { + var item = Item(); var input = Answer(item, "pass", ChecklistAnswerStatus.NotApplicable); + ChecklistValidation.Evaluate(Form(item), input, new HashSet(), true).Errors.Should().NotBeEmpty(); + item.AllowNotApplicable = true; input.Answers[0].NotApplicableReason = "Not fitted"; + ChecklistValidation.Evaluate(Form(item), input, new HashSet(), true).Errors.Should().NotBeEmpty(); + } + [Test] + public void Conditions_control_visibility_and_requiredness_without_evaluating_code() + { + var source = Item(); var conditional = Item(); conditional.Required = false; conditional.VisibleWhen = new ChecklistCondition { ItemId = source.Id, EqualsValue = "fail" }; conditional.RequiredWhen = new ChecklistCondition { ItemId = source.Id, EqualsValue = "fail" }; + var form = Form(source, conditional); ChecklistValidation.Validate(form).Should().BeEmpty(); + ChecklistValidation.Evaluate(form, Answer(source, "pass"), new HashSet(), true).Passed.Should().BeTrue(); + ChecklistValidation.Evaluate(form, Answer(source, "fail"), new HashSet(), true).Errors.Should().Contain(e => e.Contains("required")); + source.VisibleWhen = new ChecklistCondition { ItemId = conditional.Id, EqualsValue = "pass" }; ChecklistValidation.Validate(form).Should().NotBeEmpty(); + } + [Test] + public void Duplicated_ids_unknown_ids_invalid_values_and_oversized_forms_are_rejected() + { + var item = Item(); var form = Form(item, item); ChecklistValidation.Validate(form).Should().NotBeEmpty(); + form = Form(item); var input = Answer(item, "maybe"); ChecklistValidation.Evaluate(form, input, new HashSet(), true).Errors.Should().NotBeEmpty(); + input.Answers[0].ItemId = Guid.NewGuid().ToString(); ChecklistValidation.Evaluate(form, input, new HashSet(), true).Errors.Should().NotBeEmpty(); + form.Sections[0].Items = Enumerable.Range(0, 251).Select(_ => Item()).ToList(); ChecklistValidation.Validate(form).Should().NotBeEmpty(); + } + [Test] + public void Applying_templates_copies_content_and_generates_department_owned_ids() + { + foreach (var template in ChecklistTemplateCatalog.All) + { + var form = ChecklistForm.FromTemplate(template); ChecklistValidation.Validate(form).Should().BeEmpty(template.Name); + form.Sections.Select(s => s.Id).Intersect(template.Sections.Select(s => s.SectionId)).Should().BeEmpty(); + form.Sections.SelectMany(s => s.Items).Select(i => i.Id).Intersect(template.Sections.SelectMany(s => s.Items).Select(i => i.ItemId)).Should().BeEmpty(); + } + } + [Test] + public void Every_persisted_content_slot_and_binary_has_a_catalog_and_upgrade_binding() + { + var catalog = new ProtectedFieldCatalog(); var bindings = AdpTableBindings.ForVersionRange(catalog, 13, 14); + foreach (var table in ChecklistTables.All.Values) + { + bindings.Should().ContainSingle(b => b.TableName == table && b.DepartmentColumn == "DepartmentId" && b.ProtectedMarkerColumn == "IsProtected"); + bindings.Single(b => b.TableName == table).Columns.Should().Contain(c => c.FieldId == table.ToLowerInvariant() + ".content"); + } + bindings.Single(b => b.TableName == "ChecklistCompletionFiles").Columns.Should().Contain(c => c.FieldId == "checklistcompletionfiles.data"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistWorkflowTests.cs b/Tests/Resgrid.Tests/Services/ChecklistWorkflowTests.cs new file mode 100644 index 000000000..a57dc9ae0 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistWorkflowTests.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +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.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ChecklistWorkflowTests + { + private readonly ChecklistActor _actor = new ChecklistActor { DepartmentId = 77, UserId = "author" }; + private MemoryStore _store; + private Mock _access; + private Mock _authorization; + private Mock _audits; + private Mock _outbox; + private Mock _write; + private Mock _read; + private Mock _scanner; + private Mock _uow; + private ChecklistsService _service; + private List _events; + [SetUp] + public void Setup() + { + _store = new MemoryStore(); _events = new List(); + _access = new Mock(); _access.Setup(s => s.CanUseChecklistsAsync(77)).ReturnsAsync(true); + _authorization = new Mock(); + _authorization.Setup(s => s.RequireMemberAsync(It.IsAny())).Returns(Task.CompletedTask); + _authorization.Setup(s => s.CanManageAsync(It.IsAny())).ReturnsAsync(true); + _authorization.Setup(s => s.CanReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, ChecklistCompletion c) => c != null && c.DepartmentId == a.DepartmentId && (c.CreatedBy == a.UserId || c.WitnessUserId == a.UserId)); + _authorization.Setup(s => s.TargetAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, ChecklistTargetType t, string id) => new ChecklistTarget { Type = t, Id = id, Name = "Test target" }); + _audits = new Mock(); _audits.SetReturnsDefault(Task.FromResult(new AuditLog())); + _outbox = new Mock(); + _outbox.Setup(s => s.EnqueueAsync(77, "Checklists", It.IsAny(), It.IsAny())).ReturnsAsync((int d, string p, DomainEventEnvelope e, CancellationToken c) => { _events.Add(e); return new DomainEventOutboxEntry { DomainEventOutboxId = _events.Count }; }); + _read = new Mock(); _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult())); + _write = new Mock(); _write.SetReturnsDefault(Task.FromResult(ProtectedWriteResult.Allowed())); + _scanner = new Mock(); + _uow = new Mock(); _uow.Setup(u => u.CreateOrGetConnectionAsync(It.IsAny())).ReturnsAsync(() => { _store.Begin(); return (DbConnection)null; }); + _uow.Setup(u => u.DiscardChanges()).Callback(() => _store.Rollback()); + _service = new ChecklistsService(_store, _authorization.Object, _access.Object, _uow.Object, _audits.Object, _outbox.Object, new Lazy(() => _read.Object), new Lazy(() => _write.Object), _scanner.Object); + } + private static ChecklistForm Form(bool witness = false) => new ChecklistForm { Name = "Shift readiness", RequiresIndependentWitness = witness, Sections = { new ChecklistSection { Name = "Safety", Items = { new ChecklistItem { Name = "Equipment works", Critical = true }, new ChecklistItem { Name = "Fuel adequate" } } } } }; + private async Task<(string Definition, string Run, ChecklistForm Form)> Start(bool witness = false) + { + var form = Form(witness); var definition = await _service.SaveDefinitionAsync(_actor, null, 0, form); + await _service.PublishAsync(_actor, definition, 1); + var run = await _service.StartAsync(_actor, definition, "77", Guid.NewGuid().ToString()); return (definition, run, form); + } + private static ChecklistRunInput Answers(ChecklistForm form, string value = "pass") => new ChecklistRunInput { Revision = 1, + Answers = form.Sections.SelectMany(s => s.Items).Select(i => new ChecklistAnswer { ItemId = i.Id, Status = ChecklistAnswerStatus.Answered, Value = value, Note = value == "fail" ? "Removed from service; supervisor notified" : null }).ToList() }; + [Test] + public async Task Published_version_is_pinned_and_draft_edits_do_not_change_a_started_run() + { + var run = await Start(); var view = await _service.GetRunAsync(_actor, run.Run); var version = view.Completion.VersionId; + run.Form.Name = "Changed draft"; run.Form.TargetType = ChecklistTargetType.Personnel; + await _service.SaveDefinitionAsync(_actor, run.Definition, 2, run.Form); + var detail = await _service.GetDefinitionAsync(_actor, run.Definition); + detail.Form.TargetType.Should().Be(ChecklistTargetType.Personnel); detail.PublishedForm.TargetType.Should().Be(ChecklistTargetType.Department); + await _service.PublishAsync(_actor, run.Definition, 3); + view = await _service.GetRunAsync(_actor, run.Run); view.Form.Name.Should().Be("Shift readiness"); view.Completion.VersionId.Should().Be(version); + } + [Test] + public async Task Create_edit_run_fail_history_audit_and_workflow_are_connected() + { + var run = await Start(); var input = Answers(run.Form, "fail"); + await _service.SaveRunAsync(_actor, run.Run, input, true); + var result = await _service.GetRunAsync(_actor, run.Run); + result.Completion.State.Should().Be((int)ChecklistRunState.Submitted); result.Completion.Score.Should().Be(0); result.Completion.Passed.Should().BeFalse(); + result.Input.Answers.Should().HaveCount(2); (await _service.HistoryAsync(_actor, run.Definition)).Should().ContainSingle(); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.ChecklistFailed).Should().Be(2); _events.Count(e => e.Trigger == WorkflowTriggerEventType.ChecklistCompleted).Should().Be(1); + JsonConvert.SerializeObject(_events).Should().NotContain("Removed from service").And.NotContain("Equipment works"); + _audits.Verify(a => a.InsertAsync(It.Is(l => l.LogType == (int)AuditLogTypes.ChecklistCompletionSubmitted && l.ObjectId == run.Run), It.IsAny(), It.IsAny()), Times.Once); + } + [Test] + public async Task A_failed_required_note_leaves_no_partial_submission_or_events() + { + var run = await Start(); var input = Answers(run.Form, "fail"); input.Answers[0].Note = null; + Func submit = () => _service.SaveRunAsync(_actor, run.Run, input, true); + (await submit.Should().ThrowAsync()).Which.StatusCode.Should().Be(400); + var current = await _service.GetRunAsync(_actor, run.Run); current.Completion.State.Should().Be(0); current.Input.Answers.Should().BeEmpty(); _events.Should().BeEmpty(); + } + [Test] + public async Task Stale_progress_cannot_overwrite_newer_answers() + { + var run = await Start(); var input = Answers(run.Form); await _service.SaveRunAsync(_actor, run.Run, input, false); + input.Answers[0].Value = "fail"; + Func stale = () => _service.SaveRunAsync(_actor, run.Run, input, false); + (await stale.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + (await _service.GetRunAsync(_actor, run.Run)).Input.Answers[0].Value.Should().Be("pass"); + } + [Test] + public async Task Identical_terminal_retry_is_idempotent_and_changed_payload_conflicts() + { + var run = await Start(); var input = Answers(run.Form); var revision = await _service.SaveRunAsync(_actor, run.Run, input, true); + (await _service.SaveRunAsync(_actor, run.Run, input, true)).Should().Be(revision); _events.Should().ContainSingle(); + input.Note = "Changed after submission"; + Func changed = () => _service.SaveRunAsync(_actor, run.Run, input, true); + (await changed.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + } + [Test] + public async Task Repeated_start_returns_the_same_occurrence_but_cannot_change_target() + { + var run = await Start(); (await _service.StartAsync(_actor, run.Definition, "77", run.Run)).Should().Be(run.Run); + (await _store.ListAsync(77)).Should().ContainSingle(); + Func changed = () => _service.StartAsync(_actor, run.Definition, "different", run.Run); + (await changed.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + } + [Test] + public async Task Author_cannot_witness_and_a_distinct_authenticated_witness_finalizes_once() + { + var run = await Start(true); await _service.SaveRunAsync(_actor, run.Run, Answers(run.Form), true); + var current = await _service.GetRunAsync(_actor, run.Run); current.Completion.State.Should().Be(1); _events.Should().BeEmpty(); + Func own = () => _service.WitnessAsync(_actor, run.Run, current.Completion.SubmissionHash, "I verified the count."); + (await own.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + var witness = new ChecklistActor { DepartmentId = 77, UserId = "witness" }; + Func unauthorized = () => _service.WitnessAsync(witness, run.Run, current.Completion.SubmissionHash, "Verified"); + (await unauthorized.Should().ThrowAsync()).Which.StatusCode.Should().Be(404); + _authorization.Setup(a => a.CanReadAsync(witness, It.IsAny())).ReturnsAsync(true); + await _service.WitnessAsync(witness, run.Run, current.Completion.SubmissionHash, "I independently verified the count."); + await _service.WitnessAsync(witness, run.Run, current.Completion.SubmissionHash, "I independently verified the count."); + current = await _service.GetRunAsync(_actor, run.Run); current.Completion.WitnessUserId.Should().Be("witness"); current.Completion.State.Should().Be(2); _events.Should().ContainSingle(); + } + [Test] + public async Task Historical_reads_survive_flag_disable_but_writes_stop() + { + var run = await Start(); _access.Setup(s => s.CanUseChecklistsAsync(77)).ReturnsAsync(false); + (await _service.GetRunAsync(_actor, run.Run)).Should().NotBeNull(); (await _service.HistoryAsync(_actor, run.Definition)).Should().ContainSingle(); + Func write = () => _service.SaveRunAsync(_actor, run.Run, Answers(run.Form), false); + (await write.Should().ThrowAsync()).Which.StatusCode.Should().Be(404); + } + [Test] + public async Task Another_department_cannot_fetch_run_or_definition_by_known_id() + { + var run = await Start(); var foreign = new ChecklistActor { DepartmentId = 88, UserId = "author" }; + Func read = () => _service.GetRunAsync(foreign, run.Run); (await read.Should().ThrowAsync()).Which.StatusCode.Should().Be(404); + read = () => _service.GetDefinitionAsync(foreign, run.Definition); (await read.Should().ThrowAsync()).Which.StatusCode.Should().Be(404); + } + [Test] + public async Task Audit_failure_rolls_back_answers_and_completion_without_dispatch() + { + var run = await Start(); _outbox.Invocations.Clear(); + _audits.Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("Audit unavailable")); + Func save = () => _service.SaveRunAsync(_actor, run.Run, Answers(run.Form), false); await save.Should().ThrowAsync(); + var current = await _service.GetRunAsync(_actor, run.Run); current.Completion.Revision.Should().Be(1); current.Input.Answers.Should().BeEmpty(); + _outbox.Verify(o => o.DispatchAfterCommitAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + [Test] + public async Task Protected_write_denial_does_not_persist_plaintext() + { + _write.SetReturnsDefault(Task.FromResult(ProtectedWriteResult.Blocked("broker_unavailable"))); + Func save = () => _service.SaveDefinitionAsync(_actor, null, 0, Form()); (await save.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + (await _store.ListAsync(77)).Should().BeEmpty(); + } + [Test] + public async Task Redacted_definition_cannot_be_round_tripped_as_an_edit() + { + var run = await Start(); _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { RedactedFields = { "checklistdefinitions.content" } })); + Func save = () => _service.SaveDefinitionAsync(_actor, run.Definition, 2, run.Form); (await save.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + } + [Test] + public async Task Retirement_stops_new_runs_but_preserves_versions_and_existing_runs() + { + var run = await Start(); await _service.RetireAsync(_actor, run.Definition, 2); + Func start = () => _service.StartAsync(_actor, run.Definition, "77", Guid.NewGuid().ToString()); (await start.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + await _service.SaveRunAsync(_actor, run.Run, Answers(run.Form), true); (await _service.GetRunAsync(_actor, run.Run)).Completion.State.Should().Be(2); + } + [Test] + public async Task Images_need_a_clean_scan_and_scan_rejection_leaves_no_file() + { + var run = await Start(); byte[] png; + using (var image = new SixLabors.ImageSharp.Image(1, 1)) + using (var stream = new System.IO.MemoryStream()) { image.Save(stream, new SixLabors.ImageSharp.Formats.Png.PngEncoder()); png = stream.ToArray(); } + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Skipped }); + Func upload = () => _service.AddFileAsync(_actor, run.Run, run.Form.Sections[0].Items[0].Id, "evidence.png", "image/png", png); + (await upload.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); (await _store.ListAsync(77)).Should().BeEmpty(); + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Clean }); + await upload(); await upload(); (await _store.ListAsync(77)).Should().ContainSingle(); + var view = await _service.GetRunAsync(_actor, run.Run); var input = Answers(run.Form); input.Revision = view.Completion.Revision; await _service.SaveRunAsync(_actor, run.Run, input, true); + Func remove = () => _service.DeleteFileAsync(_actor, view.Files[0].Id); (await remove.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); + } + internal sealed class MemoryStore : IChecklistRepository + { + private Dictionary<(Type, string), string> _rows = new Dictionary<(Type, string), string>(); + private Dictionary<(Type, string), string> _backup; + public void Begin() => _backup = new Dictionary<(Type, string), string>(_rows); + public void Rollback() { if (_backup != null) _rows = new Dictionary<(Type, string), string>(_backup); } + public Task LockDepartmentAsync(int departmentId, CancellationToken ct = default) => Task.CompletedTask; + public Task GetAsync(int departmentId, string id, CancellationToken ct = default) where T : ChecklistRow + { + var row = id != null && _rows.TryGetValue((typeof(T), id), out var json) ? JsonConvert.DeserializeObject(json) : null; + return Task.FromResult(row?.DepartmentId == departmentId ? row : null); + } + public Task> ListAsync(int departmentId, string parentId = null, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow => Task.FromResult(_rows.Where(p => p.Key.Item1 == typeof(T)).Select(p => JsonConvert.DeserializeObject(p.Value)).Where(r => r.DepartmentId == departmentId && (parentId == null || r.ParentId == parentId)).OrderByDescending(r => r.CreatedOn).Skip(skip).Take(take).ToList()); + public Task WriteAsync(T row, bool insert, CancellationToken ct = default) where T : ChecklistRow { var key = (typeof(T), row.Id); if (insert && _rows.ContainsKey(key)) throw new InvalidOperationException("Duplicate ID"); _rows[key] = JsonConvert.SerializeObject(row); return Task.CompletedTask; } + public async Task ReplaceAnswersAsync(int departmentId, string completionId, IEnumerable items, CancellationToken ct = default) + { foreach (var old in await ListAsync(departmentId, completionId, take: 500)) _rows.Remove((typeof(ChecklistCompletionItem), old.Id)); foreach (var item in items) await WriteAsync(item, true, ct); } + public Task DeleteFileAsync(int departmentId, string id, CancellationToken ct = default) { _rows.Remove((typeof(ChecklistCompletionFile), id)); return Task.CompletedTask; } + public async Task GetFileMetadataAsync(int departmentId, string id) { var file = await GetAsync(departmentId, id); if (file != null) file.Data = null; return file; } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ReadinessAccessServiceTests.cs b/Tests/Resgrid.Tests/Services/ReadinessAccessServiceTests.cs new file mode 100644 index 000000000..e4db58838 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ReadinessAccessServiceTests.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture, NonParallelizable] + public class ReadinessAccessServiceTests + { + private const int DepartmentId = 77; + private Mock _flags; + private Mock _settings; + private Mock _billing; + private ReadinessAccessService _service; + private string _billingUrl; + private string _billingKey; + private DepartmentModuleSettings _modules; + private PaymentAddon _payment; + + [SetUp] + public void SetUp() + { + _billingUrl = Resgrid.Config.SystemBehaviorConfig.BillingApiBaseUrl; + _billingKey = Resgrid.Config.ApiConfig.BackendInternalApikey; + Resgrid.Config.SystemBehaviorConfig.BillingApiBaseUrl = "https://billing.example.invalid"; + Resgrid.Config.ApiConfig.BackendInternalApikey = "unit-test-only"; + _flags = new Mock(); + _settings = new Mock(); + _billing = new Mock(MockBehavior.Strict); + _modules = new DepartmentModuleSettings(); + _settings.Setup(s => s.GetDepartmentModuleSettingsAsync(DepartmentId, false)).ReturnsAsync(() => _modules); + SetFlag(FeatureFlagKeys.ChecklistsSystem, true); + SetFlag(FeatureFlagKeys.MaintenanceWorkOrders, true); + _payment = new PaymentAddon + { + DepartmentId = DepartmentId, PlanAddonId = "readiness-monthly", TransactionId = "paid-invoice", + EffectiveOn = DateTime.UtcNow.AddDays(-1), EndingOn = DateTime.UtcNow.AddDays(20) + }; + _billing.Setup(s => s.GetAllAddonPlansByTypeAsync(PlanAddonTypes.ReadinessPro)).ReturnsAsync(new List + { + new PlanAddon { PlanAddonId = _payment.PlanAddonId, AddonType = (int)PlanAddonTypes.ReadinessPro } + }); + _billing.Setup(s => s.GetCurrentPaymentAddonsForDepartmentAsync(DepartmentId, + It.Is>(ids => ids.Count == 1 && ids[0] == "readiness-monthly"))) + .ReturnsAsync(() => new List { _payment }); + _service = new ReadinessAccessService(_flags.Object, _settings.Object, _billing.Object); + } + + [TearDown] + public void TearDown() + { + Resgrid.Config.SystemBehaviorConfig.BillingApiBaseUrl = _billingUrl; + Resgrid.Config.ApiConfig.BackendInternalApikey = _billingKey; + } + + private void SetFlag(string key, bool enabled) => _flags.Setup(f => f.IsEnabledAsync(key, DepartmentId, false, null)).ReturnsAsync(enabled); + + [Test] + public async Task Free_checklists_never_query_billing_or_require_maintenance() + { + Resgrid.Config.SystemBehaviorConfig.BillingApiBaseUrl = null; + Resgrid.Config.ApiConfig.BackendInternalApikey = null; + SetFlag(FeatureFlagKeys.MaintenanceWorkOrders, false); + _modules.MaintenanceDisabled = true; + (await _service.CanUseChecklistsAsync(DepartmentId)).Should().BeTrue(); + _billing.VerifyNoOtherCalls(); + } + + [TestCase(false, false, false)] + [TestCase(true, true, false)] + [TestCase(false, true, false)] + [TestCase(true, false, true)] + public async Task Checklist_flag_and_module_are_both_required(bool flag, bool disabled, bool expected) + { + SetFlag(FeatureFlagKeys.ChecklistsSystem, flag); + _modules.ChecklistsDisabled = disabled; + (await _service.CanUseChecklistsAsync(DepartmentId)).Should().Be(expected); + _billing.VerifyNoOtherCalls(); + } + + [Test] + public async Task Maintenance_is_independent_of_the_checklist_flag_and_module() + { + SetFlag(FeatureFlagKeys.ChecklistsSystem, false); + _modules.ChecklistsDisabled = true; + (await _service.CanUseMaintenanceAsync(DepartmentId)).Should().BeTrue(); + } + + [TestCase(false, false)] + [TestCase(true, true)] + public async Task Maintenance_rollout_and_module_short_circuit_billing(bool flag, bool disabled) + { + SetFlag(FeatureFlagKeys.MaintenanceWorkOrders, flag); + _modules.MaintenanceDisabled = disabled; + (await _service.CanUseMaintenanceAsync(DepartmentId)).Should().BeFalse(); + _billing.VerifyNoOtherCalls(); + } + + [TestCase(null, "key")] + [TestCase(" ", "key")] + [TestCase("https://billing.example.invalid", null)] + public async Task Unconfigured_billing_cannot_grant_a_synthetic_free_entitlement(string url, string key) + { + Resgrid.Config.SystemBehaviorConfig.BillingApiBaseUrl = url; + Resgrid.Config.ApiConfig.BackendInternalApikey = key; + (await _service.CanUseMaintenanceAsync(DepartmentId)).Should().BeFalse(); + _billing.VerifyNoOtherCalls(); + } + + [TestCase("wrong-department")] + [TestCase("wrong-addon")] + [TestCase("future")] + [TestCase("expired")] + [TestCase("missing-start")] + [TestCase("system")] + [TestCase("forever")] + [TestCase("null")] + public async Task Invalid_payment_does_not_grant_access(string kind) + { + switch (kind) + { + case "wrong-department": _payment.DepartmentId++; break; + case "wrong-addon": _payment.PlanAddonId = "adp"; break; + case "future": _payment.EffectiveOn = DateTime.UtcNow.AddDays(1); break; + case "expired": _payment.EndingOn = DateTime.UtcNow.AddSeconds(-1); break; + case "missing-start": _payment.EffectiveOn = default; break; + case "system": _payment.TransactionId = "system"; break; + case "forever": _payment.EndingOn = DateTime.MaxValue; break; + case "null": _payment = null; break; + } + (await _service.CanUseMaintenanceAsync(DepartmentId)).Should().BeFalse(); + } + + [Test] + public async Task Cancellation_preserves_paid_time_but_not_expired_time() + { + _payment.IsCancelled = true; + _payment.CancelledOn = DateTime.UtcNow.AddHours(-1); + (await _service.CanUseMaintenanceAsync(DepartmentId)).Should().BeTrue(); + _payment.EndingOn = DateTime.UtcNow.AddSeconds(-1); + (await _service.CanUseMaintenanceAsync(DepartmentId)).Should().BeFalse(); + } + + [TestCase(1)] + [TestCase(2)] + public async Task Other_addon_catalogs_cannot_grant_maintenance(int addonType) + { + _billing.Setup(s => s.GetAllAddonPlansByTypeAsync(PlanAddonTypes.ReadinessPro)).ReturnsAsync(new List + { + new PlanAddon { AddonType = addonType, PlanAddonId = "readiness-monthly" }, null + }); + (await _service.CanUseMaintenanceAsync(DepartmentId)).Should().BeFalse(); + _billing.Verify(s => s.GetCurrentPaymentAddonsForDepartmentAsync(It.IsAny(), It.IsAny>()), Times.Never); + } + + [TestCase(true)] + [TestCase(false)] + public async Task Missing_billing_payloads_fail_closed(bool missingCatalog) + { + if (missingCatalog) + _billing.Setup(s => s.GetAllAddonPlansByTypeAsync(PlanAddonTypes.ReadinessPro)).ReturnsAsync((List)null); + else + _billing.Setup(s => s.GetCurrentPaymentAddonsForDepartmentAsync(DepartmentId, It.IsAny>())).ReturnsAsync((List)null); + (await _service.CanUseMaintenanceAsync(DepartmentId)).Should().BeFalse(); + } + + [Test] + public async Task Billing_outage_denies_maintenance_and_does_not_affect_checklists() + { + _billing.Setup(s => s.GetAllAddonPlansByTypeAsync(PlanAddonTypes.ReadinessPro)).ThrowsAsync(new TimeoutException("Test outage")); + (await _service.CanUseMaintenanceAsync(DepartmentId)).Should().BeFalse(); + (await _service.CanUseChecklistsAsync(DepartmentId)).Should().BeTrue(); + } + + [TestCase(0)] + [TestCase(-1)] + public async Task Invalid_department_ids_do_not_query_dependencies(int departmentId) + { + (await _service.CanUseChecklistsAsync(departmentId)).Should().BeFalse(); + (await _service.CanUseMaintenanceAsync(departmentId)).Should().BeFalse(); + _flags.VerifyNoOtherCalls(); + _settings.VerifyNoOtherCalls(); + _billing.VerifyNoOtherCalls(); + } + + [Test] + public async Task Missing_module_settings_fail_closed() + { + _modules = null; + (await _service.CanUseChecklistsAsync(DepartmentId)).Should().BeFalse(); + (await _service.CanUseMaintenanceAsync(DepartmentId)).Should().BeFalse(); + _billing.VerifyNoOtherCalls(); + } + + [TestCase(PlanFrequency.Yearly)] + [TestCase(PlanFrequency.Monthly)] + [TestCase(PlanFrequency.Never)] + public void Readiness_period_estimate_is_monthly_regardless_of_base_plan(PlanFrequency frequency) + { + var addon = new PlanAddon { AddonType = (int)PlanAddonTypes.ReadinessPro, Plan = new Plan { Frequency = (int)frequency } }; + var before = DateTime.UtcNow.AddMonths(1); + var actual = addon.GetEndDateFromNow(); + actual.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow.AddMonths(1)); + addon.GetAddonName().Should().Be("Readiness Pro"); + } + + [Test] + public void Addon_identifiers_preserve_existing_products() + { + ((int)PlanAddonTypes.PTT).Should().Be(1); + ((int)PlanAddonTypes.ADP).Should().Be(2); + ((int)PlanAddonTypes.ReadinessPro).Should().Be(3); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ReadinessApiTests.cs b/Tests/Resgrid.Tests/Services/ReadinessApiTests.cs new file mode 100644 index 000000000..da52e1033 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ReadinessApiTests.cs @@ -0,0 +1,98 @@ +using System.Diagnostics; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; + +namespace Resgrid.Tests.Services +{ + [TestFixture, NonParallelizable] + public class ReadinessApiTests + { + private HttpContextAccessor _context; + private IHttpContextAccessor _previousAccessor; + private Activity _activity; + + [SetUp] + public void SetUp() + { + _context = new HttpContextAccessor + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, "user-77"), + new Claim(ClaimTypes.PrimaryGroupSid, "77") + }, "test")) + } + }; + _previousAccessor = Resgrid.Web.ServicesCore.Helpers.ClaimsAuthorizationHelper._httpContextAccessor; + Resgrid.Web.ServicesCore.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = _context; + _activity = new Activity("ReadinessApiTests").Start(); + } + + [TearDown] + public void TearDown() + { + _activity.Dispose(); + Resgrid.Web.ServicesCore.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = _previousAccessor; + } + + [Test] + public async Task Catalog_requests_use_the_authenticated_department_and_standard_envelope() + { + var service = new Mock(MockBehavior.Strict); + service.Setup(x => x.SearchAsync(77, "shelter opening")).ReturnsAsync(ChecklistTemplateCatalog.Search("shelter opening")); + var controller = new ChecklistsController(service.Object); + var response = (await controller.GetChecklistTemplates("shelter opening")).Value; + response.Status.Should().Be("success"); + response.Version.Should().Be("v4"); + response.PageSize.Should().Be(1); + response.Data[0].Id.Should().Be("em-shelter"); + service.VerifyAll(); + } + + [Test] + public async Task Disabled_catalog_and_unknown_template_return_not_found() + { + var service = new Mock(); + var controller = new ChecklistsController(service.Object); + (await controller.GetChecklistTemplates()).Result.Should().BeOfType(); + (await controller.GetChecklistTemplate("unknown")).Result.Should().BeOfType(); + } + + [Test] + public async Task Invalid_requests_are_rejected_before_catalog_lookup() + { + var service = new Mock(MockBehavior.Strict); + var controller = new ChecklistsController(service.Object); + (await controller.GetChecklistTemplates(new string('x', 257))).Result.Should().BeOfType(); + (await controller.GetChecklistTemplate(null)).Result.Should().BeOfType(); + service.VerifyNoOtherCalls(); + } + + [Test] + public async Task Access_contract_keeps_free_checklists_independent_and_publishes_monthly_regional_prices() + { + var access = new Mock(MockBehavior.Strict); + access.Setup(x => x.CanUseChecklistsAsync(77)).ReturnsAsync(true); + access.Setup(x => x.CanUseMaintenanceAsync(77)).ReturnsAsync(false); + var response = (await new ReadinessController(access.Object).GetAccess()).Value; + response.Data.ChecklistsEnabled.Should().BeTrue(); + response.Data.MaintenanceEnabled.Should().BeFalse(); + response.Data.ProductName.Should().Be("Readiness Pro"); + response.Data.BillingInterval.Should().Be("month"); + response.Data.CheckoutAvailable.Should().BeFalse(); + response.Data.Offers.Should().ContainSingle(x => x.Region == "US" && x.Provider == "Stripe" && x.Currency == "USD" && x.MonthlyAmount == 150m); + response.Data.Offers.Should().ContainSingle(x => x.Region == "EU" && x.Provider == "Paddle" && x.Currency == "EUR" && x.MonthlyAmount == 195m); + access.VerifyAll(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ReadinessProBillingMappingTests.cs b/Tests/Resgrid.Tests/Services/ReadinessProBillingMappingTests.cs new file mode 100644 index 000000000..9dae8e3ae --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ReadinessProBillingMappingTests.cs @@ -0,0 +1,123 @@ +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Subscription; +using Resgrid.Web.Options; + +namespace Resgrid.Tests.Services +{ + [TestFixture, NonParallelizable] + public class ReadinessProBillingMappingTests + { + private bool _previousTestMode; + private string _previousPaddleTestPrice; + private string _previousBillingUrl; + private string _previousBillingKey; + + [SetUp] + public void SetUp() + { + _previousTestMode = PaymentProviderConfig.IsTestMode; + _previousPaddleTestPrice = PaymentProviderConfig.PaddleReadinessProAddonTest; + _previousBillingUrl = SystemBehaviorConfig.BillingApiBaseUrl; + _previousBillingKey = ApiConfig.BackendInternalApikey; + } + + [TearDown] + public void TearDown() + { + PaymentProviderConfig.IsTestMode = _previousTestMode; + PaymentProviderConfig.PaddleReadinessProAddonTest = _previousPaddleTestPrice; + SystemBehaviorConfig.BillingApiBaseUrl = _previousBillingUrl; + ApiConfig.BackendInternalApikey = _previousBillingKey; + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Stripe_test_mode_does_not_fall_back_to_the_supplied_live_price(string testPrice) + { + PaymentProviderConfig.IsTestMode = true; + var addon = new PlanAddon + { + AddonType = (int)PlanAddonTypes.ReadinessPro, + ExternalId = "price_0UDRwaqJFDZJcnkVnYP8bAcd", TestExternalId = testPrice + }; + addon.GetExternalKey().Should().BeNull(); + } + + [Test] + public void Stripe_selects_the_price_for_the_current_environment() + { + var addon = new PlanAddon + { + AddonType = (int)PlanAddonTypes.ReadinessPro, + ExternalId = "price_0UDRwaqJFDZJcnkVnYP8bAcd", TestExternalId = "price_test_fixture" + }; + PaymentProviderConfig.IsTestMode = true; + addon.GetExternalKey().Should().Be("price_test_fixture"); + PaymentProviderConfig.IsTestMode = false; + addon.GetExternalKey().Should().Be("price_0UDRwaqJFDZJcnkVnYP8bAcd"); + } + + [Test] + public void Paddle_selects_the_price_for_the_current_environment_without_a_live_fallback() + { + PaymentProviderConfig.IsTestMode = true; + PaymentProviderConfig.PaddleReadinessProAddonTest = ""; + PaymentProviderConfig.GetPaddleReadinessProAddonPriceId().Should().BeEmpty(); + PaymentProviderConfig.PaddleReadinessProAddonTest = "pri_test_fixture"; + PaymentProviderConfig.GetPaddleReadinessProAddonPriceId().Should().Be("pri_test_fixture"); + PaymentProviderConfig.IsTestMode = false; + PaymentProviderConfig.GetPaddleReadinessProAddonPriceId().Should().Be("pri_01m20xy5x54j0sp4mcydcm4q6m"); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Legacy_purchase_get_and_post_cannot_sell_Readiness_Pro_as_PTT(bool post) + { + var billing = new Mock(MockBehavior.Strict); + billing.Setup(s => s.GetPlanAddonByIdAsync("readiness")).ReturnsAsync(new PlanAddon + { + PlanAddonId = "readiness", AddonType = (int)PlanAddonTypes.ReadinessPro + }); + var controller = new SubscriptionController(Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), billing.Object, + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Options.Create(new AppOptions()), Mock.Of(), Mock.Of()); + + var result = post + ? await controller.BuyAddon(new BuyAddonView { PlanAddonId = "readiness", Quantity = 1 }, CancellationToken.None) + : await controller.BuyAddon("readiness"); + result.Should().BeOfType(); + billing.Verify(s => s.GetPlanAddonByIdAsync("readiness"), Times.Once); + billing.VerifyNoOtherCalls(); + } + + [TestCase(false)] + [TestCase(true)] + public async Task PTT_service_methods_reject_Readiness_before_constructing_a_billing_request(bool paddle) + { + // This would fail URI validation if either method tried to construct a billing client. + SystemBehaviorConfig.BillingApiBaseUrl = "http://[invalid"; + ApiConfig.BackendInternalApikey = "unit-test-only"; + var service = new SubscriptionsService(null, null, null, null, null, null, null, null); + var addon = new PlanAddon { AddonType = (int)PlanAddonTypes.ReadinessPro }; + var result = paddle + ? await service.ModifyPaddlePTTAddonSubscriptionAsync("customer", 1, addon) + : await service.ModifyPTTAddonSubscriptionAsync("customer", 1, addon); + result.Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/checklist-localization.test.cjs b/Tests/Resgrid.Tests/Web/checklist-localization.test.cjs new file mode 100644 index 000000000..d1f56204f --- /dev/null +++ b/Tests/Resgrid.Tests/Web/checklist-localization.test.cjs @@ -0,0 +1,69 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('./browser-launch.cjs').playwright(); +const root = path.resolve(__dirname, '../../..'); +const script = path.join(root, 'Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js'); +const cultures = [...fs.readFileSync(path.join(root, 'Core/Resgrid.Localization/SupportedLocales.cs'), 'utf8').matchAll(/\{"([a-z]{2})",/g)].map(match => match[1]); +const json = value => JSON.stringify(value).replace(/ ({ Id: suffix.padStart(8, '0') + '-1111-1111-1111-111111111111', Name: 'User-authored question ' + suffix, Type: type, Required: true, Critical: false, AllowNotApplicable: true, Weight: 1, Options: [] }); + +(async () => { + const browser = await chromium.launch(require('./browser-launch.cjs').launchOptions()); + try { + assert.ok(cultures.length > 0); + assert.ok(cultures.includes('ar'), 'Arabic is part of the supported locale registry'); + for (const culture of cultures) { + const page = await browser.newPage(); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await page.route('https://checklist-locales.test/**', route => route.fulfill({ contentType: 'text/html', body: '' })); + await page.goto('https://checklist-locales.test/'); + const resourceXml = fs.readFileSync(path.join(root, 'Core/Resgrid.Localization/Areas/User/Checklists/Checklists.' + culture + '.resx'), 'utf8'); + const translations = await page.evaluate(xml => Object.fromEntries([...new DOMParser().parseFromString(xml, 'application/xml').querySelectorAll('data')].map(entry => [entry.getAttribute('name'), entry.querySelector('value').textContent])), resourceXml); + const translate = key => { assert.ok(translations[key], culture + ': missing ' + key); return translations[key]; }; + const first = { ...makeItem('1', 0), Critical: true }; + const custom = { ...makeItem('2', 6), Options: ['Yes', 'No'], PassingValue: 'Yes', VisibleWhen: { ItemId: first.Id, EqualsValue: 'fail' } }; + const definition = { Name: 'User-owned title', Category: 0, TargetType: 0, PassThreshold: 100, Sections: [{ Id: id, Name: 'User-owned section', Items: [first, custom] }] }; + const localeScript = ``; + await page.setContent(`
${localeScript}`); + await page.evaluate(() => { window.saved = null; window.fetch = async (_, options) => { saved = JSON.parse(options.body.get('formJson')); return { ok: true, json: async () => ({ revision: 2 }) }; }; }); + await page.addScriptTag({ path: script }); + assert.equal(await page.getByLabel(translate('Checklist name'), { exact: true }).inputValue(), definition.Name); + assert.equal(await page.getByLabel(translate('Answer type'), { exact: true }).first().locator('option:checked').textContent(), translate('Pass / Fail')); + assert.equal(await page.getByLabel(translate('Category'), { exact: true }).locator('option:checked').textContent(), translate('Start of shift')); + const condition = page.getByLabel(translate('Answer that activates this condition'), { exact: true }); + assert.equal(await condition.inputValue(), 'fail'); + assert.equal(await condition.locator('option:checked').textContent(), translate('Fail')); + await condition.selectOption('pass'); + await page.locator('#save').click(); + assert.equal(await page.evaluate(() => saved.Sections[0].Items[1].VisibleWhen.EqualsValue), 'pass', 'Translated condition labels preserve protocol values'); + assert.deepEqual(await page.evaluate(() => saved.Sections[0].Items[1].Options), ['Yes', 'No'], 'User-defined choices are not translated'); + + const reading = { ...makeItem('3', 3), Units: 'kg', Minimum: 1, Maximum: 5 }; + const signature = makeItem('4', 9); + const run = { Completion: { Id: id }, Form: { ...definition, RequireLocation: true, Sections: [{ Id: id, Name: definition.Sections[0].Name, Items: [first, reading, { ...custom, VisibleWhen: null }, signature] }] }, Input: { Revision: 1, Answers: [] }, Files: [] }; + await page.setContent(`
${localeScript}`); + await page.evaluate(() => { window.saved = null; window.fetch = async (_, options) => { saved = JSON.parse(options.body.get('inputJson')); return { ok: true, json: async () => ({ revision: 2 }) }; }; }); + await page.addScriptTag({ path: script }); + await page.getByLabel(translate('Answer status'), { exact: true }).first().selectOption('1'); + const answer = page.getByLabel(translate('Answer'), { exact: true }).first(); + await answer.selectOption('fail'); + assert.equal(await answer.locator('option:checked').textContent(), translate('Fail')); + const customAnswer = page.getByLabel(translate('Answer'), { exact: true }).nth(1); + assert.equal(await customAnswer.locator('option[value="Yes"]').textContent(), 'Yes'); + assert.equal(await page.getByLabel(translate('Answer') + ' (kg)', { exact: true }).count(), 1); + assert.equal(await page.getByLabel(translate('Reported latitude (required)'), { exact: true }).count(), 1); + assert.equal(await page.getByLabel(translate('Signature drawing area. Alternatively upload an image.'), { exact: true }).count(), 1); + await page.getByRole('button', { name: translate('Save signature image'), exact: true }).click(); + assert.equal(await page.locator('#checklist-error').textContent(), translate('Draw a signature first.')); + await page.locator('#save').click(); + await page.locator('#checklist-saved:not([hidden])').waitFor(); + assert.equal(await page.locator('#checklist-saved').textContent(), translate('Progress saved.')); + assert.equal(await page.evaluate(() => saved.Answers[0].Value), 'fail'); + assert.deepEqual(errors, [], culture); + await page.close(); + } + console.log('PASS: all supported languages render translated checklist controls, condition choices, required labels, signature accessibility, errors and save notices; response codes and user-authored text are preserved.'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/Tests/Resgrid.Tests/Web/checklists.test.cjs b/Tests/Resgrid.Tests/Web/checklists.test.cjs new file mode 100644 index 000000000..c7100468c --- /dev/null +++ b/Tests/Resgrid.Tests/Web/checklists.test.cjs @@ -0,0 +1,67 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { chromium } = require('./browser-launch.cjs').playwright(); +const script = path.resolve(__dirname, '../../../Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js'); +const itemId = '11111111-1111-1111-1111-111111111111'; +const otherId = '22222222-2222-2222-2222-222222222222'; +const item = { Id: itemId, Name: 'Equipment works', Type: 0, Required: true, Critical: true, AllowNotApplicable: true, RequireNoteOnFail: true, Weight: 1, PassingValue: 'true', Options: [] }; +const definition = { Name: 'Shift readiness', Category: 0, TargetType: 0, PassThreshold: 100, Sections: [{ Id: '33333333-3333-3333-3333-333333333333', Name: 'Safety', Items: [item] }] }; +const htmlJson = value => JSON.stringify(value).replace(/ { + const browser = await chromium.launch(require('./browser-launch.cjs').launchOptions()); + try { + const page = await browser.newPage(); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await page.route('https://checklists.test/**', route => route.fulfill({ contentType: 'text/html', body: '' })); + await page.goto('https://checklists.test/'); + await page.setContent(`
`); + await page.evaluate(() => { window.requests = []; window.fetch = async (url, options) => { requests.push({ url, fields: Object.fromEntries(options.body.entries()) }); return { ok: true, status: 200, json: async () => ({ revision: 2 }) }; }; }); + await page.addScriptTag({ path: script }); + await page.getByLabel('Checklist name', { exact: true }).fill('Warehouse readiness'); + await page.getByLabel('Answer type', { exact: true }).selectOption('3'); + await page.getByLabel('Minimum passing value (optional if maximum is set)', { exact: true }).fill('5'); + await page.getByLabel('Maximum passing value (optional if minimum is set)', { exact: true }).fill('10'); + await page.getByRole('button', { name: 'Add item', exact: true }).click(); + await page.getByLabel('Question / check', { exact: true }).nth(1).fill(''); + await page.getByRole('button', { name: 'Move item up', exact: true }).nth(1).click(); + await page.getByRole('button', { name: 'Save draft', exact: true }).click(); + const posted = await page.evaluate(() => requests[0].fields); + const saved = JSON.parse(posted.formJson); + assert.equal(saved.Name, 'Warehouse readiness'); + assert.equal(saved.Sections[0].Items[1].Id, itemId, 'Reordering preserves item identity'); + assert.equal(saved.Sections[0].Items[1].Minimum, 5); + assert.equal(saved.Sections[0].Items[1].Type, 3, 'Type selectors serialize enum numbers'); + assert.equal(posted.__RequestVerificationToken, 'csrf'); + assert.equal(await page.evaluate(() => window.pwned), undefined); + + const conditional = { ...item, Id: otherId, Name: 'Failure detail', Type: 5, Critical: false, Required: false, VisibleWhen: { ItemId: itemId, EqualsValue: 'fail' }, RequiredWhen: { ItemId: itemId, EqualsValue: 'fail' } }; + const run = { Completion: { Id: '44444444-4444-4444-4444-444444444444' }, Form: { ...definition, Sections: [{ ...definition.Sections[0], Items: [item, conditional] }] }, Input: { Revision: 1, Answers: [], Note: null }, Files: [] }; + await page.setContent(`
`); + await page.evaluate(() => { window.requests = []; window.fetch = (url, options) => new Promise(resolve => requests.push({ url, fields: Object.fromEntries(options.body.entries()), resolve })); }); + await page.addScriptTag({ path: script }); + assert.equal(await page.getByText('Failure detail', { exact: true }).isVisible(), false); + await page.getByLabel('Answer status', { exact: true }).nth(0).selectOption('1'); + await page.getByLabel('Answer', { exact: true }).nth(0).selectOption('fail'); + assert.equal(await page.getByText('Failure detail', { exact: true }).isVisible(), true); + await page.getByLabel('Answer status', { exact: true }).nth(1).selectOption('1'); + await page.getByLabel('Answer', { exact: true }).nth(1).fill('Fault isolated'); + await page.getByLabel('Answer', { exact: true }).nth(0).selectOption('pass'); + assert.equal(await page.getByText('Failure detail', { exact: true }).isVisible(), false); + await page.getByRole('button', { name: 'Save progress', exact: true }).click(); + await page.getByRole('button', { name: 'Save progress', exact: true }).click(); + assert.equal(await page.evaluate(() => requests.length), 1, 'Only one save may be in flight'); + const first = JSON.parse(await page.evaluate(() => requests[0].fields.inputJson)); + assert.equal(first.Answers[1].Status, 0); assert.equal(first.Answers[1].Value, null, 'Hidden answers are cleared before posting'); + await page.getByLabel('Completion / handover note', { exact: true }).fill('Typed while save is in flight'); + await page.evaluate(() => requests[0].resolve({ ok: true, status: 200, json: async () => ({ revision: 2 }) })); + await page.getByRole('button', { name: 'Save progress', exact: true }).click(); + const second = JSON.parse(await page.evaluate(() => requests[1].fields.inputJson)); + assert.equal(second.Revision, 2); assert.equal(second.Note, 'Typed while save is in flight'); + await page.evaluate(() => requests[1].resolve({ ok: false, status: 409, json: async () => ({ message: 'Reload before saving.' }) })); + await page.locator('#checklist-error:not([hidden])').waitFor(); + assert.match(await page.locator('#checklist-error').textContent(), /Reload/); + assert.equal(await page.getByLabel('Completion / handover note', { exact: true }).inputValue(), 'Typed while save is in flight'); + assert.deepEqual(errors, []); + console.log('PASS: builder types and ordering, escaped content, conditional visibility, serialized saves, revision updates, CSRF, conflict preserves unsaved work.'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/Web/Resgrid.Web.Eventing/Worker.cs b/Web/Resgrid.Web.Eventing/Worker.cs index 744ec31f4..e3078cc00 100644 --- a/Web/Resgrid.Web.Eventing/Worker.cs +++ b/Web/Resgrid.Web.Eventing/Worker.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting; using OpenIddict.Abstractions; using static OpenIddict.Abstractions.OpenIddictConstants; using System.Threading; @@ -53,6 +53,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken = def IncidentCommandUpdated); _rabbitInboundEventProvider.RegisterForChatEvents(ChatEventReceived); + _rabbitInboundEventProvider.RegisterForChecklistEvents((departmentId, id) => _eventingHub.Clients.Group(departmentId.ToString()).SendAsync("checklistUpdated", id)); await StartProviderAsync(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistsController.cs new file mode 100644 index 000000000..4bb0e09cd --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistsController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Checklists; +using System.Threading.Tasks; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Authorize] + public class ChecklistsController : V4AuthenticatedApiControllerbase + { + private readonly IChecklistTemplateService _templates; + public ChecklistsController(IChecklistTemplateService templates) => _templates = templates; + + /// Searches the free starter catalog for the authenticated department. + [HttpGet("GetChecklistTemplates")] + public async Task> GetChecklistTemplates(string query = null) + { + if (query?.Length > 256) + return BadRequest("Search must be 256 characters or fewer."); + var templates = await _templates.SearchAsync(DepartmentId, query); + if (templates == null) + return NotFound(); + var result = new ChecklistTemplatesResult { Data = templates, PageSize = templates.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Returns a starter template preview, including critical checks and witness requirements. + [HttpGet("GetChecklistTemplate")] + public async Task> GetChecklistTemplate(string templateId) + { + if (string.IsNullOrWhiteSpace(templateId) || templateId.Length > 80) + return BadRequest("A valid template ID is required."); + var template = await _templates.GetByIdAsync(DepartmentId, templateId); + if (template == null) + return NotFound(); + var result = new ChecklistTemplateResult { Data = template, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ReadinessController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ReadinessController.cs new file mode 100644 index 000000000..ca73560bd --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/ReadinessController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Checklists; +using System.Threading.Tasks; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Authorize] + public class ReadinessController : V4AuthenticatedApiControllerbase + { + private readonly IReadinessAccessService _access; + public ReadinessController(IReadinessAccessService access) => _access = access; + + /// Independent feature availability and monthly offers. A flag alone is not a paid entitlement. + [HttpGet("GetAccess")] + public async Task> GetAccess() + { + var result = new ReadinessAccessResult + { + Data = new ReadinessAccessData + { + ChecklistsEnabled = await _access.CanUseChecklistsAsync(DepartmentId), + MaintenanceEnabled = await _access.CanUseMaintenanceAsync(DepartmentId) + }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs index 8254ab4ed..9c619f70a 100644 --- a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using Autofac; using System.Security.Claims; using CommonServiceLocator; @@ -11,6 +11,8 @@ public static class ClaimsAuthorizationHelper { public static IHttpContextAccessor _httpContextAccessor; + 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() { if (GetClaimsPrincipal().Identity.IsAuthenticated) diff --git a/Web/Resgrid.Web.Services/Models/v4/Checklists/ChecklistTemplateResults.cs b/Web/Resgrid.Web.Services/Models/v4/Checklists/ChecklistTemplateResults.cs new file mode 100644 index 000000000..826ce7fde --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Checklists/ChecklistTemplateResults.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Resgrid.Model.Checklists; + +namespace Resgrid.Web.Services.Models.v4.Checklists +{ + public class ChecklistTemplatesResult : StandardApiResponseV4Base + { + public IReadOnlyList Data { get; set; } + public string Guidance { get; set; } = ChecklistTemplateCatalog.Guidance; + } + + public class ChecklistTemplateResult : StandardApiResponseV4Base + { + public ChecklistTemplate Data { get; set; } + public string Guidance { get; set; } = ChecklistTemplateCatalog.Guidance; + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Checklists/ReadinessAccessResult.cs b/Web/Resgrid.Web.Services/Models/v4/Checklists/ReadinessAccessResult.cs new file mode 100644 index 000000000..672c1497f --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Checklists/ReadinessAccessResult.cs @@ -0,0 +1,30 @@ +namespace Resgrid.Web.Services.Models.v4.Checklists +{ + public class ReadinessAccessResult : StandardApiResponseV4Base + { + public ReadinessAccessData Data { get; set; } + } + + public class ReadinessAccessData + { + public bool ChecklistsEnabled { get; set; } + public bool MaintenanceEnabled { get; set; } + public string ProductName { get; set; } = "Readiness Pro"; + public string BillingInterval { get; set; } = "month"; + // Do not advertise checkout before the P2-M1 purchase and reconciliation flow ships. + public bool CheckoutAvailable => false; + public ReadinessProOffer[] Offers { get; set; } = new[] + { + new ReadinessProOffer { Region = "US", Provider = "Stripe", Currency = "USD", MonthlyAmount = Config.ReadinessProConfig.StripeMonthlyAmount }, + new ReadinessProOffer { Region = "EU", Provider = "Paddle", Currency = "EUR", MonthlyAmount = Config.ReadinessProConfig.PaddleMonthlyAmount } + }; + } + + public class ReadinessProOffer + { + public string Region { get; set; } + public string Provider { get; set; } + public string Currency { get; set; } + public decimal MonthlyAmount { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 610aad997..c04ce0b43 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1120,6 +1120,12 @@ The call identifier to inspect. + + Searches the free starter catalog for the authenticated department. + + + Returns a starter template preview, including critical checks and witness requirements. + Command definitions: predefined incident-command templates (swimlanes) per call type, used to @@ -2749,6 +2755,9 @@ while the Incident Commander/PIO has public sharing enabled. Disabling sharing revokes the token immediately. + + Independent feature availability and monthly offers. A flag alone is not a paid entitlement. + RMS-6 records analytics (RMS plan section 6, RMS-6): response-performance, workload, executive, accreditation and diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index cb8c28659..f17693a84 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Autofac; using Microsoft.AspNetCore.Builder; @@ -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.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)); options.AddPolicy(ResgridResources.RecordDefinition_Publish, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordDefinition, ResgridClaimTypes.Actions.Publish)); options.AddPolicy(ResgridResources.RecordReport_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordReport, ResgridClaimTypes.Actions.Update)); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs new file mode 100644 index 000000000..40c22e2d9 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs @@ -0,0 +1,154 @@ +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.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Checklists; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + [Area("User")] + [Authorize] + public class ChecklistsController : SecureBaseController + { + private readonly IChecklistTemplateService _templates; + private readonly IChecklistsService _checklists; + private readonly IReadinessAccessService _access; + private readonly IProtectedGrantContext _grant; + private readonly IDepartmentDataProtectionService _protection; + private readonly IStringLocalizer _strings; + public ChecklistsController(IChecklistTemplateService templates, IChecklistsService checklists, IReadinessAccessService access, IProtectedGrantContext grant, IDepartmentDataProtectionService protection, IStringLocalizer strings) + { _templates = templates; _checklists = checklists; _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"; + ViewBag.ProtectionEnforced = await _protection.IsProtectionEnforcedAsync(DepartmentId); + ViewBag.ProtectedGrant = _grant.GrantToken; + ViewBag.GrantExpiresOn = HttpProtectedGrantContext.ReadExpiry(Request); + ViewBag.ChecklistUserId = UserId; + ViewBag.ChecklistsEnabled = await _access.CanUseChecklistsAsync(DepartmentId); + var executed = await next(); + if (executed.Exception is ChecklistException ex) + { + executed.ExceptionHandled = true; + if (HttpMethods.IsGet(Request.Method) && ex.StatusCode == 403 && ex.Message.StartsWith("Unlock protected", StringComparison.Ordinal)) + executed.Result = View("Locked", new ChecklistLockedView { Page = context.RouteData.Values["action"]?.ToString(), Id = Request.Query["id"].ToString() is { Length: > 0 } queryId ? queryId : context.RouteData.Values["id"]?.ToString() }); + else executed.Result = StatusCode(ex.StatusCode, new { message = ex.Message }); + } + } + [HttpGet] + public async Task Index(int page = 0) => View("Index", new ChecklistIndexView { Definitions = await _checklists.ListAsync(Actor, page), CanManage = await _checklists.CanManageAsync(Actor) && await _access.CanUseChecklistsAsync(DepartmentId), Page = page }); + [HttpGet] + public async Task Templates(string query = null) + { + if (query?.Length > 256) return BadRequest(); + var templates = await _templates.SearchAsync(DepartmentId, query); + if (templates == null) return NotFound(); + return View(new ChecklistTemplatesView { Templates = templates, Query = query }); + } + [HttpGet] + public async Task Template(string id) + { + if (string.IsNullOrWhiteSpace(id) || id.Length > 80) return NotFound(); + var template = await _templates.GetByIdAsync(DepartmentId, id); + if (template == null) return NotFound(); + return View(template); + } + [HttpGet, Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task New(string templateId = null) + { + if (!await _access.CanUseChecklistsAsync(DepartmentId) || !await _checklists.CanManageAsync(Actor)) return NotFound(); + var form = new ChecklistForm { Name = "", Sections = { new ChecklistSection { Name = _strings["Checks"].Value, Items = { new ChecklistItem { Name = "" } } } } }; + if (templateId != null) + { + var template = await _templates.GetByIdAsync(DepartmentId, templateId); + if (template == null) return NotFound(); + form = ChecklistForm.FromTemplate(template); + } + return View("Edit", new ChecklistEditView { Form = form }); + } + [HttpGet, Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task Edit(string id) + { + if (!await _checklists.CanManageAsync(Actor)) return Forbid(); + if (!await _access.CanUseChecklistsAsync(DepartmentId)) return NotFound(); + var row = await _checklists.GetDefinitionAsync(Actor, id); + return View("Edit", new ChecklistEditView { Id = id, Revision = row.Definition.Revision, Form = row.Form }); + } + [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task SaveDefinition(string id, int revision, string formJson) + { + var form = Parse(formJson); + var saved = await _checklists.SaveDefinitionAsync(Actor, id, revision, form); + return Json(new { url = Url.Action("Detail", new { id = saved }) }); + } + private static T Parse(string json) + { + if (string.IsNullOrWhiteSpace(json) || json.Length > 1000000) throw new ChecklistException(400, "Form content is missing or too large."); + try { return JsonConvert.DeserializeObject(json, new JsonSerializerSettings { MaxDepth = 20, TypeNameHandling = TypeNameHandling.None }); } + catch (JsonException) { throw new ChecklistException(400, "The form content is invalid."); } + } + [HttpGet] + 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; + return View("Detail", new ChecklistDetailView { Definition = row, History = await _checklists.HistoryAsync(Actor, id, page), CanManage = await _checklists.CanManageAsync(Actor) && await _access.CanUseChecklistsAsync(DepartmentId), 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)] + public async Task Publish(string id, int revision) { await _checklists.PublishAsync(Actor, id, revision); return RedirectToAction("Detail", new { id }); } + [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task Retire(string id, int revision, bool delete = false) { await _checklists.RetireAsync(Actor, id, revision, delete); return RedirectToAction(delete ? "Index" : "Detail", new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task Start(string id, string targetId, string completionId) + { var run = await _checklists.StartAsync(Actor, id, targetId, completionId); return RedirectToAction("Run", new { id = run }); } + [HttpGet] + 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); + } + [HttpGet] + public Task CompletionDetail(string id) => Run(id); + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveRun(string id, string inputJson, bool submit) + { var revision = await _checklists.SaveRunAsync(Actor, id, Parse(inputJson), submit); return Json(new { revision, url = submit ? Url.Action("CompletionDetail", new { id }) : null }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task Witness(string id, string submissionHash, string attestation) + { await _checklists.WitnessAsync(Actor, id, submissionHash, attestation); return RedirectToAction("CompletionDetail", new { id }); } + [HttpPost, ValidateAntiForgeryToken, RequestSizeLimit(11 * 1024 * 1024)] + public async Task Upload(string id, string itemId, IFormFile file) + { + if (file == null || file.Length > 10 * 1024 * 1024) return BadRequest(new { message = "Choose an evidence image up to 10 MB." }); + using var stream = new MemoryStream(); await file.CopyToAsync(stream); + await _checklists.AddFileAsync(Actor, id, itemId, file.FileName, file.ContentType, stream.ToArray()); + var run = await _checklists.GetRunAsync(Actor, id); return Json(new { revision = run.Completion.Revision, files = run.Files }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task RemoveFile(string id, string completionId) + { await _checklists.DeleteFileAsync(Actor, id); var run = await _checklists.GetRunAsync(Actor, completionId); return Json(new { revision = run.Completion.Revision, files = run.Files }); } + [HttpGet] + public async Task Evidence(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, ValidateAntiForgeryToken] + public async Task Export(string id) + { var run = await _checklists.GetRunAsync(Actor, id); return File(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(run, Formatting.Indented)), "application/json", "checklist-" + run.Completion.Id + ".json"); } + [HttpPost, ValidateAntiForgeryToken] + public Task Reopen(string page, string id) => page switch + { + "Index" => Index(), "Detail" => Detail(id), "Edit" => Edit(id), "Run" => Run(id), "CompletionDetail" => CompletionDetail(id), + _ => Task.FromResult(BadRequest()) + }; + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index 43624b9ad..548e45a9f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -2857,6 +2857,7 @@ public async Task ModuleSettings() model.TrainingEnabled = !model.Modules.TrainingDisabled; model.InventoryEnabled = !model.Modules.InventoryDisabled; model.MaintenanceEnabled = !model.Modules.MaintenanceDisabled; + model.ChecklistsEnabled = !model.Modules.ChecklistsDisabled; return View(model); } @@ -2884,6 +2885,7 @@ public async Task ModuleSettings(DepartmentModulesSettingView mod modules.TrainingDisabled = !model.TrainingEnabled; modules.InventoryDisabled = !model.InventoryEnabled; modules.MaintenanceDisabled = !model.MaintenanceEnabled; + modules.ChecklistsDisabled = !model.ChecklistsEnabled; await _departmentSettingsService.SetDepartmentModuleSettingsAsync(DepartmentId, modules, cancellationToken); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index ba15b9e72..afd3e1867 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); + model.RecordsPermissions = RecordsPermissionRows.Build(permissions).Concat(RecordsPermissionRows.Build(permissions, ChecklistPermissionCatalog.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/SubscriptionController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs index ebdf62c2f..69efecf71 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs @@ -808,6 +808,10 @@ public async Task BuyAddon(string planAddonId) if (model.PlanAddon == null) return NotFound(); + // Readiness Pro uses a dedicated monthly checkout, not the legacy PTT quantity flow. + if (model.PlanAddon.AddonType == (int)PlanAddonTypes.ReadinessPro) + return NotFound(); + model.PlanAddonId = model.PlanAddon.PlanAddonId; model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); var addonTypes = await _subscriptionsService.GetAllAddonPlansAsync(); @@ -915,9 +919,11 @@ public async Task BuyAddon(BuyAddonView model, CancellationToken { try { - var user = _usersService.GetUserById(UserId); - var addonPlan = await _subscriptionsService.GetPlanAddonByIdAsync(model.PlanAddonId); + if (addonPlan?.AddonType == (int)PlanAddonTypes.ReadinessPro) + return NotFound(); + + var user = _usersService.GetUserById(UserId); var currentAddonPayments = await _subscriptionsService.GetCurrentPlanAddonsForDepartmentFromStripeAsync(DepartmentId); if (addonPlan != null) diff --git a/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistTemplatesView.cs b/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistTemplatesView.cs new file mode 100644 index 000000000..03d893fbd --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistTemplatesView.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Resgrid.Model.Checklists; + +namespace Resgrid.Web.Areas.User.Models.Checklists +{ + public class ChecklistTemplatesView + { + public string Query { get; set; } + public IReadOnlyList Templates { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistViews.cs b/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistViews.cs new file mode 100644 index 000000000..ce7baf0f0 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistViews.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Resgrid.Model.Checklists; + +namespace Resgrid.Web.Areas.User.Models.Checklists +{ + public class ChecklistIndexView { public List Definitions { get; set; } public bool CanManage { get; set; } public int Page { get; set; } } + public class ChecklistEditView { public string Id { get; set; } public int Revision { get; set; } public ChecklistForm Form { get; set; } } + public class ChecklistDetailView + { + public ChecklistDefinitionView Definition { get; set; } + public List History { get; set; } + public List Targets { get; set; } = new List(); + public bool CanManage { get; set; } + public bool CanStart { get; set; } + public int Page { get; set; } + } + public class ChecklistLockedView { public string Page { get; set; } public string Id { get; set; } } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Departments/DepartmentModulesSettingView.cs b/Web/Resgrid.Web/Areas/User/Models/Departments/DepartmentModulesSettingView.cs index 17f237d62..fb6ef0060 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Departments/DepartmentModulesSettingView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Departments/DepartmentModulesSettingView.cs @@ -19,6 +19,7 @@ public class DepartmentModulesSettingView public bool TrainingEnabled { get; set; } public bool InventoryEnabled { get; set; } public bool MaintenanceEnabled { get; set; } + public bool ChecklistsEnabled { get; set; } = true; public DepartmentModulesSettingView() { diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs b/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs index 323ccd80a..3dde4a16d 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs @@ -35,12 +35,12 @@ public static class RecordsPermissionRows public const string EveryoneValue = "3"; public const string DepartmentAndGroupAdminsAndSelectRolesValue = "4"; - public static List Build(IEnumerable permissions) + public static List Build(IEnumerable permissions, IEnumerable descriptors = null) { var existing = (permissions ?? Enumerable.Empty()).Where(p => p != null).ToList(); var rows = new List(); - foreach (var descriptor in RecordPermissionCatalog.All) + foreach (var descriptor in descriptors ?? RecordPermissionCatalog.All) { var row = existing.FirstOrDefault(p => p.PermissionType == (int)descriptor.Type); var value = row != null ? row.Action : (int)descriptor.NoRowDefault; @@ -50,7 +50,7 @@ public static List Build(IEnumerable permissio Type = descriptor.Type, Value = value, HasRow = row != null, - LockToGroup = row != null && row.LockToGroup, + LockToGroup = row != null ? row.LockToGroup : descriptor.Type == PermissionTypes.ViewChecklistResults, ShowLockToGroup = descriptor.LockToGroupMeaningful, Options = BuildOptions(descriptor.EveryoneOffered, value) }); diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml new file mode 100644 index 000000000..369ee862a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml @@ -0,0 +1,57 @@ +@model Resgrid.Model.Checklists.ChecklistRunView +@using Resgrid.Model.Checklists +@using System.Linq +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + Model.Form.Name; var run = Model.Completion; } +
+

@Model.Form.Name

@Model.Target.Name

+ @await Html.PartialAsync("_Protection") +
+
@localizer["State"]
@localizer[((ChecklistRunState)run.State).ToString()]
+
@localizer["Score"]
@(run.Score.HasValue ? run.Score + "%" : localizer["NoScore"].Value)
+
@localizer["Result"]
@(run.SubmittedOn.HasValue ? run.Passed ? localizer["Passed"] : localizer["Failed"] : localizer["Incomplete"])
+
@localizer["Author"]
@run.CreatedBy
@localizer["Submitted"] (UTC)
@run.SubmittedOn?.ToString("u")
+
@localizer["Version"]
@Model.VersionNumber
+ @if (run.WitnessedOn.HasValue) {
@localizer["WitnessLabel"]
@run.WitnessUserId · @run.WitnessedOn.Value.ToString("u")
@localizer["Attestation"]
@((string)Newtonsoft.Json.Linq.JObject.Parse(run.Content ?? "{}")["WitnessAttestation"])
} +
+ @foreach (var checklistSection in Model.Form.Sections) + { +

@checklistSection.Name

+ + @foreach (var item in checklistSection.Items) + { + var answer = Model.Input.Answers.FirstOrDefault(a => a.ItemId == item.Id); + var displayAnswer = answer?.Status == ChecklistAnswerStatus.NotApplicable ? localizer["N/A"].Value + ": " + answer.NotApplicableReason : localizer["Unanswered"].Value; + if (answer?.Status == ChecklistAnswerStatus.Answered) + { + displayAnswer = item.Type == ChecklistItemType.PassFail ? localizer[answer.Value == "pass" ? "Pass" : "Fail"].Value + : item.Type == ChecklistItemType.YesNo ? localizer[answer.Value == "true" ? "Yes" : "No"].Value + : item.Type == ChecklistItemType.Checkbox ? localizer[answer.Value == "true" ? "Checked" : "Unchecked"].Value + : answer.Value; + } + + } +
@localizer["Item"]@localizer["Answer"]@localizer["Note"]@localizer["Evidence"]
@item.Name @if(item.Critical) { (@localizer["Critical"]) }@displayAnswer@answer?.Note + @foreach (var file in Model.Files.Where(f => f.ItemId == item.Id)) { @file.Content
} +
+ } +

@Model.Input.LocationDescription

+

@Model.Input.Note

+ @if (Model.Input.Latitude.HasValue) {

@localizer["ReportedLocation"]: @Model.Input.Latitude, @Model.Input.Longitude

} + @if (run.State == (int)ChecklistRunState.AwaitingWitness) + { +

@localizer["WitnessInstructions"]

+ @if (run.CreatedBy != (string)ViewBag.ChecklistUserId && ViewBag.ChecklistsEnabled == true) + { +
+ @Html.AntiForgeryToken() + + +
+ } + } +
@Html.AntiForgeryToken()
+ + @localizer["History"] +
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml new file mode 100644 index 000000000..e79b7ae84 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml @@ -0,0 +1,44 @@ +@model Resgrid.Web.Areas.User.Models.Checklists.ChecklistDetailView +@using Resgrid.Model.Checklists +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + Model.Definition.Form.Name; var definition = Model.Definition.Definition; } +
+

@Model.Definition.Form.Name

@Model.Definition.Form.Instructions

+

@localizer["PublishedVersion"]: @definition.PublishedVersion · @(definition.Retired ? localizer["Retired"] : localizer["Active"])

+ @await Html.PartialAsync("_Protection") + @if (Model.CanManage) + { +

@localizer["EditDraft"]

+
+ @Html.AntiForgeryToken() + @localizer["PublishGuidance"] +
+
+ @Html.AntiForgeryToken() + + @if (definition.PublishedVersion == 0) { } +
+ } + @if (Model.CanStart) + { +
+ @Html.AntiForgeryToken() + +
+ } +

@localizer["History"]

+ + @foreach (var entry in Model.History) + { + var run = entry.Completion; + + } +
@localizer["Started"] (UTC)@localizer["Target"]@localizer["State"]@localizer["Score"]@localizer["Result"]
@run.CreatedOn.ToString("yyyy-MM-dd HH:mm:ss")@entry.TargetName@localizer[((ChecklistRunState)run.State).ToString()]@(run.Score.HasValue ? run.Score + "%" : "—")@(run.SubmittedOn.HasValue ? run.Passed ? localizer["Passed"] : localizer["Failed"] : localizer["Incomplete"])
+ @if (Model.Page > 0) { @localizer["Previous"] } + @localizer["Next"] + @localizer["Back"] +
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml new file mode 100644 index 000000000..8f80e6440 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml @@ -0,0 +1,21 @@ +@model Resgrid.Web.Areas.User.Models.Checklists.ChecklistEditView +@using Newtonsoft.Json +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["EditChecklist"]; } +
+

@localizer["EditChecklist"]

@localizer["DraftGuidance"]

+ @await Html.PartialAsync("_Protection") + +
+ @Html.AntiForgeryToken() + +
+ + @localizer["Back"] +
+ +
+@section Scripts { + @await Html.PartialAsync("_ProtectionScripts") + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml new file mode 100644 index 000000000..4793cd675 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml @@ -0,0 +1,19 @@ +@model Resgrid.Web.Areas.User.Models.Checklists.ChecklistIndexView +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["Checklists"]; } +

@localizer["Checklists"]

@localizer["Free"]

+
+ @await Html.PartialAsync("_Protection") +

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

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

@localizer["NoDefinitions"]

} + + @foreach (var item in Model.Definitions) + { + + } +
@localizer["Name"]@localizer["Version"]@localizer["State"]
@item.Form.Name@item.Definition.PublishedVersion@(item.Definition.Retired ? localizer["Retired"] : item.Definition.PublishedVersion == 0 ? localizer["Draft"] : localizer["Published"])
+ @if (Model.Page > 0) { @localizer["Previous"] } + @localizer["Next"] +
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Locked.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Locked.cshtml new file mode 100644 index 000000000..0cb137f95 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Locked.cshtml @@ -0,0 +1,14 @@ +@model Resgrid.Web.Areas.User.Models.Checklists.ChecklistLockedView +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["Protected checklists"]; ViewBag.ProtectionEnforced = true; } +
+

@localizer["Unlock checklist data"]

+

@localizer["Verify your identity to view the checklist, answers and evidence."]

+ @await Html.PartialAsync("_Protection") +
+ @Html.AntiForgeryToken() + + +
+
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Run.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Run.cshtml new file mode 100644 index 000000000..5cb2d1815 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Run.cshtml @@ -0,0 +1,19 @@ +@model Resgrid.Model.Checklists.ChecklistRunView +@using Newtonsoft.Json +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + Model.Form.Name; } +
+

@Model.Form.Name

@Model.Target.Name · @localizer["Started"] @Model.Completion.CreatedOn.ToString("u")

+

@Model.Form.Instructions

@localizer["RunGuidance"]

+ @await Html.PartialAsync("_Protection") + +
+ @Html.AntiForgeryToken() +
+ + + @localizer["History"] +
+ +
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Template.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Template.cshtml new file mode 100644 index 000000000..19e55eb18 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Template.cshtml @@ -0,0 +1,33 @@ +@model Resgrid.Model.Checklists.ChecklistTemplate +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + Model.Name; } + +
+

@Model.Name

@Model.Sector

+
+
+
+

@Model.Description

@if (ClaimsAuthorizationHelper.CanManageChecklists()) {

@localizer["Use this template"]

} +

@localizer["Guidance"]

+ @if (Model.RequiresIndependentWitness) + { +

@localizer["Witness"]

+ } + @foreach (var checklistSection in Model.Sections) + { +

@checklistSection.Name

+
    + @foreach (var item in checklistSection.Items) + { +
  • + @item.Name + @if (item.Critical) { @localizer["Critical"] } + @if (item.Required) { @localizer["Required"] } + @if (item.RequireNoteOnFail) {

    @localizer["FailNote"]

    } +
  • + } +
+ } + @localizer["Back"] +
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Templates.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Templates.cshtml new file mode 100644 index 000000000..d89fbe97c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Templates.cshtml @@ -0,0 +1,31 @@ +@model Resgrid.Web.Areas.User.Models.Checklists.ChecklistTemplatesView +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["Templates"]; } + +
+

@localizer["Templates"]

@localizer["Free"]

+
+
+
+

@localizer["Guidance"]

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

@localizer["NoResults"]

+ } + @foreach (var template in Model.Templates) + { +
+
+

@template.Name @template.Sector

+

@template.Description

+ @localizer["Preview"] @template.Name +
+
+ } +
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/_Protection.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/_Protection.cshtml new file mode 100644 index 000000000..358114e70 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/_Protection.cshtml @@ -0,0 +1,6 @@ +@using Resgrid.Web.Areas.User.Models +@inject IStringLocalizer checklistStrings +@if (ViewBag.ProtectionEnforced == true) +{ + @await Html.PartialAsync("_AdpRevealBanner", new AdpRevealView { BannerTitle = checklistStrings["Protected checklist data"].Value }) +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/_ProtectionScripts.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/_ProtectionScripts.cshtml new file mode 100644 index 000000000..2e61ea93d --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/_ProtectionScripts.cshtml @@ -0,0 +1,24 @@ +@using Resgrid.Web.Areas.User.Models +@using Newtonsoft.Json +@inject IStringLocalizer checklistStrings + +@if (ViewBag.ProtectionEnforced == true) +{ + + + @await Html.PartialAsync("_AdpRevealScripts", new AdpRevealView { BannerTitle = checklistStrings["Protected checklist data"].Value, GrantExpiresOnUtc = (DateTime?)ViewBag.GrantExpiresOn, BindForms = new System.Collections.Generic.List { "form.checklist-form" } }) +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/ModuleSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/ModuleSettings.cshtml index 4e03ecc42..f6991e8ed 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/ModuleSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/ModuleSettings.cshtml @@ -1,5 +1,6 @@ @model Resgrid.Web.Areas.User.Models.Departments.DepartmentModulesSettingView @inject IStringLocalizer localizer +@inject IStringLocalizer checklistLocalizer @{ ViewBag.Title = "Resgrid | " + @localizer["DispatchSettingsModuleHeader"]; } @@ -192,8 +193,16 @@ -@*
- +
+ +
+ +

@checklistLocalizer["Free"]

+
+
+ +
+
@@ -205,7 +214,7 @@
-
*@ +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml index 7c318d16a..a1fa2ddd8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml @@ -1,7 +1,8 @@ -@using Resgrid.Model +@using Resgrid.Model @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.Security.PermissionsView @inject IStringLocalizer localizer +@inject IStringLocalizer checklistLocalizer @inject IStringLocalizer twoFactorLocalizer @{ ViewBag.Title = "Resgrid | " + localizer["SecurityPermissionsHeader"]; @@ -455,9 +456,10 @@ @foreach (var row in Model.RecordsPermissions) { + if (row.Type == PermissionTypes.ManageChecklists) { @checklistLocalizer["Checklists"] } - @localizer[row.LabelKey] - @localizer[row.NoteKey] + @(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) @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 74203a618..d4044488b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -1,4 +1,6 @@ @inject Resgrid.Model.Services.IFeatureToggleService featureToggleService +@inject Resgrid.Model.Services.IReadinessAccessService readinessAccess +@inject IStringLocalizer checklistLocalizer @inject Resgrid.Model.Services.IRecordsAuthorizationService recordsAuthorization @inject IStringLocalizer recordsLocalizer @{ @@ -159,6 +161,12 @@ @commonLocalizer["TrainingsModule"] } + @if (await readinessAccess.CanUseChecklistsAsync(ClaimsAuthorizationHelper.GetDepartmentId())) + { +
  • + @checklistLocalizer["Checklists"] +
  • + } @if (SettingsHelper.IsInventoryEnabled()) {
  • diff --git a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs index cd8bb0c33..a0e6ebbd6 100644 --- a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using Autofac; using System.Security.Claims; using CommonServiceLocator; @@ -313,6 +313,9 @@ public static bool CanDeleteContacts() return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Contacts, ResgridClaimTypes.Actions.Delete); } + 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 bool CanViewRoutes() { return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.View); diff --git a/Web/Resgrid.Web/Startup.cs b/Web/Resgrid.Web/Startup.cs index 246042cd3..a4ae3b435 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Configuration; using System.Globalization; @@ -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.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)); options.AddPolicy(ResgridResources.RecordDefinition_Publish, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordDefinition, ResgridClaimTypes.Actions.Publish)); options.AddPolicy(ResgridResources.RecordReport_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordReport, ResgridClaimTypes.Actions.Update)); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js b/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js new file mode 100644 index 000000000..ca47b857a --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js @@ -0,0 +1,212 @@ +(function () { + 'use strict'; + const translationNode = document.getElementById('checklist-translations'); + const translations = translationNode ? JSON.parse(translationNode.textContent) : {}; + const tr = value => translations[value] || value; + const format = (key, ...values) => tr(key).replace(/\{(\d+)\}/g, (_, index) => values[Number(index)]); + const types = ['Pass / Fail', 'Yes / No', 'Checkbox', 'Numeric reading', 'Quantity', 'Free text', 'Select list', 'Date', 'Photo', 'Signature']; + const categories = ['Start of shift', 'Unit check', 'Personal gear', 'Annual review', 'Facility', 'Safety audit', 'Equipment check', 'Other']; + const el = (tag, text, parent) => { const node = document.createElement(tag); if (text != null) node.textContent = text; if (parent) parent.appendChild(node); return node; }; + function button(parent, text, action) { const node = el('button', tr(text), parent); node.type = 'button'; node.className = 'btn btn-default btn-sm'; node.addEventListener('click', action); return node; } + function field(parent, label, object, key, type, choices) { + const box = el('div', null, parent); box.className = 'form-group'; + const caption = el('label', tr(label), box); const id = 'checklist-field-' + crypto.randomUUID(); caption.htmlFor = id; + const input = el(choices ? 'select' : type === 'textarea' ? 'textarea' : 'input', null, box); + input.id = id; input.className = type === 'checkbox' ? '' : 'form-control'; + if (choices) choices.forEach(choice => { const option = el('option', choice[2] === false ? choice[1] : tr(choice[1]), input); option.value = choice[0]; }); + else if (type !== 'textarea') input.type = type || 'text'; + if (type === 'number') input.step = 'any'; + if (type === 'checkbox') input.checked = !!object[key]; else input.value = object[key] == null ? '' : object[key]; + input.addEventListener('input', () => { object[key] = type === 'checkbox' ? input.checked : type === 'number' ? input.value === '' ? null : Number(input.value) : input.value; }); + return input; + } + function move(list, index, delta, render) { const next = index + delta; if (next < 0 || next >= list.length) return; [list[index], list[next]] = [list[next], list[index]]; render(); } + function error(message) { const node = document.getElementById('checklist-error'); node.textContent = tr(message); node.hidden = false; node.scrollIntoView({ block: 'nearest' }); } + async function post(form, url, data) { + const headers = new Headers(); + if (window.resgridAdpReveal) window.resgridAdpReveal.applyGrantHeader(headers); + const response = await fetch(url, { method: 'POST', body: data, headers, credentials: 'same-origin' }); + let result; try { result = await response.json(); } catch (_) { throw new Error('The request could not be completed. Your changes are still on this page.'); } + if (!response.ok) { + if (response.status === 403 && window.resgridAdpReveal) form.dispatchEvent(new CustomEvent('adp:grant-required')); + throw new Error(result.message || result.Message || 'The request could not be completed.'); + } + document.getElementById('checklist-error').hidden = true; return result; + } + function bindSave(form, collect) { + form.addEventListener('submit', async event => { + if (event.defaultPrevented) return; + event.preventDefault(); + const submitter = event.submitter; + if (form.dataset.busy === 'true') return; + form.dataset.busy = 'true'; + try { + collect(); const data = new FormData(form); + if (submitter && submitter.name) data.set(submitter.name, submitter.value); + const result = await post(form, form.action, data); + if (result.url) { form.dispatchEvent(new CustomEvent("checklist:saved", { detail: result })); window.location.assign(result.url); return; } + form.dispatchEvent(new CustomEvent('checklist:saved', { detail: result })); + } catch (ex) { error(ex.message); } finally { form.dataset.busy = 'false'; } + }); + } + function editor() { + const form = document.getElementById('checklist-editor'); if (!form) return; + const model = JSON.parse(document.getElementById('checklist-form-data').textContent); + const root = document.getElementById('checklist-builder'); + const freshItem = () => ({ Id: crypto.randomUUID(), Name: '', Type: 0, Required: true, Critical: false, AllowNotApplicable: false, RequireNoteOnFail: true, RequirePhotoOnFail: false, Weight: 1, PassingValue: 'true', Options: [] }); + function render() { + root.replaceChildren(); + field(root, 'Checklist name', model, 'Name').maxLength = 200; + field(root, 'Instructions (do not include patient data)', model, 'Instructions', 'textarea').maxLength = 10000; + field(root, 'Category', model, 'Category', 'number', categories.map((name, i) => [i, name])); + field(root, 'Target type', model, 'TargetType', 'number', [[0, 'Department'], [1, 'Unit'], [2, 'Group / station'], [3, 'Personnel']]); + const threshold = field(root, 'Passing score (%)', model, 'PassThreshold', 'number'); threshold.min = 0; threshold.max = 100; + field(root, 'Require reported location', model, 'RequireLocation', 'checkbox'); + field(root, 'Require a different authenticated member to witness the submission', model, 'RequiresIndependentWitness', 'checkbox'); + const earlier = []; + model.Sections.forEach((section, si) => { + const sectionBox = el('fieldset', null, root); sectionBox.className = 'well'; el('legend', format('Section {0}', si + 1), sectionBox); + field(sectionBox, 'Section name', section, 'Name').maxLength = 200; + button(sectionBox, 'Move section up', () => move(model.Sections, si, -1, render)); + button(sectionBox, 'Move section down', () => move(model.Sections, si, 1, render)); + button(sectionBox, 'Remove section', () => { if (confirm(tr('Remove this section and its items from the draft?'))) { model.Sections.splice(si, 1); render(); } }); + section.Items.forEach((item, ii) => { + const box = el('fieldset', null, sectionBox); box.className = 'panel panel-default'; box.style.padding = '15px'; + el('legend', format('Item {0}', ii + 1), box); + field(box, 'Question / check', item, 'Name').maxLength = 300; + field(box, 'Item instructions', item, 'Instructions', 'textarea').maxLength = 5000; + field(box, 'Answer type', item, 'Type', 'number', types.map((name, i) => [i, name])).addEventListener('change', render); + [['Required', 'Required'], ['Critical failure overrides the score', 'Critical'], ['Allow N/A with a reason', 'AllowNotApplicable'], ['Require a note on failure', 'RequireNoteOnFail'], ['Require a photo on failure', 'RequirePhotoOnFail']].forEach(pair => field(box, pair[0], item, pair[1], 'checkbox')); + const weight = field(box, 'Score weight (0 excludes this item from the score)', item, 'Weight', 'number'); weight.min = 0; weight.max = 1000; + if (item.Type === 1 || item.Type === 2) field(box, 'Passing answer', item, 'PassingValue', 'text', [['true', 'Yes / checked'], ['false', 'No / unchecked']]); + if (item.Type === 3 || item.Type === 4) { + field(box, 'Units', item, 'Units').maxLength = 50; + field(box, 'Minimum passing value (optional if maximum is set)', item, 'Minimum', 'number'); + field(box, 'Maximum passing value (optional if minimum is set)', item, 'Maximum', 'number'); + } + if (item.Type === 6) { + const options = { Lines: (item.Options || []).join('\n') }; + field(box, 'Choices (one per line)', options, 'Lines', 'textarea').addEventListener('input', () => { item.Options = options.Lines.split('\n').map(value => value.trim()).filter(Boolean); }); + field(box, 'Exact passing choice', item, 'PassingValue'); + } + [['VisibleWhen', 'Show only when'], ['RequiredWhen', 'Also required when']].forEach(pair => { + const value = { ItemId: item[pair[0]] ? item[pair[0]].ItemId : '' }; + field(box, pair[1], value, 'ItemId', 'text', [['', 'Always / no condition']].concat(earlier.map(i => [i.Id, i.Name || tr('Unnamed earlier item'), false]))).addEventListener('change', () => { + item[pair[0]] = value.ItemId ? { ItemId: value.ItemId, EqualsValue: '' } : null; render(); + }); + if (item[pair[0]]) { + const source = earlier.find(candidate => candidate.Id === value.ItemId); + let choices; + if (source && source.Type === 0) choices = [['', 'Choose'], ['pass', 'Pass'], ['fail', 'Fail']]; + if (source && (source.Type === 1 || source.Type === 2)) choices = [['', 'Choose'], ['true', source.Type === 1 ? 'Yes' : 'Checked'], ['false', source.Type === 1 ? 'No' : 'Unchecked']]; + if (source && source.Type === 6) choices = [['', 'Choose']].concat((source.Options || []).map(option => [option, option, false])); + field(box, 'Answer that activates this condition', item[pair[0]], 'EqualsValue', source && source.Type === 7 ? 'date' : 'text', choices); + } + }); + earlier.push(item); + button(box, 'Move item up', () => move(section.Items, ii, -1, render)); + button(box, 'Move item down', () => move(section.Items, ii, 1, render)); + button(box, 'Remove item', () => { if (confirm(tr('Remove this item from the draft?'))) { section.Items.splice(ii, 1); render(); } }); + }); + button(sectionBox, 'Add item', () => { section.Items.push(freshItem()); render(); }); + }); + button(root, 'Add section', () => { model.Sections.push({ Id: crypto.randomUUID(), Name: '', Items: [freshItem()] }); render(); }); + } + render(); bindSave(form, () => { form.elements.formJson.value = JSON.stringify(model); }); + } + function runner() { + const form = document.getElementById('checklist-run'); if (!form) return; + const model = JSON.parse(document.getElementById('checklist-run-data').textContent); + const input = model.Input; const answers = new Map(input.Answers.map(answer => [answer.ItemId, answer])); + const root = document.getElementById('checklist-answers'); const blocks = []; + let files = model.Files; + let dirty = false, generation = 0, savingGeneration = 0; + const matches = condition => !condition || answers.has(condition.ItemId) && answers.get(condition.ItemId).Status === 1 && answers.get(condition.ItemId).Value === condition.EqualsValue; + function visibility() { + blocks.forEach(block => { + const visible = matches(block.item.VisibleWhen); block.box.hidden = !visible; + if (!visible) { block.answer.Status = 0; block.answer.Value = null; block.answer.Note = null; block.answer.NotApplicableReason = null; block.status.value = '0'; block.value.value = ''; } + block.required.textContent = tr(block.item.Required || block.item.RequiredWhen && matches(block.item.RequiredWhen) ? 'Required' : 'Optional'); + if (block.answer.Status !== 1) { block.answer.Value = null; block.value.value = ""; } block.value.disabled = !visible || block.answer.Status !== 1; + block.na.parentElement.hidden = block.answer.Status !== 2; + }); + } + function evidenceList(block) { + block.evidence.replaceChildren(); + files.filter(file => (file.ItemId || file.itemId) === block.item.Id).forEach(file => { + const id = file.Id || file.id; + const line = el('p', null, block.evidence); + const link = el('a', file.Content || file.content || tr('Evidence'), line); link.href = form.dataset.evidence + '?id=' + encodeURIComponent(id); + if (window.resgridAdpReveal) link.addEventListener('click', event => { event.preventDefault(); window.resgridAdpReveal.download(link.href); }); + button(line, 'Remove', async () => { + if (form.dataset.busy === 'true') return; + form.dataset.busy = 'true'; + try { const data = new FormData(form); data.set('id', id); data.set('completionId', model.Completion.Id); updateFiles(await post(form, form.dataset.remove, data)); } + catch (ex) { error(ex.message); } finally { form.dataset.busy = 'false'; } + }); + }); + } + function updateFiles(result) { input.Revision = result.revision; files = result.files; blocks.forEach(evidenceList); } + async function upload(block, file) { + if (!file || form.dataset.busy === 'true') return; + if (file.size > 10 * 1024 * 1024) { error('Evidence must be at most 10 MB.'); return; } + form.dataset.busy = 'true'; + try { + const data = new FormData(form); data.set('itemId', block.item.Id); data.set('file', file, file.name || 'signature.png'); + updateFiles(await post(form, form.dataset.upload, data)); + if (block.item.Type === 8 || block.item.Type === 9) { block.answer.Status = 1; block.status.value = '1'; block.answer.Value = null; dirty = true; visibility(); } + } catch (ex) { error(ex.message); } finally { form.dataset.busy = 'false'; } + } + model.Form.Sections.forEach(section => { + el('h3', section.Name, root); + section.Items.forEach(item => { + const answer = answers.get(item.Id) || { ItemId: item.Id, Status: 0, Value: null, Note: null, NotApplicableReason: null }; answers.set(item.Id, answer); + const box = el('fieldset', null, root); box.className = 'well'; el('legend', item.Name + (item.Critical ? ' — ' + tr('Critical') : ''), box); + const required = el('strong', '', box); if (item.Instructions) el('p', item.Instructions, box); + const states = [[0, 'Unanswered'], [1, 'Answered']]; if (item.AllowNotApplicable) states.push([2, 'N/A']); + const status = field(box, 'Answer status', answer, 'Status', 'number', states); + let choices, type = 'text'; + if (item.Type === 0) choices = [['', 'Choose'], ['pass', 'Pass'], ['fail', 'Fail']]; + if (item.Type === 1 || item.Type === 2) choices = [['', 'Choose'], ['true', item.Type === 1 ? 'Yes' : 'Checked'], ['false', item.Type === 1 ? 'No' : 'Unchecked']]; + if (item.Type === 6) choices = [['', 'Choose']].concat(item.Options.map(value => [value, value, false])); + if (item.Type === 5) type = 'textarea'; if (item.Type === 7) type = 'date'; + const value = field(box, tr('Answer') + (item.Units ? ' (' + item.Units + ')' : ''), answer, 'Value', type, choices); + if (item.Type === 3 || item.Type === 4) { value.inputMode = 'decimal'; el('p', format('Passing range: {0} to {1}', item.Minimum == null ? tr('No minimum') : item.Minimum, item.Maximum == null ? tr('No maximum') : item.Maximum), box); } + if (item.Type === 8 || item.Type === 9) value.parentElement.hidden = true; + const na = field(box, 'N/A reason', answer, 'NotApplicableReason', 'textarea'); na.maxLength = 2000; + field(box, item.RequireNoteOnFail ? 'Note (required on failure)' : 'Note', answer, 'Note', 'textarea').maxLength = 5000; + const evidence = el('div', null, box); const block = { box, item, answer, required, status, value, na, evidence }; blocks.push(block); + const uploadLabel = el('label', tr('Evidence image (PNG/JPEG, up to 10 MB; scanning required)'), box); + const picker = el('input', null, uploadLabel); picker.type = 'file'; picker.accept = 'image/png,image/jpeg'; picker.addEventListener('change', () => upload(block, picker.files[0])); + if (item.Type === 9) { + el('p', tr('Draw your signature or upload a signature image. A required independent witness must sign in separately.'), box); + const canvas = el('canvas', null, box); canvas.width = 600; canvas.height = 160; canvas.style.cssText = 'max-width:100%;border:1px solid #777;touch-action:none;background:white'; + canvas.setAttribute('aria-label', tr('Signature drawing area. Alternatively upload an image.')); + const context = canvas.getContext('2d'); context.fillStyle = 'white'; context.fillRect(0, 0, canvas.width, canvas.height); context.lineWidth = 2; + let drawing = false, marked = false; + const point = event => { const rect = canvas.getBoundingClientRect(); return [(event.clientX - rect.left) * canvas.width / rect.width, (event.clientY - rect.top) * canvas.height / rect.height]; }; + canvas.addEventListener('pointerdown', event => { drawing = true; canvas.setPointerCapture(event.pointerId); context.beginPath(); context.moveTo(...point(event)); }); + canvas.addEventListener('pointermove', event => { if (drawing) { context.lineTo(...point(event)); context.stroke(); marked = true; } }); + canvas.addEventListener('pointerup', () => { drawing = false; }); canvas.addEventListener('pointercancel', () => { drawing = false; }); + button(box, 'Clear signature', () => { context.fillRect(0, 0, canvas.width, canvas.height); marked = false; }); + button(box, 'Save signature image', () => { if (!marked) { error('Draw a signature first.'); return; } canvas.toBlob(blob => upload(block, new File([blob], 'signature.png', { type: 'image/png' })), 'image/png'); }); + } + evidenceList(block); + }); + }); + field(root, 'Site / building / room', input, 'LocationDescription').maxLength = 500; + field(root, 'Completion / handover note', input, 'Note', 'textarea').maxLength = 10000; + const latitude = field(root, model.Form.RequireLocation ? 'Reported latitude (required)' : 'Reported latitude', input, 'Latitude', 'number'); + const longitude = field(root, 'Reported longitude', input, 'Longitude', 'number'); + button(root, 'Use current location', () => { + if (!navigator.geolocation) { error('Location is unavailable. Enter coordinates manually.'); return; } + navigator.geolocation.getCurrentPosition(position => { input.Latitude = position.coords.latitude; input.Longitude = position.coords.longitude; latitude.value = input.Latitude; longitude.value = input.Longitude; dirty = true; }, () => error('Location could not be read. Enter coordinates manually.'), { timeout: 15000, maximumAge: 0 }); + }); + root.addEventListener('input', () => { dirty = true; generation++; visibility(); }); visibility(); + bindSave(form, () => { savingGeneration = generation; visibility(); input.Answers = [...answers.values()]; form.elements.inputJson.value = JSON.stringify(input); }); + form.addEventListener('checklist:saved', event => { input.Revision = event.detail.revision; dirty = generation !== savingGeneration; const node = document.getElementById('checklist-saved'); node.textContent = tr(dirty ? 'Earlier progress saved; unsaved changes remain.' : 'Progress saved.'); node.hidden = false; }); + window.addEventListener('beforeunload', event => { if (dirty && form.dataset.busy !== 'true') { event.preventDefault(); event.returnValue = ''; } }); + } + // The shared ADP binder installs its capture listener first and can hold a save for verification. + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { editor(); runner(); }); else { editor(); runner(); } +}()); diff --git a/docs/architecture/checklists-p1-m1-implementation.md b/docs/architecture/checklists-p1-m1-implementation.md new file mode 100644 index 000000000..e110e153d --- /dev/null +++ b/docs/architecture/checklists-p1-m1-implementation.md @@ -0,0 +1,49 @@ +# Checklists P1-M1 implementation + +Status: implementation complete, pending deployment and the PostgreSQL environment check described below. This milestone is the free, on-demand web checklist workflow. P1-M2 scheduling, linked inventory targets and notifications; P1-M3 mobile/offline execution; P1-M4 reporting; and paid Readiness Pro workflows remain separate milestones. + +Subsequent planning amendment: the [Workflow events and ADP contract](readiness-workflows-adp-contract.md) adds P1-M1 follow-up acceptance checks for the existing event/catalog baseline before release. Its field classification, derived-data, serialization and policy-transition checks have not been claimed as executed by this implementation report. Later milestones must deliver Workflow and ADP coverage with their features. + +## Delivered behavior + +- Template-to-draft creation generates new section/item identities. The editor supports sections, ordering, instructions, ten answer types, numeric units/bounds, explicit passing values, required/critical items, N/A reasons, failure evidence and bounded conditions referencing earlier items. +- Saving a draft, publishing a new immutable version, retiring a definition and deleting an unpublished draft are separate operations. Runs pin their published version. Editing a draft cannot change the target picker for the active published version. +- Department, unit, group/station and personnel targets are authorized on the server. Equipment templates work on demand using a required equipment identifier and a responsible department/unit/group/person; linked inventory asset routing remains P1-M2. Site/building/room descriptions and optional reported coordinates are protected run content. +- Progress saves support resumption and optimistic revisions. Client-generated run IDs make repeated starts idempotent. Terminal retries return the same result only for the same answers, metadata and evidence checksums. Changed submitted content conflicts. +- Missing answers never count as passes. N/A requires permission and a reason. Applicable weights determine the score; zero applicable weight has no score. Critical failures override the score. Optional handover notes in starter templates do not reduce it. +- PNG/JPEG photo and signature evidence uses image identification, bounded dimensions, metadata stripping/re-encoding, a 10 MB limit, scanning, SHA-256 integrity checks and ADP-protected bytes. Only a clean scan is accepted. Duplicate uploads are idempotent; submitted evidence is immutable. Metadata authorization precedes blob retrieval. +- Independent witness submission requires a different active member with results permission and the relevant group scope. The witness attests to the immutable submitted payload. A shared URL does not grant access. Failed-item events occur on submission; the completion event waits for the required witness. +- History, numbered published versions, target snapshots, completed answers, print, JSON export and separately authorized image downloads are available. Normal historical read permissions continue to work after the rollout flag or department module is disabled. Group scope is captured with the run, so moving/deleting a unit does not silently reassign its old results to a different group. +- Definition/run changes write audit rows in the same transaction as the affected data. Completed/failed events enter the durable domain outbox in that transaction, with dispatch after commit. Workflow variables/sample data and ID-only Eventing notifications are registered. +- Permission issuance covers the shared claims path used by MVC identities, cookie principals and JWTs, both policy registrations, both helper copies, and the permission administration screen. The existing Records permission catalog retains its original defaults and membership. +- Neutral and all ten supported language resource sets cover the checklist views and editor labels: en, de, el, es, fr, it, pl, sv, uk and ar. The localization correction adds Arabic, removes English-only interface labels and condition-code instructions, and corrects terminology. Each dictionary has 174 entries. Stored response codes and user-authored text remain unchanged; translated condition selectors submit the original codes. +- GDPR export includes associated completion, answer and evidence metadata through the existing recursive ADP redaction/manifest pipeline. Evidence downloads use the authorized checklist endpoint. The existing SQL Server department-deletion path deletes checklist children before their parents. The platform's pre-existing lack of PostgreSQL department deletion remains unchanged. + +## Storage and identifiers + +`M0191_AddChecklistWorkflow` and its PostgreSQL twin create seven tables: ChecklistDefinitions, ChecklistDefinitionVersions, ChecklistOccurrences, ChecklistCompletions, ChecklistCompletionItems, ChecklistCompletionFiles and DepartmentChecklistSettings. The last table reserves the department settings aggregate used by P1-M2. + +Definitions and version schemas, target snapshots, completion metadata and typed answer payloads use a cataloged `Content` slot. Completion items remain relational rows keyed to their completion and stable item ID, with an indexed parent and a queryable failure marker. File bytes are a separate protected column. This reuses the existing dual-dialect, unit-of-work-aware repository plumbing rather than duplicating SQL across query configurations. + +Composite tenant foreign keys, unique published version numbers, one completion per occurrence and one answer per item enforce aggregate integrity. A department row lock serializes commands in the transaction; revisions detect stale edits. Historical versions cannot be updated through the repository. + +| Registry | Allocation | +|---|---| +| Migration | M0191; next physical migration M0192, within the existing readiness block | +| Permissions | ManageChecklists = 112; ViewChecklistResults = 113 | +| Workflow triggers | ChecklistCompleted = 67; ChecklistFailed = 68 | +| Eventing | ChecklistUpdated = 11 (IDs only) | +| ADP | Catalog version 14: seven Content slots and checklist file Data | +| Feature flag | Checklists.System, independent of Maintenance.WorkOrders | + +Both flags remain seeded off. Checklists invoke no Readiness Pro billing gate. The previously configured USD 150/month Stripe and EUR 195/month Paddle Readiness Pro mappings remain for P2's dedicated purchase/reconciliation workflow. + +## Validation and rollout + +Final build and focused test results are recorded in the review's P1-M1 completion section. Tests cover the web editor in headless Edge, conditional execution, escaped content, CSRF, concurrent saves, immutable versioning, idempotency, failure evidence, independent witnesses, authorization, ADP denial, audit rollback, workflow contracts and the existing readiness/billing boundaries. + +The real SQL Server fixture creates a uniquely named disposable database, applies M0191, rolls it down/up, checks tenant foreign keys, duplicate versions, blob-free list reads, transaction rollback and serialization of two writers. It deletes only its own database. The PostgreSQL fixture provides the same checks and is opt-in through `RESGRID_CHECKLIST_POSTGRES_TEST_CONNECTION`; Docker's Linux engine was unavailable during this work, so those checks remain an explicit environment validation gap. + +For rollout, apply migrations through M0191, complete the ADP catalog-14 upgrade for protected departments, configure the existing ClamAV scanner (`AttachmentScanningConfig.Enabled`, Host and Port), and enable Checklists.System plus the department's checklist module. A scanner that reports Skipped, Pending or Rejected cannot satisfy evidence requirements. Refresh sign-in claims after deploying the new permission resources. Checklists can be reviewed at `/User/Checklists` once enabled. + +Work is intentionally uncommitted. Suggested commit message: `feat(checklists): complete P1-M1 on-demand web workflow`. diff --git a/docs/architecture/readiness-pro-plan-review-2026-09-08.md b/docs/architecture/readiness-pro-plan-review-2026-09-08.md new file mode 100644 index 000000000..114120f35 --- /dev/null +++ b/docs/architecture/readiness-pro-plan-review-2026-09-08.md @@ -0,0 +1,160 @@ +# Checklists and Readiness Pro: implementation review + +Reviewed 2026-09-08 against `../int-Coordination/docs/architecture/checklists-maintenance-workorders-design.md`, the identifier registry, and the current Core checkout. + +## Decision + +The original plan is a strong starting point, but is not complete without the requirements below. With this amendment it is suitable for incremental implementation. This is design verification, not certification of operational readiness or a claim that the planned features have shipped. + +The [Workflow events and ADP contract](readiness-workflows-adp-contract.md), added 2026-09-08 at the user's request, is mandatory across all milestones. It supersedes older raw event/audit payload guidance and adds explicit P1-M1 follow-up verification before release. Event production and field/data-flow protection must be delivered with each feature, including models that may receive incidental PII/PHI. + +- **Checklists are free for every department**, including templates, authoring, scheduling, completion, failure evidence, notifications, history and checklist reports. They require neither a paid base plan nor Readiness Pro. +- **Readiness Pro** is the new **monthly** add-on covering maintenance, work requests, work orders, preventive maintenance, parts/labor, approvals and maintenance reports. It is a department subscription independent of the base plan's billing interval. User-confirmed pricing: **USD 150/month for US via Stripe; EUR 195/month for EU via Paddle**. Matching test/live monthly provider product/price identifiers and verified purchase/reconciliation flows are required before checkout opens; do not invent a free trial, annual option or activation from a pending checkout. +- `PlanAddonTypes.ReadinessPro = 3`; `PTT = 1` and `ADP = 2` are already used and remain unchanged. +- Independent feature flags: `Checklists.System` and `Maintenance.WorkOrders`. Both seed off. A flag controls rollout; the second flag does **not** confer a paid entitlement. Neither depends on the other. Maintenance may operate without Checklists; failed free checks remain saved and visible without the add-on. +- Reuse `MaintenanceDisabled`. Add `ChecklistsDisabled` to serialized `DepartmentModuleSettings` using new protobuf member 23; there is **no DepartmentModuleSettings table/column migration**. Apply settings, flags, department scope and permissions on the server, including workers/integrations, not only navigation. + +## Operational coverage and acceptance + +Every row is required before the corresponding milestone is considered complete. Suggested checklists are starting points to adapt to the organization's equipment, adopted standards, manufacturer procedures and jurisdiction; templates must not claim regulatory certification. + +| Users / workflow | Existing coverage | Required completion of the design | Acceptance milestone | +|---|---|---|---| +| Fire / EMS apparatus, boats, aviation and special operations | Rig checks, SCBA/PPE, unit and serialized equipment history | Pre-use/post-use/event checks; manufacturer-specific readings and units; critical defects override any aggregate passing score; quarantine and qualified return-to-service authorization | P1-M1/M2, P2-M2 | +| EMS bags, medications and biomedical equipment | Par quantities, AED checks, two signature items | Lot/serial/expiry and temperature excursions via inventory; calibrated instruments; independent authenticated witness identity for controlled counts, discrepancy escalation and restricted evidence. Two drawings from one user are not a dual attestation. Do not collect patient records in generic checks | P1-M1/M3, P2-M2 | +| SAR / wildland / volunteer organizations | Rope caches, personal packs, agency pre-use templates | Post-deployment rehabilitation and decontamination; retirement/inspection limits; no-shift scheduling; equipment checked out to individuals; pooled kit and borrowed-resource readiness | P1-M2/M3, P2-M2 | +| Emergency management / EOC / shelters | Department and station targets, readiness packet | EOC activation/handover/demobilization, shelter opening/accessibility, cache deployment/return, communications exercises, emergency power and continuity checks; site/group ownership, operational-period schedule and overdue escalation | P1-M1/M2/M4 | +| Industrial / construction / warehouses | Workplace audits, forklift pre-op, PM | Meter/odometer/hour/cycle-driven PM with calendar-or-meter whichever-first rules; condition thresholds; calibration/certification expiry; job hazards, isolation/permit references, qualified technicians, shift handover and independent release approval | P2-M1/M2/M3 | +| Businesses / facilities / campuses / fleets | Facility issues, vehicle inspections, costs | Site/building/room or free-text location without an inventory dependency; opening/closing/continuity checks, recurring vendor services, warranty/vendor/contact links, service documentation, cost centers and downtime windows | P1-M1/M2, P2-M1/M4 | +| Small organizations | Anyone in scope can complete, text parts fallback | Manual checks and work requests without shifts, inventory or a dedicated mechanic; simple defaults, accessible forms, printable/exportable evidence and clear ownership | P1-M1/M3, P2-M1 | +| Large / multi-site organizations | Role routing, digests, paging | Group/site-scoped access, bulk assignment/import with preview and row errors, technician queues, approval thresholds, cost currency, service-level targets by priority and business calendars, filtered/export limits | P1-M2/M4, P2-M1/M4 | + +## Required domain corrections + +### Checklists and evidence (free) + +1. Support explicit Pass, Fail and Not Applicable outcomes, with a configured reason for N/A. Missing answers must never become passes. Calculate score over applicable scored answers; zero applicable weight produces no score, never 100%. A critical failure makes the run fail regardless of score. Separate completed, passed, late, missed, skipped and exempt states in reports. +2. Item definitions need instructions, response type, stable identity, requiredness, criticality, allowed N/A, explicit pass semantics (including yes/no and selected options), numeric unit/range, evidence requirements and conditional visibility/requiredness. Never execute arbitrary expressions from definitions. Validate definition size/depth, unique item IDs, ranges, positive weights and valid references. +3. Published definitions are immutable snapshots. Draft editing/publishing/retiring are distinct. Copying a template produces a department-owned draft with new definition/section/item IDs; changing catalog content never rewrites a department definition. Pin all started runs and generated occurrences to the published version. Inactive/retired targets stop future generation without removing history. +4. Keep authenticated actor, target snapshot, server receive/submit time and client capture time separately. Device clocks and GPS are evidence, not authorization. Independent witness requirements use separate authorized users, timestamps and an explicit attestation. Restrict sensitive signatures/counts and account for the ADP protected-field catalog before persisting them. +5. Client GUIDs are scoped/validated for department and occurrence. An identical retry returns the original result; a changed payload under the same completed ID returns a conflict. Use optimistic concurrency for drafts/progress, one terminal completion per occurrence, uniqueness in both databases, and transactional answer/occurrence/completion/audit writes. Emit downstream effects through the existing durable outbox after commit. +6. Offline runs preserve published definitions, target/assignment snapshots and attachment queues. Membership revoked before sync blocks the mutation without discarding the local work. Reject stale conflicting submissions explicitly; do not silently overwrite another user's completion. Scan and authorize uploads before they count as required evidence; apply size/type limits and existing storage/protected-data rules. +7. Define recurrence in department local time with UTC storage, a documented DST gap/fold rule, month-end clamping, leap-year behavior and schedule effective dates. Materialized rows include target in their occurrence uniqueness key when one schedule expands to multiple targets. Grace windows, late completion, excused skips, reassignment, asset retirement and worker outage catch-up preserve what was actually due. Report denominator rules must be explicit. +8. Notification deduplication is per recipient/channel/occurrence or digest window. Resolve current asset ownership at send time, honor preferences/quiet hours, support an explicitly configured emergency exception, and prevent worker retries from duplicating alerts. A module/rollout pause freezes new work/notifications without manufacturing missed checks for the paused period. +9. Failure notes, photos, critical defect indicators and checklist alerts remain free. Work order creation is a separate authorized paid effect; a failed billing lookup must never roll back a valid free completion. Paid effects need unique source occurrence/item keys and retryable outbox processing. + +### Maintenance and work orders (Readiness Pro) + +1. Add work-request triage outcomes (accepted, rejected with reason, duplicate linked to canonical order), reopen with reason, structured hold/cancel reasons, assignment acceptance, multiple contributors and restricted external vendor details. Centralize the transition matrix and enforce field/role preconditions at the service boundary. Sequence human numbers atomically per department/year, with a unique index. +2. Model completion separately from verification/closure. Closure requires resolution/cause, applicable test/inspection evidence and authorized verification for safety-critical work. For hazardous work, record procedure/version, permit/isolation references and authorized personnel. Application checkboxes do not execute or replace physical energy-isolation procedures. +3. **Remove automatic unconditional restoration of the previous unit/asset state.** Track every active defect/maintenance hold and its source, reason and release authorization. Closing one order cannot clear another open hold, a later dispatcher status change, retirement, or a manual safety restriction. Use conditional state updates and a qualified return-to-service step. Billing expiry or disabling a flag must never clear a hold. +4. PM supports calendar, usage and condition triggers, units, source/timestamp of readings, meter replacement/reset, interval baselines, due-soon and overdue, fixed-vs-completion-based scheduling and blackout/service windows. Generate at most one work order per schedule/target/cycle, even across concurrent workers. Record rescheduling/deferral approvals, without erasing original due dates. +5. Parts flows include reservation/issue/consume/unused return, serial/lot/expiry when available and idempotent ledger links. Correct consumed parts with a linked inventory reversal, never deletion or an unrelated Adjust. Record labor time, rate snapshots, vendor charges, estimated/approved/actual cost, currency and cost center using decimal quantities/money. Keep free-text fallback and indicate when stock was not posted. +6. Add task steps/checklists on a work order, skills/role eligibility, attachments, vendor/warranty/reference links, downtime intervals, response/repair SLAs, priority escalation, customer/requester updates and assignment history. Reports distinguish elapsed downtime, active repair time, waiting time, PM compliance, repeated failures, backlog aging and costs. +7. Historical access/export must survive subscription cancellation, module disablement and feature rollout pause, under normal authorization and retention rules. Define a narrow safety-release operation for existing holds after expiry; it must not authorize new maintenance work. Commercial suspension does not delete or rewrite evidence. Plan separate read/export and create/update authorization methods when those data surfaces ship. + +## Billing and access contract + +- Trust reconciled department-scoped entitlements with exact add-on IDs, effective start and exclusive paid-through end. Missing/null/malformed billing responses, wrong department/type, future/expired dates and billing transport failures deny new paid operations. Do not rely on list count or feature flag alone. +- Core's generic `SubscriptionsService` helpers can return synthetic `SYSTEM`/forever entitlements when Billing API is unconfigured. Readiness Pro must reject this behavior explicitly; do not change PTT/ADP behavior as part of this feature. +- Readiness Pro renews monthly even if the base plan is annual. Do not use `PlanAddon.GetEndDateFromNow()`'s generic 7-day grace/annual-plan fallback to grant paid access. A successful reconciled payment supplies actual monthly period boundaries; checkout creation alone grants nothing. +- Cancellation at period end preserves access through the already paid interval. Immediate cancellation/revocation shortens that interval. Billing webhook/API reconciliation must distinguish these cases, verify signatures, deduplicate/replay safely, handle out-of-order events and invalidate entitlement caches. No new implicit grace period is approved in this amendment. +- One department-level subscription, without invented per-seat pricing. Buying/managing it requires existing department billing authorization. Test/live Stripe and Paddle mappings must be isolated. No zero-cost placeholder product or guessed provider identifiers. Readiness Pro is not offered for purchase until the external billing implementation is verified. +- Acceptance: all combinations of both flags, both module settings, free/paid base plan, no addon, PTT-only, ADP-only, active/future/expired/cancel-at-end Readiness Pro and unconfigured/unavailable billing. Checklists must make **zero billing calls**. Maintenance-only operation must work with the Checklists flag off. Recheck entitlement at writes/worker effects, not only session login. + +## Integration and identifier reconciliation + +- Live migrations end at M0188 in both dialects. Use **M0189** for the initial readiness flag seed. The registry currently reserves M0189-M0195 for this plan; subsequent migrations take the next physical number and must recheck the registry at authoring time. Do not reuse the original M0122-M0128/M0131-M0137 references. +- Current registry reservations: checklist/work-order permissions **112-115**, workflow triggers **67-73**, workers **63-66**. These are reserved, not implemented by the initial slice. Do not use the plan's stale values. Extend allocation tests to pin addon 3 and check duplicate addon values. +- Services use the existing Autofac registration/module pattern; current services/controllers support constructor injection. Do not introduce another service locator or persistence framework. SQL remains dual Dapper/FluentMigrator. +- Inventory, Unit, Responder, billing and coordination repositories are separate deliverables. Inventory absence must not prevent unit/personnel/site checklists or manual maintenance requests. Validate soft references against the owning department, preserve historical asset location/serial snapshots and do not infer call-time equipment from today's location. +- Reuse RMS's immutable PDF + evidence-manifest capture contract, source authorization and checksums. Plan ADP field ownership, purge/export/legal-hold semantics, access audit, classification and attachment scanning before checklist/work-order persistence. Do not create legacy Logs after Records cutover. Hydrant/prevention inspections already owned by RMS stay there; readiness checks link to them without duplicating violations or official inspections. +- Standard references need edition metadata and local review dates. The old plan's NFPA 1911 chapter references must not imply the current consolidated NFPA 1910 is identical, or that a short starter template implements a standard in full. + +## Delivery and release gates + +**Initial implementation slice:** independent flag seeds and keys; addon identity/monthly interval semantics; server access service; serialized checklist module toggle; free catalog with sector coverage; authenticated web catalog and v4 catalog/access endpoints; focused billing/gating/catalog/API tests. Seeded flags remain off. This slice does not provide persisted checklist execution or a purchasable maintenance product. + +**Current state:** P1-M1 implementation is complete and uncommitted: definition builder, immutable publication/versioning, authorized on-demand web runs, evidence, independent witnesses, history/export, permissions, ADP, audit transactions and durable workflow events. See the [implementation report](checklists-p1-m1-implementation.md) and completion validation below. Rollout flags remain off; PostgreSQL runtime verification is still pending. + +**Additional release gate:** the subsequent [Workflow/ADP amendment](readiness-workflows-adp-contract.md#milestone-acceptance-gates) requires a field-by-field and serialized-sink audit of this baseline, including derived scores/outcomes, safe Workflow projections and redaction metadata. Those new checks are planned, not covered by the earlier passing test counts. Complete the P1-M1 follow-up gaps before release and the corresponding checks in each later milestone. + +**Next milestone:** P1-M2 scheduling/targets/notifications/calendar, then P1-M3 offline mobile and P1-M4 reports. These are not part of the completed P1-M1 scope. Implement the expanded P2 milestones after free Checklists has shipped. Meter/condition PM, approval and safety-release requirements belong to Phase 2 GA, not a stretch backlog. + +Required proofs beyond existing milestone tests: concurrent edit/submit/worker races on both databases; cross-department object IDs and uploads; critical-failure and N/A score cases; end-to-end cancellation/failure/recovery; two simultaneous asset holds plus an intervening dispatch status change; historical asset moves; verified immutable records after template edits/retirement; outage catch-up/DST/leap days; large-department paging/digests/export bounds; database up/down/up on disposable databases; browser accessibility and both apps offline-to-online. Do not claim these pass until executed. + +## Sources checked for the review + +- [USFA/FEMA: NIMS resource management](https://www.usfa.fema.gov/a-z/nims/managing-resources.html): preparedness, resource tracking through demobilization and reporting support the EM coverage additions. +- [OSHA recommended safety and health practices](https://www.osha.gov/shpguidelines/docs/OSHA_SHP_Recommended_Practices.pdf): documented inspections and verifying corrective actions support auditable failure/closure workflows. +- [OSHA powered industrial trucks, 1910.178](https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.178) and [NIOSH daily inspections](https://www.cdc.gov/niosh/docs/wp-solutions/2022-100/): pre-use/shift examination and unsafe-equipment handling support event checks and explicit safety release. +- [OSHA hazardous energy control, 1910.147](https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.147): authorized-person and verification requirements support procedure references and role-controlled maintenance approval. +- [NFPA publications catalog](https://link.nfpa.org/all-publications/655/2012): lists NFPA 1910 (2024) for in-service emergency vehicles; use edition-specific references reviewed by the adopting organization. + +## Initial slice implementation and verification (2026-09-08) + +Implemented in Core, left uncommitted: + +- M0189 flag seeds in SQL Server and PostgreSQL; independent keys, both off. Down deliberately preserves operator-managed rows because a guarded Up cannot establish ownership of existing keys. +- Readiness Pro add-on 3 and monthly interval estimation independent of the base plan; configured USD 150 Stripe / EUR 195 Paddle monthly offers. No provider product or payment row is fabricated, and the API explicitly reports checkout unavailable. +- Autofac-registered `ReadinessAccessService`, rejecting missing billing configuration, wrong department/addon/type, missing/future/expired dates, synthetic SYSTEM/forever payments, null payloads and billing exceptions. Cancellation remains active through the reconciled paid-through date. Free checklist access never invokes billing. +- Serialized `ChecklistsDisabled` protobuf member 23, department settings controls and gated navigation. Existing MaintenanceDisabled is reused. +- 28 immutable starter templates covering the requested sectors; stable section/item GUIDs, critical-check metadata, failure-note requirements and witness metadata. Authenticated web gallery/preview plus v4 `Checklists/GetChecklistTemplates`, `Checklists/GetChecklistTemplate` and `Readiness/GetAccess`. Server service gates apply to direct catalog reads. Neutral English UI resources provide fallback; translations and localized template content remain for P1 completion. + +Validation executed: + +- `dotnet build Resgrid.sln --no-restore --verbosity quiet`: **passed**, 0 errors, 43 existing compatibility/obsolete API warnings. This includes compiled Razor views. +- Focused tests in `ReadinessAccessServiceTests`, `ChecklistTemplateServiceTests`, `ReadinessApiTests` and `IdentifierAllocationTests`: **54 passed**, 0 failed. Includes both migration assemblies' numbering/parity checks, the preserved addon IDs, invalid/unpaid entitlement cases, independent flags/modules, catalog structure/search and department-scoped API contracts. +- `git diff --check`: passed. +- **Not executed:** SQL Server/PostgreSQL Up/Down/Up against live databases (no isolated test connections configured; Docker daemon unavailable), authenticated browser smoke tests, mobile checks or billing-provider checkout/reconciliation. No live database migrations or feature activation were performed. + +At the end of this initial slice, the remaining P1-M1 builder/versioned persistence/on-demand execution was next. It is now implemented as recorded below. Paid work-order lifecycle, PM, integrations and purchase/reconciliation remain P2; the initial gate/catalog slice is not a GA declaration. + +## Provider mapping follow-up (2026-09-08) + +The user supplied these production mappings for the previously confirmed monthly prices: + +| Provider / region | Monthly price | Product ID | Price ID | +|---|---|---|---| +| Stripe / US | USD 150 | `prod_VDtkPNAa2qNBx3` | `price_0UDRwaqJFDZJcnkVnYP8bAcd` | +| Paddle / EU | EUR 195 | `pro_01m20xwmzpnkxzp7mm7nwwxp7p` | `pri_01m20xy5x54j0sp4mcydcm4q6m` | + +- M0190 (both dialects) seeds the Stripe PlanAddon row: ID `8a82f517-13db-4950-a514-d990248a67e6`, AddonType 3, Cost 150, ExternalId above, no base PlanId and empty TestExternalId. An existing Readiness Pro catalog is preserved. This is the Stripe/USD catalog amount; Paddle/EUR uses its own price mapping and EUR 195 offer. +- Paddle price selection follows the existing PaymentProviderConfig convention: `PaddleReadinessProAddon`, `PaddleReadinessProAddonTest`, `GetPaddleReadinessProAddonPriceId()`. +- Test/sandbox IDs are not supplied and remain unset. Readiness Pro's Stripe and Paddle selectors never fall back from test mode to production. Existing PTT and ADP key-selection behavior is unchanged. +- Legacy BuyAddon GET/POST and the PTT-specific subscription service methods reject Readiness Pro, preventing the new catalog row from opening the wrong purchase flow. Dedicated checkout remains unavailable until P2-M1 purchase/reconciliation is implemented and verified. +- Provider account metadata (monthly interval, currency, amount, product ownership) still needs checking in the dedicated checkout implementation. The supplied IDs have been recorded, not queried or modified at Stripe/Paddle. No live migration or purchase was executed. +- M0190 consumed the second migration in the existing readiness reservation. P1-M1 subsequently consumed M0191; the next physical migration is now M0192. Recheck before authoring further migrations. + +Provider mapping validation: full solution build passed (0 errors, 41 existing warnings); 75 focused readiness, mapping, allocation, payment-configuration and ADP billing-authorization tests passed. Database migration round-trips and live provider checks remain unexecuted. Changes are uncommitted. + +## P1-M1 completion (2026-09-08) + +The free on-demand web workflow is implemented. The [implementation report](checklists-p1-m1-implementation.md) records the delivered behavior, persistence choices, deployment prerequisites and remaining phase boundaries. This completion supersedes the earlier initial-slice status; it does not declare all of Phase 1 or Readiness Pro shipped. + +- Full solution build: `dotnet build Resgrid.sln --no-restore --verbosity quiet` passed with **0 errors and 6,379 warnings**. Warnings remain in the solution, including the database fixture's use of the existing obsolete migration-source test pattern. +- Focused regression run: **226 total, 222 passed, 4 skipped, 0 failed**. Coverage includes checklist validation/services/authorization, readiness and billing boundaries, identifier allocations, ADP catalog/GDPR export, Records permission/claim regressions, workflow contracts and browser scripts. +- SQL Server: four real database checks passed on a dedicated disposable LocalDB instance, including M0191 Up/Down/Up and repeated Up, tenant foreign keys, duplicate versions, blob-free metadata lists, transaction rollback and two-writer serialization. Each fixture database is removed after the test. +- Browser: the checklist editor/run scripts passed in headless Edge, covering stable item identities and ordering, escaped content, CSRF, conditional answer clearing, serialized saves, conflict preservation and edits made during an in-flight save. Existing browser script tests also passed. A deployed authenticated application walkthrough was not performed. +- PostgreSQL: the corresponding four real database checks were skipped because no test connection was configured and Docker's Linux engine was unavailable. Both migration projects compile and allocation/parity tests pass; this does not substitute for PostgreSQL execution. +- `git diff --check` passed. Resource XML was validated across neutral and nine language files. Starter-template content localization and later-phase mobile/reporting work are not claimed complete. + +M0191 creates the seven checklist tables in both dialects. Implemented allocations are permissions **112-113**, workflow triggers **67-68**, `EventingTypes.ChecklistUpdated = 11` and **ADP catalog 14**. Workers **63-66**, remaining triggers **69-73** and work-order permissions **114-115** remain reserved for later milestones. The next physical migration is **M0192**, within the existing readiness block; no subsequent block moves. + +Both feature flags remain off and no production migration, deployment, purchase or provider-account mutation was performed. Checklists have no billing dependency. Readiness Pro remains USD 150/month via Stripe and EUR 195/month via Paddle, with the supplied production IDs preserved and dedicated checkout/reconciliation pending P2-M1. + +Suggested commit message: `feat(checklists): complete P1-M1 on-demand web workflow`. Work is left uncommitted for human review. + +## Localization correction (2026-09-08) + +The supported-locale registry includes Arabic, which the initial P1-M1 resources omitted. The checklist resource family now includes the neutral dictionary and all ten supported languages: English, German, Greek, Spanish (Latin America), French, Italian, Polish, Swedish, Ukrainian and Arabic. Each has 174 matching keys with real translations, including interface choices, status names, permission labels, confirmations, signature instructions, accessibility labels and progress messages. German passing-answer terminology, Greek authentication wording, Swedish performer terminology and Ukrainian phrasing were corrected. + +The condition editor displays translated choices and preserves the existing stored response codes. It no longer asks users to type English boolean/pass-fail codes. Completion history localizes those codes, and user-authored names/options remain verbatim. Shared spellings that are correct in both languages, product names such as Readiness Pro and format names such as JSON remain intentional. This correction covers the dictionaries and their interface consumers; starter-template content and server validation message localization remain separate from those dictionaries. + +Validation: the affected test project and its web/Razor dependencies build with zero errors. All 15 focused tests pass, including one compiled-resource/placeholder/format-argument check per supported locale and all browser scripts. The new browser check exercises the editor and runner in every supported language, preserving condition/answer codes and user-authored text. The project memory now requires supported-locale coverage and genuine translations instead of English placeholders. Changes remain uncommitted. + +## Workflow and ADP planning amendment (2026-09-08) + +The user requires Workflow engine events and ADP coverage wherever readiness data can contain PII/PHI. The [delivery contract](readiness-workflows-adp-contract.md) now defines event timing and trigger mappings, transaction/outbox and consumer deduplication rules, pre-serialization redaction, protected-model/data-flow inventory, worker/source-reference behavior, and acceptance checks for every milestone. Checklists remain free, Readiness Pro remains a separate monthly product, and durable department ADP state continues to govern protection independently of billing and rollout flags. + +The amendment was checked against the current ADP plan and the P1-M1 event/catalog implementation. It changes documentation only; it does not claim implementation or runtime verification of the new acceptance gates. No new numeric allocation, migration, feature activation or deployment is included. diff --git a/docs/architecture/readiness-workflows-adp-contract.md b/docs/architecture/readiness-workflows-adp-contract.md new file mode 100644 index 000000000..fe39c0887 --- /dev/null +++ b/docs/architecture/readiness-workflows-adp-contract.md @@ -0,0 +1,83 @@ +# Readiness: Workflow events and ADP contract + +Updated 2026-09-08 at the user's request. This is a required delivery contract for free Checklists and paid Readiness Pro, including web, v4 API, mobile, workers, integrations and reports. It amends the [readiness plan](../../../int-Coordination/docs/architecture/checklists-maintenance-workorders-design.md) and [implementation review](readiness-pro-plan-review-2026-09-08.md). The [ADP plan](../../../int-Coordination/docs/architecture/department-protected-data-implementation-plan.md), especially sections 3.4, 5, 8, 9 and 22.1, governs protected-data behavior. Where older readiness prose suggests raw model broadcasts or plaintext audit snapshots, this contract takes precedence. + +## Current implementation and remaining proof + +P1-M1 already emits ChecklistCompleted and ChecklistFailed through the transactional domain outbox and registers Workflow variables/context/sample data. ADP catalog 14 covers Content on the seven checklist tables and ChecklistCompletionFiles.Data. Interactive services use protected read/write services, and Eventing broadcasts a completion identifier. This is the implementation baseline, not evidence that every requirement below is already satisfied. + +Before releasing P1-M1, complete the field/disclosure inventory below against the actual models and every serialized sink. In particular, review the existing queryable Score, Passed, IsFailure, target/actor/witness IDs and event projections; a small payload is not automatically non-sensitive. Verify ADP redaction metadata and handling of inferred sensitive outcomes, audit payloads, derived files and protected-data lifecycle transitions. Record gaps as P1-M1 follow-up work. Scheduling, mobile/offline, reporting and all Phase 2 integrations must satisfy the corresponding gates when implemented. This amendment changes the plan; it does not claim those checks have run or deploy further code. + +## Workflow event contract + +Workflow support is a service-level requirement. Every entry point for a supported transition uses the same domain command and event path; controller-only notifications do not satisfy it. A checklist failure remains saved and emits its free event even when Readiness Pro is absent. Each paid action invoked by a Workflow rechecks Readiness Pro, module/flag state, department scope and ordinary authorization at execution time. Events do not grant access to the referenced object. + +The existing trigger reservation is 67-73, as recorded in the [identifier registry](../../../int-Coordination/docs/architecture/identifier-allocation-registry.md). Values 67-68 are implemented; the remaining mappings below consume that reservation when their milestones ship. Recheck the registry and physical enums before authoring. Do not use the old 63-69 values elsewhere in the original plan. + +| Workflow trigger | Required emission boundary | Delivery milestone | +|---|---|---| +| ChecklistCompleted = 67 | Once the completion reaches its final submitted state; after an independent witness attests when required. Completion and passing are distinct: a completed run may fail. | P1-M1; reused by P1-M3 | +| ChecklistFailed = 68 | One logical event per failed item on accepted initial submission, including a submission awaiting its witness. A witness/retry does not emit the same failure again. The current envelope event name is ChecklistItemFailed. | P1-M1; paid consumers in P2-M2 | +| ChecklistMissed = 69 | Once an occurrence actually transitions to missed after its due/grace window. Worker replays and module pauses must not manufacture additional missed transitions. | P1-M2 | +| WorkOrderCreated = 70 | After the new work request/order is committed, whether created manually, through the API, from a failed check or from preventive maintenance. Include a typed source and opaque source reference. | P2-M1; reused in P2-M2/M3 | +| WorkOrderStatusChanged = 71 | On each accepted lifecycle transition, with old/new state and aggregate revision. This covers triage, in-progress, waiting/on-hold, completion, closure, cancellation and reopening when supported. | P2-M1 onward | +| WorkOrderAssigned = 72 | On assignment, reassignment or unassignment; identify the change with reviewed routing identifiers, never copied names or contact details. | P2-M1 onward | +| WorkOrderOverdue = 73 | On transition into overdue for the applicable due-date/revision episode, not on every scheduler scan. Rescheduling and re-entry have explicit episode/deduplication semantics. | P2-M3 | + +Additional meaningful transitions must be specified alongside the feature: checklist publication/schedule changes and excused skips; approval requested/decided; safety hold placed/released; cost-affecting parts/labor changes; meter/condition thresholds becoming due. A lifecycle state change can use the existing status trigger where it describes the transition accurately. Otherwise, define a separate named domain event and Workflow trigger, then reserve its numeric identifier before implementation. This document allocates no additional numbers. Do not silently omit automation support for those features or overload a status event with a change that did not change status. + +For every emitted event: + +1. Persist the business mutation, audit record and outbox entry in one unit-of-work transaction. Dispatch only after commit. Rollback emits nothing; an unavailable bus leaves durable pending work. Do not send to Workflow or SignalR from inside a transaction before the outbox commit. +2. Use a stable event identity, department, aggregate ID/type/revision, UTC occurrence time and correlation/causation identifiers. Preserve that identity through delivery retries. Delivery is at least once; consumers deduplicate by event and effect identity. Paid work-order generation also deduplicates its source completion/item and policy so one failure cannot produce multiple orders or holds. +3. Use a versioned allowlisted projection, not serialized domain entities. Only reviewed routing/lifecycle data belongs in clear fields. Subject names, free text, selected answer text, notes, witness statements, filenames, coordinates and attachment bytes are excluded from the safe payload. Review numeric results and person-linked outcomes before allowing them; omit or redact protected derivatives as required by the ADP catalog. +4. Register the trigger through the existing WorkflowEventProvider/outbox listener, WorkflowTriggerEventType, WorkflowTemplateVariableCatalog, WorkflowTemplateContextBuilder and WorkflowSampleDataGenerator; include designer labels/descriptions and localized UI resources. Tests pin trigger IDs, payload schema and variable parity. Previews and examples use synthetic data only. +5. When ADP is enforced, build the safe projection **before any serialization**. Protected scalar variables use the exact shared REDACTED sentinel and structured is_redacted, redacted_fields and catalog-version metadata. Omit binaries. Do not substitute false, zero or empty text for a redacted value, which could trigger a mistaken condition. The designer identifies protected/unavailable variables and explains how existing definitions behave on enrollment. +6. Apply that projection to the outbox, bus messages, Workflow input/event JSON, queue retries, dead letters, execution history, previews, webhooks, scripts, AI/custom HTTP actions, logs and telemetry. Never persist plaintext and attempt to scrub it afterward. URLs contain opaque identifiers and require normal authorization; no grant, secret, filename, narrative or protected value belongs in a URL. +7. Workflows never obtain a user grant or decrypt protected fields, even when a human has a valid grant or a notification channel permits protected content. Re-evaluate current durable ADP state when older queued work is dispatched/replayed; enrollment cannot release a previously queued plaintext payload. Use the platform's controlled migration/redaction policy for retained queues/history. + +SignalR/Eventing is a separate refresh mechanism. Keep it to reviewed identifiers and tenant-authorized subscription scopes; it does not replace Workflow events. Notification workers default to generic sign-in messages and use the existing per-channel egress policy. They must not copy protected names or findings into a push preview, email subject, SMS, voice script or provider metadata. + +## ADP field and data-flow inventory + +Users may enter PII/PHI into an apparently operational field, a custom answer, an uploaded photo or an invoice. A warning not to enter patient data is useful guidance, not a protection boundary. Readiness is not a patient-record workflow; nevertheless its flexible input and copied evidence must be protected according to their possible contents. + +Each milestone must maintain a reviewed catalog manifest listing model/table/property or protected JSON slot, stable field ID, storage kind/capacity, Pii/Phi/Sensitive classification, owning-department path, read/write permissions, UI reveal behavior, retention owner, search behavior and egress rules. Use existing ADP classifications and services. Explicitly document reviewed plaintext exclusions. For mixed/unbounded content, protect the entire slot and assess potential PHI; do not rely on detecting names or diagnoses after entry. Classifications and field IDs cannot silently change after deployment; follow the catalog-version migration process. + +| Model/data surface | Potentially sensitive input or copies | Required coverage | +|---|---|---| +| ChecklistDefinition and immutable versions | Names, descriptions, instructions, section/item names, option labels, conditions, custom schema JSON and any copied template text after editing | Protect Content as a whole, including historical versions. A published snapshot retains its protection and cannot be rewritten during an edit or left plaintext during a catalog upgrade. | +| ChecklistSchedule, ChecklistOccurrence and DepartmentChecklistSettings | Assignment/target display snapshots, user-configurable notification text, schedule notes, exemption/skip reasons, custom configuration | Protect free text and snapshots. Keep only reviewed recurrence/routing fields queryable; person-linked schedules still require scoped access. Existing settings Content is already cataloged; future structured fields need explicit review. | +| ChecklistCompletion and ChecklistCompletionItem | Free text, selected-option values, readings/dates that may describe a person, failure/handover notes, location descriptions/GPS, witness statements, copied target identity | Protect the completion/answer Content slots. Review Score, Passed and IsFailure as derived data, especially personnel-related checks, instead of assuming every number/boolean is safe. | +| ChecklistCompletionFile and WorkOrderFile | Image/document bytes, signatures, original filenames/captions, embedded metadata, extracted text, thumbnails, scan/moderation findings | Protect binary content and sensitive metadata at every copy. Strip unnecessary image metadata; use existing size/type/scanning controls. Scanning alone does not remove PII/PHI. Authorize the parent and ADP access before loading/downloading bytes. | +| WorkOrder, requests, WorkOrderActivity and approval/release records | Titles, descriptions, comments, resolution/cancellation reasons, location, reporter/vendor contact details, hazards/permit references, source-check snapshots, approval and safety-release statements | Catalog every sensitive text/JSON slot before its first production write. Protect appended activity and approval history as well as the current row. A non-editable history record still needs ADP. | +| WorkOrderLabor and WorkOrderPart | Technician notes/identity snapshots, individual rates/pay-related information, free-text descriptions, vendor/contact details, invoice/receipt data and copied inventory information | Review individual financial/personnel data separately from aggregate costs. Protect sensitive columns and snapshots; do not expose them through generic assignment/cost events. Use source-system authorization for linked records. | +| WorkOrderRecurrence and future meter/condition PM data | Template titles/instructions, technician/vendor data, condition notes, service documents and protected source snapshots | Protect templates and evidence; validate which typed trigger thresholds are safe for unattended evaluation. Generated orders preserve protected-source references without copying plaintext into a job payload. | +| Reports, readiness packets, exports, calendars, search and caches | Rendered PDF/CSV/JSON, manifest labels, summaries, per-person scores, calendar titles, search snippets, extracted text and cached DTOs | Derived data inherits source protection. No plaintext protected content in shared caches, search indexes, report jobs or calendar/event projections. Interactive release requires current authorization/grant; unattended output uses safe/redacted projections. | +| Audit, Workflow/notification jobs, telemetry, moderation and support surfaces | Before/after JSON, request/validation excerpts, GPS, filenames, comments, diagnostic payloads and copied attachments | Keep ordinary audit and diagnostics value-free (IDs, operation, revision, state/result codes). If an audit/evidence system must retain sensitive content, use its cataloged ADP storage and authorized read path. Never dump a model or protected request body into logs. | + +Opaque IDs, tenant/foreign keys, row versions and necessary lifecycle/timestamp fields may remain plaintext under the ADP architecture after review. They still require tenant/role/group authorization and data minimization. They are not anonymous and cannot automatically be expanded into names or combined with sensitive outcomes in a downstream projection. + +## ADP implementation requirements + +- Reuse IProtectedReadService, IProtectedWriteService, the protected-data broker/grant context, ProtectedFieldCatalog, AdpTableBindings and the existing binary binding/storage patterns. Register new service/repository types in all applicable Autofac modules. Include encryption envelope capacity and required protection markers in both migration dialects, catalog/binding parity, backfill/enrollment verification, rotation and recovery checks. No new bespoke encryption or bypass repository path. +- Authorize the resource first, then validate a current tenant/user/application/session-bound grant for protected reads and writes. Apply the same policy in web, API, downloads, imports and integrations. Grant expiry/revocation must conceal protected views and stop dependent operations; never fall back to plaintext. Redacted edit round-trips must preserve unseen values or require re-verification, not save REDACTED as customer content. +- Durable department ADP state is the data-safety authority. Losing Readiness Pro, disabling either readiness flag/module, or a billing/enrollment-service failure never decrypts, deletes or disables protection on existing records. ADP and Readiness Pro are separate products. Free Checklists remain free; protected departments receive ADP behavior without an added Readiness Pro requirement. For departments not enrolled, use ordinary platform controls and do not claim ADP encryption is active. +- Workflow, scheduler, notification, export, integration and other unattended workers do not borrow/store/refresh a user's grant and cannot decrypt protected data. Design checklist occurrence and PM generation around immutable protected-source references plus reviewed routing metadata. Copying a ciphertext envelope to a different row is not valid because its associated-data binding changes. If a feature cannot operate through references and safe metadata, it needs a separately authorized attended operation or an explicitly approved protected-workload design before shipping; there is no implicit worker bypass. Referenced immutable sources remain retained while generated records or legal holds depend on them. +- API/mobile views expose clear protected/redacted state and the shared grant-renewal flow. Offline persistence and queued uploads use the approved ADP secure-local-storage policy; no protected plaintext in ordinary local storage, crash logs, notifications or sync queues. Recheck membership, grants and current department policy at sync. Preserve recoverable local work securely when sync is denied, and prevent expired grants from being replayed. +- Every file derivative, report and finalized RMS readiness artifact follows the source authorization, protected-storage and manifest/checksum contract. The GDPR background export has no decrypt grant: recursively redact protected values and include the structured redaction manifest. An attended authorized export is a separate flow. Apply retention, legal holds, subject export and department deletion to every new table, file, source reference and derived artifact in both supported database/storage paths; unresolved platform gaps are explicit release dependencies. + +## Milestone acceptance gates + +| Milestone | Workflow and ADP proof required in addition to functional tests | +|---|---| +| P1-M1 follow-up | Inventory the existing seven-table catalog, routing/derived scalar exclusions and every serialized sink. Verify transactional completion/failure events, witness timing, safe variables/redaction metadata, no-grant/expired/wrong-tenant denial and sensitive-data absence from audit/outbox/history/logs. Track remaining gaps before release. | +| P1-M2 | Missed/skip/schedule-event contracts; deduplication across retries/DST/pauses; protected assignments and reasons; generic digests and calendar projections; workers generate from safe metadata/source references with no user grant. | +| P1-M3 | Web/API/mobile have identical field/tenant/grant enforcement; protected offline data and attachments remain secure through expiry/revocation/reconnect; replay creates one logical completion/event/effect. | +| P1-M4 | Protected source content does not leak through reports, CSV/PDF exports, readiness manifests, calendars, analytics, cache keys or search. Authorized interactive and redacted unattended exports are tested separately. | +| P2-M1 | Work-order model/attachment/activity/labor/parts catalog and migrations precede writes; creation/state/assignment events work from every entry point; Workflow actions cannot bypass billing, ADP or ordinary permissions. | +| P2-M2 | Failure-to-order and inventory/hold effects are idempotent; sensitive findings are referenced or protected at the destination; approval/hold/release event contracts are explicit; one system's access does not authorize another system's protected content. | +| P2-M3 | PM recurrence/condition data is classified; unattended materialization needs no protected plaintext; created/overdue/threshold events and their safe projections survive retries and policy changes. | +| P2-M4 | Repeat disclosure, restore/upgrade, retention/hold/deletion and export tests on both databases and storage paths, including disabled modules, expired Readiness Pro and enforced ADP. No open sensitive-data gap is treated as reporting polish. | + +Use distinctive synthetic PII/PHI canaries in free text, selected options, witness notes, coordinates, filenames and attachments. Verify they are encrypted at cataloged storage and absent from outbox/bus/Workflow persistence, retries/dead letters, logs, caches, indexes, generic notifications and unattended exports. Check exact REDACTED values and field/catalog metadata, not just that a name is absent. Exercise enrollment/upgrade and policy changes between enqueue and dispatch, revoked grants, cross-department object/file references, transaction rollback, repeated delivery and subscriber failure. Record executed proof separately from planned coverage; do not put real customer PII/PHI into tests, fixtures or external memory.