diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx
index 50f28ada6..931422c5e 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx
@@ -534,4 +534,107 @@
الفحوصات
-
\ No newline at end of file
+ الجداول
+ جدول جديد
+ لا توجد جداول بعد.
+ تعديل الجدول
+ معدل التكرار
+ المنطقة الزمنية
+ متوقف مؤقتًا
+ ملاحظات الجدول
+ تاريخ البدء
+ تاريخ الانتهاء (اختياري)
+ الأوقات المحلية
+ أيام الفحوص الأسبوعية
+ اليوم من الشهر
+ الشهر المرجعي
+ وردية العمل للفحوص في كل وردية
+ استخدام الأوقات المحلية كل يوم
+ مهلة الإكمال (بالدقائق)
+ الجدول مفعّل
+ حفظ الجدول
+ الفحوص المستحقة
+ لا توجد فحوص في هذه القائمة.
+ موعد الفحص
+ نهاية المهلة
+ الإجراءات
+ فتح الفحص
+ تبرير تجاوز الفحص
+ سبب التجاوز المبرر للفحص
+ مجدول
+ لم يُنفّذ
+ تجاوز مبرر
+ ملغى
+ كل وردية
+ يوميًا
+ أسبوعيًا
+ شهريًا
+ كل ثلاثة أشهر
+ كل ستة أشهر
+ سنويًا
+ تحقق من تواريخ الجدول وأوقاته والهدف وإعدادات التكرار.
+ ليس لديك إذن لإدارة جداول الفحص.
+ هذا الجدول غير متاح.
+ انشر قائمة التحقق قبل جدولتها.
+ هذا الفحص غير متاح أو تم تغييره بالفعل.
+ أدخل سببًا لا يتجاوز 4000 حرف.
+ اكتملت قائمة التحقق
+ لم تجتز قائمة التحقق
+ لم تُنفّذ قائمة التحقق
+ تم تغيير جدول قائمة التحقق
+ تم تبرير تجاوز الفحص المجدول
+ يحفظ الجدول الإصدار المنشور الحالي. تستبدل التغييرات الفحوص المستقبلية التي لم تبدأ؛ ويُلغي الإيقاف المؤقت الفحوص التي لم تبدأ.
+ تتبع الأوقات هذه المنطقة. يُنقل الوقت غير الموجود إلى الأمام؛ ويحدث الوقت المكرر مرة واحدة.
+ أدخل ما يصل إلى ثمانية أوقات بتنسيق 24 ساعة، مفصولة بفواصل إنجليزية.
+ للفحوص الشهرية أو الأقل تكرارًا. تستخدم الأشهر القصيرة يومها الأخير. يحدد الشهر المرجعي الفحوص ربع السنوية ونصف السنوية والسنوية.
+ للفحوص في كل وردية فقط. تستخدم الوردية المختارة تواريخ البدء المسجلة؛ وإلا فتُستخدم الأوقات المحلية يوميًا.
+ الفحوص القادمة لسبعة أيام والفحوص غير المكتملة. لا يزال بالإمكان إكمال الفحوص الفائتة.
+
+ تذكيرات قوائم التحقق
+
+
+ تنطبق التذكيرات على عمليات التحقق التي تبدأ بعد تفعيلها. تحتوي الرسائل على رابط لتسجيل الدخول وتراعي تفضيلات الإشعارات لكل شخص. تجمع الملخصات التذكيرات المعلقة حسب المستلم.
+
+
+ تفعيل تذكيرات قوائم التحقق
+
+
+ الدقائق قبل الموعد النهائي (0 للتعطيل)
+
+
+ إشعار الموظفين المسؤولين بعمليات التحقق الفائتة
+
+
+ جمع التذكيرات المعلقة في ملخصات
+
+
+ إشعار مسؤولي القسم بعد هذا العدد من دقائق التأخير (اتركه فارغًا للتعطيل)
+
+
+ أدخل مدة للتذكير من 0 إلى 10080 دقيقة ومدة لإشعار المسؤولين من 1 إلى 43200 دقيقة، أو اترك مدة إشعار المسؤولين فارغة.
+
+
+ تذكيرات قوائم التحقق
+
+
+ تتوفر تحديثات لقوائم التحقق. سجّل الدخول لمراجعة عمليات التحقق الخاصة بك.
+
+
+ حفظ إعدادات التذكيرات
+
+ المعدات ذات الرقم التسلسلي غير متاحة.
+ مُسند إلى
+ إسناد تلقائي
+ لم يُسند إليك إجراء هذا الفحص.
+ شخص
+ دور وظيفي
+ مجموعة أو محطة
+ طاقم الوحدة
+ اختر إسنادًا متاحًا في هذه المؤسسة.
+ فحوص الجاهزية
+ اختر فترة تقويم لا تتجاوز 93 يومًا.
+ معدات ذات رقم تسلسلي
+ وقت الملخص اليومي (المنطقة الزمنية للمؤسسة؛ يحل محل بداية المناوبة)
+ إرسال ملخص عند بداية المناوبة
+ أدخل وقتًا واحدًا بتنسيق HH:mm.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx
index 7e7457a20..f41488a8e 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx
@@ -534,4 +534,107 @@
Prüfungen
-
\ No newline at end of file
+ Zeitpläne
+ Neuer Zeitplan
+ Noch keine Zeitpläne.
+ Zeitplan bearbeiten
+ Häufigkeit
+ Zeitzone
+ Pausiert
+ Hinweise zum Zeitplan
+ Startdatum
+ Enddatum (optional)
+ Ortszeiten
+ Tage für wöchentliche Prüfungen
+ Tag des Monats
+ Ausgangsmonat
+ Dienstschicht für Prüfungen je Schicht
+ Täglich Ortszeiten verwenden
+ Bearbeitungszeitraum (Minuten)
+ Zeitplan aktiviert
+ Zeitplan speichern
+ Fällige Prüfungen
+ Keine Prüfungen in dieser Liste.
+ Geplant für
+ Zeitraum endet
+ Aktionen
+ Prüfung öffnen
+ Prüfung entschuldigen
+ Grund für die entschuldigte Prüfung
+ Geplant
+ Versäumt
+ Entschuldigt
+ Abgebrochen
+ Je Schicht
+ Täglich
+ Wöchentlich
+ Monatlich
+ Vierteljährlich
+ Halbjährlich
+ Jährlich
+ Prüfen Sie Daten, Uhrzeiten, Ziel und Wiederholung des Zeitplans.
+ Sie dürfen keine Prüfzeitpläne verwalten.
+ Dieser Zeitplan ist nicht verfügbar.
+ Veröffentlichen Sie die Prüfliste, bevor Sie sie einplanen.
+ Diese Prüfung ist nicht verfügbar oder wurde bereits geändert.
+ Geben Sie einen Grund mit höchstens 4.000 Zeichen ein.
+ Prüfliste abgeschlossen
+ Prüfliste nicht bestanden
+ Prüfliste versäumt
+ Prüfzeitplan geändert
+ Geplante Prüfung entschuldigt
+ Beim Speichern wird die aktuell veröffentlichte Version festgelegt. Änderungen ersetzen künftige unbegonnene Prüfungen; Pausieren hebt unbegonnene Prüfungen auf.
+ Die Zeiten gelten in dieser Zeitzone. Nicht vorhandene Uhrzeiten werden auf die nächste gültige Minute verschoben; doppelte Uhrzeiten werden nur einmal berücksichtigt.
+ Geben Sie bis zu acht Uhrzeiten im 24-Stunden-Format ein, getrennt durch Kommas.
+ Für monatliche oder seltenere Prüfungen. In kurzen Monaten gilt der letzte Tag. Der Ausgangsmonat bestimmt vierteljährliche, halbjährliche und jährliche Prüfungen.
+ Nur für Prüfungen je Schicht. Eine gewählte Dienstschicht nutzt ihre erfassten Startdaten; sonst gelten täglich die Ortszeiten.
+ Anstehende Prüfungen für sieben Tage und offene Prüfungen. Versäumte Prüfungen können weiterhin abgeschlossen werden.
+
+ Erinnerungen an Checklisten
+
+
+ Erinnerungen gelten für Prüfungen, die nach dem Aktivieren beginnen. Nachrichten enthalten einen Anmeldelink und berücksichtigen die Benachrichtigungseinstellungen jeder Person. Ausstehende Erinnerungen werden je Empfänger zusammengefasst.
+
+
+ Erinnerungen an Checklisten aktivieren
+
+
+ Minuten vor der Fälligkeit (0 deaktiviert)
+
+
+ Zuständiges Personal über versäumte Prüfungen benachrichtigen
+
+
+ Ausstehende Erinnerungen zusammenfassen
+
+
+ Nach so vielen Minuten Überfälligkeit die Abteilungsadministratoren benachrichtigen (leer deaktiviert)
+
+
+ Geben Sie 0–10080 Minuten für Erinnerungen und 1–43200 Minuten für die Eskalation ein oder lassen Sie die Eskalation leer.
+
+
+ Erinnerungen an Checklisten
+
+
+ Aktualisierungen zu Checklisten sind verfügbar. Melden Sie sich an, um Ihre Prüfungen einzusehen.
+
+
+ Erinnerungseinstellungen speichern
+
+ Einzeln erfasste Ausrüstung ist nicht verfügbar.
+ Zugewiesen an
+ Automatische Zuordnung
+ Sie sind dieser Prüfung nicht zugewiesen.
+ Einzelperson
+ Personalrolle
+ Gruppe oder Wache
+ Fahrzeugbesatzung
+ Wählen Sie eine verfügbare Zuweisung in dieser Organisation.
+ Bereitschaftsprüfungen
+ Wählen Sie einen Kalenderzeitraum von höchstens 93 Tagen.
+ Einzeln erfasste Ausrüstung
+ Uhrzeit der täglichen Zusammenfassung (Zeitzone der Organisation; ersetzt Schichtbeginn)
+ Zusammenfassung bei Schichtbeginn senden
+ Geben Sie eine Uhrzeit im Format HH:mm ein.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx
index 253341648..10b84bdd5 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx
@@ -534,4 +534,107 @@
Έλεγχοι
-
\ No newline at end of file
+ Προγράμματα
+ Νέο πρόγραμμα
+ Δεν υπάρχουν ακόμη προγράμματα.
+ Επεξεργασία προγράμματος
+ Συχνότητα
+ Ζώνη ώρας
+ Σε παύση
+ Σημειώσεις προγράμματος
+ Ημερομηνία έναρξης
+ Ημερομηνία λήξης (προαιρετική)
+ Τοπικές ώρες
+ Ημέρες εβδομαδιαίων ελέγχων
+ Ημέρα του μήνα
+ Μήνας αναφοράς
+ Βάρδια για ελέγχους ανά βάρδια
+ Χρήση τοπικών ωρών κάθε ημέρα
+ Περιθώριο ολοκλήρωσης (λεπτά)
+ Ενεργό πρόγραμμα
+ Αποθήκευση προγράμματος
+ Εκκρεμείς έλεγχοι
+ Δεν υπάρχουν έλεγχοι σε αυτή τη λίστα.
+ Προγραμματισμένος για
+ Λήξη περιθωρίου
+ Ενέργειες
+ Άνοιγμα ελέγχου
+ Δικαιολόγηση παράλειψης
+ Αιτία δικαιολόγησης της παράλειψης
+ Προγραμματισμένος
+ Δεν εκτελέστηκε
+ Δικαιολογημένη παράλειψη
+ Ακυρωμένος
+ Ανά βάρδια
+ Καθημερινά
+ Εβδομαδιαία
+ Μηνιαία
+ Τριμηνιαία
+ Ανά εξάμηνο
+ Ετησίως
+ Ελέγξτε τις ημερομηνίες, τις ώρες, τον στόχο και την επανάληψη του προγράμματος.
+ Δεν έχετε δικαίωμα διαχείρισης προγραμμάτων ελέγχου.
+ Αυτό το πρόγραμμα δεν είναι διαθέσιμο.
+ Δημοσιεύστε τη λίστα ελέγχου πριν την προγραμματίσετε.
+ Αυτός ο έλεγχος δεν είναι διαθέσιμος ή έχει ήδη αλλάξει.
+ Εισαγάγετε αιτία έως 4.000 χαρακτήρες.
+ Η λίστα ελέγχου ολοκληρώθηκε
+ Η λίστα ελέγχου απέτυχε
+ Η λίστα ελέγχου δεν εκτελέστηκε
+ Το πρόγραμμα ελέγχου άλλαξε
+ Η παράλειψη του προγραμματισμένου ελέγχου δικαιολογήθηκε
+ Η αποθήκευση δεσμεύει την τρέχουσα δημοσιευμένη έκδοση. Οι αλλαγές αντικαθιστούν μελλοντικούς ελέγχους που δεν ξεκίνησαν· η παύση ακυρώνει ελέγχους που δεν ξεκίνησαν.
+ Οι ώρες ακολουθούν αυτή τη ζώνη. Ώρα που παραλείπεται μεταφέρεται εμπρός· επαναλαμβανόμενη ώρα εκτελείται μία φορά.
+ Εισαγάγετε έως οκτώ ώρες σε μορφή 24ώρου, χωρισμένες με κόμματα.
+ Για μηνιαίους ή αραιότερους ελέγχους. Στους σύντομους μήνες χρησιμοποιείται η τελευταία ημέρα. Ο μήνας αναφοράς καθορίζει τριμηνιαίους, εξαμηνιαίους και ετήσιους ελέγχους.
+ Μόνο για ελέγχους ανά βάρδια. Η επιλεγμένη βάρδια χρησιμοποιεί τις καταγεγραμμένες ημερομηνίες έναρξης· διαφορετικά ισχύουν καθημερινά οι τοπικές ώρες.
+ Προσεχείς έλεγχοι επτά ημερών και ανολοκλήρωτοι έλεγχοι. Οι εκπρόθεσμοι έλεγχοι μπορούν ακόμη να ολοκληρωθούν.
+
+ Υπενθυμίσεις λιστών ελέγχου
+
+
+ Οι υπενθυμίσεις αφορούν ελέγχους που ξεκινούν μετά την ενεργοποίησή τους. Τα μηνύματα περιέχουν σύνδεσμο σύνδεσης και σέβονται τις προτιμήσεις ειδοποιήσεων κάθε ατόμου. Οι συνόψεις ομαδοποιούν τις εκκρεμείς υπενθυμίσεις ανά παραλήπτη.
+
+
+ Ενεργοποίηση υπενθυμίσεων λιστών ελέγχου
+
+
+ Λεπτά πριν από την προθεσμία (0 για απενεργοποίηση)
+
+
+ Ειδοποίηση του υπεύθυνου προσωπικού για ελέγχους που παραλείφθηκαν
+
+
+ Ομαδοποίηση εκκρεμών υπενθυμίσεων σε συνόψεις
+
+
+ Ειδοποίηση των διαχειριστών του τμήματος μετά από τόσα λεπτά καθυστέρησης (κενό για απενεργοποίηση)
+
+
+ Ορίστε διάστημα υπενθύμισης 0–10080 λεπτών και διάστημα ειδοποίησης διαχειριστών 1–43200 λεπτών ή αφήστε το δεύτερο κενό.
+
+
+ Υπενθυμίσεις λιστών ελέγχου
+
+
+ Υπάρχουν ενημερώσεις για τις λίστες ελέγχου. Συνδεθείτε για να δείτε τους ελέγχους σας.
+
+
+ Αποθήκευση ρυθμίσεων υπενθυμίσεων
+
+ Ο εξοπλισμός με σειριακό αριθμό δεν είναι διαθέσιμος.
+ Ανάθεση σε
+ Αυτόματη ανάθεση
+ Δεν σας έχει ανατεθεί η εκτέλεση αυτού του ελέγχου.
+ Άτομο
+ Ρόλος προσωπικού
+ Ομάδα ή σταθμός
+ Πλήρωμα μονάδας
+ Επιλέξτε μια διαθέσιμη ανάθεση σε αυτόν τον οργανισμό.
+ Έλεγχοι ετοιμότητας
+ Επιλέξτε περίοδο ημερολογίου έως 93 ημέρες.
+ Εξοπλισμός με σειριακό αριθμό
+ Ώρα ημερήσιας σύνοψης (ζώνη ώρας οργανισμού· υπερισχύει της έναρξης βάρδιας)
+ Αποστολή σύνοψης στην έναρξη βάρδιας
+ Εισαγάγετε μία ώρα σε μορφή HH:mm.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx
index d32c76c91..40081c2aa 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx
@@ -534,4 +534,107 @@
Checks
-
\ No newline at end of file
+ Schedules
+ New schedule
+ No schedules yet.
+ Edit schedule
+ Frequency
+ Time zone
+ Paused
+ Schedule notes
+ Start date
+ End date (optional)
+ Local times
+ Days for weekly checks
+ Day of month
+ Starting month
+ Workshift for checks each shift
+ Use local times each day
+ Completion window (minutes)
+ Schedule enabled
+ Save schedule
+ Due checks
+ No checks in this list.
+ Scheduled for
+ Window ends
+ Actions
+ Open check
+ Excuse check
+ Reason for excusing this check
+ Scheduled
+ Missed
+ Excused
+ Cancelled
+ Each shift
+ Daily
+ Weekly
+ Monthly
+ Quarterly
+ Every six months
+ Annually
+ Check the schedule dates, times, target and recurrence settings.
+ You cannot manage checklist schedules.
+ This schedule is unavailable.
+ Publish the checklist before scheduling it.
+ This check is unavailable or has already changed.
+ Enter a reason of up to 4,000 characters.
+ Checklist completed
+ Checklist failed
+ Checklist missed
+ Checklist schedule changed
+ Checklist occurrence excused
+ Saving pins the current published version. Changes replace future unstarted checks; pausing cancels unstarted checks.
+ Times use this zone. A skipped clock time moves forward; a repeated time occurs once.
+ Enter up to eight times, separated by commas, in 24-hour format.
+ For monthly or less frequent checks. Short months use their last day. The starting month anchors quarterly, six-monthly and annual checks.
+ Only for each-shift checks. A selected workshift uses its recorded start dates; otherwise local times apply daily.
+ Upcoming checks for seven days and unfinished checks. Missed checks can still be completed.
+
+ Checklist reminders
+
+
+ Reminders apply to checks starting after reminders are enabled. Messages contain a sign-in link and respect each person's notification preferences. Digests combine pending reminders for each recipient.
+
+
+ Enable checklist reminders
+
+
+ Minutes before the due time (0 disables)
+
+
+ Notify responsible personnel of missed checks
+
+
+ Combine pending reminders into digests
+
+
+ Escalate to department administrators after this many minutes overdue (blank disables)
+
+
+ Enter a reminder interval of 0–10080 minutes and an escalation interval of 1–43200 minutes, or leave escalation blank.
+
+
+ Checklist reminders
+
+
+ Checklist updates are available. Sign in to review your checks.
+
+
+ Save reminder settings
+
+ Serialized equipment is unavailable.
+ Assigned to
+ Automatic routing
+ You are not assigned to perform this check.
+ Person
+ Personnel role
+ Group or station
+ Unit crew
+ Choose an available assignment in this department.
+ Readiness checks
+ Choose a calendar range of up to 93 days.
+ Serialized equipment
+ Daily digest time (department time zone; overrides shift starts)
+ Send a digest at shift start
+ Enter one time in HH:mm format.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx
index 7b571db88..efbd7a342 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx
@@ -534,4 +534,107 @@
Verificaciones
-
\ No newline at end of file
+ Programaciones
+ Nueva programación
+ Aún no hay programaciones.
+ Editar programación
+ Frecuencia
+ Zona horaria
+ En pausa
+ Notas de la programación
+ Fecha de inicio
+ Fecha de fin (opcional)
+ Horas locales
+ Días de las revisiones semanales
+ Día del mes
+ Mes de referencia
+ Turno para revisiones por turno
+ Usar horas locales cada día
+ Plazo de realización (minutos)
+ Programación habilitada
+ Guardar programación
+ Revisiones pendientes
+ No hay revisiones en esta lista.
+ Programada para
+ Fin del plazo
+ Acciones
+ Abrir revisión
+ Justificar omisión
+ Motivo de la omisión justificada
+ Programada
+ No realizada
+ Omisión justificada
+ Cancelada
+ Cada turno
+ Diariamente
+ Semanalmente
+ Mensualmente
+ Trimestralmente
+ Cada seis meses
+ Anualmente
+ Revise las fechas, horas, objetivo y repetición de la programación.
+ No tiene permiso para gestionar programaciones de revisión.
+ Esta programación no está disponible.
+ Publique la lista de comprobación antes de programarla.
+ Esta revisión no está disponible o ya ha cambiado.
+ Introduzca un motivo de hasta 4.000 caracteres.
+ Lista de comprobación completada
+ Lista de comprobación no superada
+ Lista de comprobación no realizada
+ Programación de revisión modificada
+ Omisión de revisión programada justificada
+ Al guardar se fija la versión publicada actual. Los cambios sustituyen revisiones futuras no iniciadas; la pausa cancela las no iniciadas.
+ Las horas usan esta zona. Una hora inexistente se adelanta; una hora repetida se ejecuta una sola vez.
+ Introduzca hasta ocho horas en formato de 24 horas, separadas por comas.
+ Para revisiones mensuales o menos frecuentes. Los meses cortos usan su último día. El mes de referencia determina las revisiones trimestrales, semestrales y anuales.
+ Solo para revisiones por turno. Un turno seleccionado usa sus fechas de inicio registradas; de lo contrario, se usan las horas locales cada día.
+ Próximas revisiones de siete días y revisiones sin terminar. Las revisiones no realizadas aún se pueden completar.
+
+ Recordatorios de listas de verificación
+
+
+ Los recordatorios se aplican a las verificaciones que comiencen después de activarlos. Los mensajes incluyen un enlace para iniciar sesión y respetan las preferencias de notificación de cada persona. Los resúmenes agrupan los recordatorios pendientes por destinatario.
+
+
+ Activar recordatorios de listas de verificación
+
+
+ Minutos antes del vencimiento (0 desactiva)
+
+
+ Avisar al personal responsable de las verificaciones omitidas
+
+
+ Agrupar los recordatorios pendientes en resúmenes
+
+
+ Avisar a los administradores del departamento tras estos minutos de retraso (vacío desactiva)
+
+
+ Indique un intervalo de 0–10080 minutos para los recordatorios y de 1–43200 minutos para el aviso a administradores, o deje este último vacío.
+
+
+ Recordatorios de listas de verificación
+
+
+ Hay actualizaciones de listas de verificación. Inicie sesión para revisar sus verificaciones.
+
+
+ Guardar configuración de recordatorios
+
+ El equipo con número de serie no está disponible.
+ Asignado a
+ Asignación automática
+ No tiene asignada la realización de esta comprobación.
+ Persona
+ Función del personal
+ Grupo o estación
+ Dotación de la unidad
+ Seleccione una asignación disponible en esta organización.
+ Comprobaciones de disponibilidad
+ Seleccione un intervalo de calendario de hasta 93 días.
+ Equipo con número de serie
+ Hora del resumen diario (zona horaria de la organización; sustituye al inicio del turno)
+ Enviar un resumen al inicio del turno
+ Introduzca una sola hora con el formato HH:mm.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx
index db6008857..9da7c7eb4 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx
@@ -534,4 +534,107 @@
Contrôles
-
\ No newline at end of file
+ Planifications
+ Nouvelle planification
+ Aucune planification pour le moment.
+ Modifier la planification
+ Fréquence
+ Fuseau horaire
+ En pause
+ Notes de planification
+ Date de début
+ Date de fin (facultative)
+ Heures locales
+ Jours des contrôles hebdomadaires
+ Jour du mois
+ Mois de référence
+ Vacation pour les contrôles par vacation
+ Utiliser les heures locales chaque jour
+ Délai de réalisation (minutes)
+ Planification activée
+ Enregistrer la planification
+ Contrôles à effectuer
+ Aucun contrôle dans cette liste.
+ Prévu pour
+ Fin du délai
+ Opérations
+ Ouvrir le contrôle
+ Justifier la dispense
+ Motif de dispense du contrôle
+ Planifié
+ Non effectué
+ Dispensé
+ Annulé
+ À chaque vacation
+ Chaque jour
+ Chaque semaine
+ Chaque mois
+ Chaque trimestre
+ Tous les six mois
+ Chaque année
+ Vérifiez les dates, heures, cible et récurrence de la planification.
+ Vous ne pouvez pas gérer les planifications de contrôle.
+ Cette planification est indisponible.
+ Publiez la liste de contrôle avant de la planifier.
+ Ce contrôle est indisponible ou a déjà été modifié.
+ Saisissez un motif de 4 000 caractères au maximum.
+ Liste de contrôle terminée
+ Échec de la liste de contrôle
+ Liste de contrôle non effectuée
+ Planification de contrôle modifiée
+ Dispense du contrôle planifié justifiée
+ L’enregistrement fixe la version publiée actuelle. Les modifications remplacent les futurs contrôles non commencés ; la pause annule les contrôles non commencés.
+ Les heures suivent ce fuseau. Une heure inexistante est décalée vers l’avant ; une heure répétée ne se produit qu’une fois.
+ Saisissez jusqu’à huit heures au format 24 heures, séparées par des virgules.
+ Pour les contrôles mensuels ou moins fréquents. Les mois courts utilisent leur dernier jour. Le mois de référence détermine les contrôles trimestriels, semestriels et annuels.
+ Uniquement pour les contrôles par vacation. Une vacation sélectionnée utilise ses dates de début enregistrées ; sinon, les heures locales s’appliquent chaque jour.
+ Contrôles prévus sur sept jours et contrôles inachevés. Les contrôles manqués peuvent encore être réalisés.
+
+ Rappels des listes de contrôle
+
+
+ Les rappels concernent les contrôles qui commencent après leur activation. Les messages contiennent un lien de connexion et respectent les préférences de notification de chaque personne. Les récapitulatifs regroupent les rappels en attente par destinataire.
+
+
+ Activer les rappels des listes de contrôle
+
+
+ Minutes avant l’échéance (0 désactive)
+
+
+ Avertir le personnel responsable des contrôles manqués
+
+
+ Regrouper les rappels en attente dans des récapitulatifs
+
+
+ Avertir les administrateurs du service après ce nombre de minutes de retard (vide désactive)
+
+
+ Saisissez un délai de rappel de 0 à 10080 minutes et un délai d’alerte des administrateurs de 1 à 43200 minutes, ou laissez ce dernier vide.
+
+
+ Rappels des listes de contrôle
+
+
+ Des mises à jour des listes de contrôle sont disponibles. Connectez-vous pour consulter vos contrôles.
+
+
+ Enregistrer les paramètres de rappel
+
+ Le matériel identifié par numéro de série est indisponible.
+ Attribué à
+ Attribution automatique
+ Vous ne faites pas partie des personnes affectées à ce contrôle.
+ Personne
+ Fonction du personnel
+ Groupe ou centre
+ Équipage de l’unité
+ Choisissez une affectation disponible dans cette organisation.
+ Contrôles de disponibilité
+ Choisissez une période de calendrier de 93 jours au maximum.
+ Matériel identifié par numéro de série
+ Heure du récapitulatif quotidien (fuseau de l’organisation ; remplace le début de garde)
+ Envoyer un récapitulatif au début de garde
+ Saisissez une seule heure au format HH:mm.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx
index 84c2dc289..b8daf3045 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx
@@ -534,4 +534,107 @@
Controlli
-
\ No newline at end of file
+ Pianificazioni
+ Nuova pianificazione
+ Non ci sono ancora pianificazioni.
+ Modifica pianificazione
+ Frequenza
+ Fuso orario
+ In pausa
+ Note della pianificazione
+ Data di inizio
+ Data di fine (facoltativa)
+ Orari locali
+ Giorni dei controlli settimanali
+ Giorno del mese
+ Mese di riferimento
+ Turno per i controlli a ogni turno
+ Usa gli orari locali ogni giorno
+ Tempo di completamento (minuti)
+ Pianificazione abilitata
+ Salva pianificazione
+ Controlli da eseguire
+ Nessun controllo in questo elenco.
+ Previsto per
+ Scadenza del termine
+ Azioni
+ Apri controllo
+ Giustifica omissione
+ Motivo dell'omissione giustificata
+ Pianificato
+ Non eseguito
+ Omissione giustificata
+ Annullato
+ A ogni turno
+ Ogni giorno
+ Ogni settimana
+ Ogni mese
+ Ogni trimestre
+ Ogni sei mesi
+ Ogni anno
+ Verifica date, orari, destinatario e ricorrenza della pianificazione.
+ Non puoi gestire le pianificazioni dei controlli.
+ Questa pianificazione non è disponibile.
+ Pubblica la lista di controllo prima di pianificarla.
+ Questo controllo non è disponibile o è già stato modificato.
+ Inserisci un motivo di massimo 4.000 caratteri.
+ Lista di controllo completata
+ Lista di controllo non superata
+ Lista di controllo non eseguita
+ Pianificazione del controllo modificata
+ Omissione del controllo pianificato giustificata
+ Il salvataggio fissa la versione pubblicata corrente. Le modifiche sostituiscono i controlli futuri non iniziati; la pausa annulla quelli non iniziati.
+ Gli orari seguono questo fuso. Un orario inesistente viene spostato in avanti; un orario ripetuto si verifica una sola volta.
+ Inserisci fino a otto orari nel formato a 24 ore, separati da virgole.
+ Per controlli mensili o meno frequenti. Nei mesi corti si usa l’ultimo giorno. Il mese di riferimento determina i controlli trimestrali, semestrali e annuali.
+ Solo per controlli a ogni turno. Un turno selezionato usa le date di inizio registrate; altrimenti si usano gli orari locali ogni giorno.
+ Controlli previsti per sette giorni e controlli incompleti. I controlli mancati possono ancora essere completati.
+
+ Promemoria delle liste di controllo
+
+
+ I promemoria si applicano ai controlli che iniziano dopo l’attivazione. I messaggi contengono un collegamento per accedere e rispettano le preferenze di notifica di ciascuna persona. I riepiloghi raggruppano i promemoria in attesa per destinatario.
+
+
+ Attiva i promemoria delle liste di controllo
+
+
+ Minuti prima della scadenza (0 disattiva)
+
+
+ Avvisa il personale responsabile dei controlli mancati
+
+
+ Raggruppa i promemoria in attesa nei riepiloghi
+
+
+ Avvisa gli amministratori del reparto dopo questi minuti di ritardo (vuoto disattiva)
+
+
+ Inserisci un intervallo di 0–10080 minuti per i promemoria e di 1–43200 minuti per l’avviso agli amministratori, oppure lascia vuoto quest’ultimo.
+
+
+ Promemoria delle liste di controllo
+
+
+ Sono disponibili aggiornamenti delle liste di controllo. Accedi per consultare i tuoi controlli.
+
+
+ Salva le impostazioni dei promemoria
+
+ L’attrezzatura con numero di serie non è disponibile.
+ Assegnato a
+ Assegnazione automatica
+ Non sei assegnato all’esecuzione di questo controllo.
+ Persona
+ Ruolo del personale
+ Gruppo o sede
+ Equipaggio dell’unità
+ Scegli un’assegnazione disponibile in questa organizzazione.
+ Controlli di prontezza operativa
+ Scegli un intervallo di calendario non superiore a 93 giorni.
+ Attrezzatura con numero di serie
+ Ora del riepilogo giornaliero (fuso orario dell’organizzazione; sostituisce l’inizio turno)
+ Invia un riepilogo all’inizio del turno
+ Inserisci un solo orario nel formato HH:mm.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx
index 98ebc38f2..498480efc 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx
@@ -534,4 +534,107 @@
Kontrole
-
\ No newline at end of file
+ Harmonogramy
+ Nowy harmonogram
+ Nie ma jeszcze harmonogramów.
+ Edytuj harmonogram
+ Częstotliwość
+ Strefa czasowa
+ Wstrzymany
+ Uwagi do harmonogramu
+ Data rozpoczęcia
+ Data zakończenia (opcjonalna)
+ Godziny lokalne
+ Dni kontroli cotygodniowych
+ Dzień miesiąca
+ Miesiąc odniesienia
+ Zmiana dla kontroli na każdej zmianie
+ Używaj godzin lokalnych codziennie
+ Czas na wykonanie (minuty)
+ Harmonogram włączony
+ Zapisz harmonogram
+ Kontrole do wykonania
+ Brak kontroli na tej liście.
+ Zaplanowano na
+ Koniec terminu
+ Działania
+ Otwórz kontrolę
+ Usprawiedliw pominięcie
+ Powód usprawiedliwionego pominięcia
+ Zaplanowana
+ Niewykonana
+ Pominięcie usprawiedliwione
+ Anulowana
+ Na każdej zmianie
+ Codziennie
+ Co tydzień
+ Co miesiąc
+ Co kwartał
+ Co sześć miesięcy
+ Co rok
+ Sprawdź daty, godziny, obiekt i powtarzalność harmonogramu.
+ Nie możesz zarządzać harmonogramami kontroli.
+ Ten harmonogram jest niedostępny.
+ Opublikuj listę kontrolną przed jej zaplanowaniem.
+ Ta kontrola jest niedostępna lub została już zmieniona.
+ Podaj powód zawierający do 4000 znaków.
+ Lista kontrolna ukończona
+ Lista kontrolna niezaliczona
+ Lista kontrolna niewykonana
+ Harmonogram kontroli zmieniony
+ Pominięcie zaplanowanej kontroli usprawiedliwione
+ Zapis przypina aktualną opublikowaną wersję. Zmiany zastępują przyszłe nierozpoczęte kontrole; wstrzymanie anuluje nierozpoczęte kontrole.
+ Godziny dotyczą tej strefy. Pominięta godzina jest przesuwana do przodu; powtórzona godzina występuje tylko raz.
+ Podaj do ośmiu godzin w formacie 24-godzinnym, oddzielonych przecinkami.
+ Dla kontroli miesięcznych lub rzadszych. W krótszych miesiącach używany jest ostatni dzień. Miesiąc odniesienia wyznacza kontrole kwartalne, półroczne i roczne.
+ Tylko dla kontroli na każdej zmianie. Wybrana zmiana używa zapisanych dat rozpoczęcia; w przeciwnym razie godziny lokalne obowiązują codziennie.
+ Nadchodzące kontrole na siedem dni i kontrole nieukończone. Zaległe kontrole można nadal wykonać.
+
+ Przypomnienia o listach kontrolnych
+
+
+ Przypomnienia dotyczą kontroli rozpoczynających się po ich włączeniu. Wiadomości zawierają odnośnik do logowania i uwzględniają preferencje powiadomień każdej osoby. Podsumowania łączą oczekujące przypomnienia według odbiorcy.
+
+
+ Włącz przypomnienia o listach kontrolnych
+
+
+ Minuty przed terminem (0 wyłącza)
+
+
+ Powiadamiaj odpowiedzialny personel o pominiętych kontrolach
+
+
+ Łącz oczekujące przypomnienia w podsumowania
+
+
+ Powiadom administratorów działu po tylu minutach opóźnienia (puste pole wyłącza)
+
+
+ Podaj czas przypomnienia od 0 do 10080 minut oraz czas powiadomienia administratorów od 1 do 43200 minut lub pozostaw ten drugi pusty.
+
+
+ Przypomnienia o listach kontrolnych
+
+
+ Dostępne są aktualizacje list kontrolnych. Zaloguj się, aby przejrzeć swoje kontrole.
+
+
+ Zapisz ustawienia przypomnień
+
+ Sprzęt z numerem seryjnym jest niedostępny.
+ Przypisano do
+ Automatyczne przypisanie
+ Nie przypisano Ci wykonania tej kontroli.
+ Osoba
+ Rola personelu
+ Grupa lub stacja
+ Załoga jednostki
+ Wybierz dostępne przypisanie w tej organizacji.
+ Kontrole gotowości
+ Wybierz zakres kalendarza nieprzekraczający 93 dni.
+ Sprzęt z numerem seryjnym
+ Godzina codziennego podsumowania (strefa czasowa organizacji; zastępuje początek zmiany)
+ Wyślij podsumowanie na początku zmiany
+ Wprowadź jedną godzinę w formacie HH:mm.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx
index d32c76c91..40081c2aa 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx
@@ -534,4 +534,107 @@
Checks
-
\ No newline at end of file
+ Schedules
+ New schedule
+ No schedules yet.
+ Edit schedule
+ Frequency
+ Time zone
+ Paused
+ Schedule notes
+ Start date
+ End date (optional)
+ Local times
+ Days for weekly checks
+ Day of month
+ Starting month
+ Workshift for checks each shift
+ Use local times each day
+ Completion window (minutes)
+ Schedule enabled
+ Save schedule
+ Due checks
+ No checks in this list.
+ Scheduled for
+ Window ends
+ Actions
+ Open check
+ Excuse check
+ Reason for excusing this check
+ Scheduled
+ Missed
+ Excused
+ Cancelled
+ Each shift
+ Daily
+ Weekly
+ Monthly
+ Quarterly
+ Every six months
+ Annually
+ Check the schedule dates, times, target and recurrence settings.
+ You cannot manage checklist schedules.
+ This schedule is unavailable.
+ Publish the checklist before scheduling it.
+ This check is unavailable or has already changed.
+ Enter a reason of up to 4,000 characters.
+ Checklist completed
+ Checklist failed
+ Checklist missed
+ Checklist schedule changed
+ Checklist occurrence excused
+ Saving pins the current published version. Changes replace future unstarted checks; pausing cancels unstarted checks.
+ Times use this zone. A skipped clock time moves forward; a repeated time occurs once.
+ Enter up to eight times, separated by commas, in 24-hour format.
+ For monthly or less frequent checks. Short months use their last day. The starting month anchors quarterly, six-monthly and annual checks.
+ Only for each-shift checks. A selected workshift uses its recorded start dates; otherwise local times apply daily.
+ Upcoming checks for seven days and unfinished checks. Missed checks can still be completed.
+
+ Checklist reminders
+
+
+ Reminders apply to checks starting after reminders are enabled. Messages contain a sign-in link and respect each person's notification preferences. Digests combine pending reminders for each recipient.
+
+
+ Enable checklist reminders
+
+
+ Minutes before the due time (0 disables)
+
+
+ Notify responsible personnel of missed checks
+
+
+ Combine pending reminders into digests
+
+
+ Escalate to department administrators after this many minutes overdue (blank disables)
+
+
+ Enter a reminder interval of 0–10080 minutes and an escalation interval of 1–43200 minutes, or leave escalation blank.
+
+
+ Checklist reminders
+
+
+ Checklist updates are available. Sign in to review your checks.
+
+
+ Save reminder settings
+
+ Serialized equipment is unavailable.
+ Assigned to
+ Automatic routing
+ You are not assigned to perform this check.
+ Person
+ Personnel role
+ Group or station
+ Unit crew
+ Choose an available assignment in this department.
+ Readiness checks
+ Choose a calendar range of up to 93 days.
+ Serialized equipment
+ Daily digest time (department time zone; overrides shift starts)
+ Send a digest at shift start
+ Enter one time in HH:mm format.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx
index b49a5ac5e..32ce38b5f 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx
@@ -534,4 +534,107 @@
Kontroller
+ Scheman
+ Nytt schema
+ Det finns inga scheman ännu.
+ Redigera schema
+ Frekvens
+ Tidszon
+ Pausat
+ Schemaanteckningar
+ Startdatum
+ Slutdatum (valfritt)
+ Lokala tider
+ Dagar för veckokontroller
+ Dag i månaden
+ Referensmånad
+ Arbetspass för kontroller vid varje pass
+ Använd lokala tider varje dag
+ Tidsfrist för genomförande (minuter)
+ Schema aktiverat
+ Spara schema
+ Kontroller att utföra
+ Inga kontroller i den här listan.
+ Planerad till
+ Tidsfristen löper ut
+ Åtgärder
+ Öppna kontroll
+ Godkänn undantag
+ Orsak till undantaget
+ Planerad
+ Missad
+ Undantag godkänt
+ Avbruten
+ Varje arbetspass
+ Dagligen
+ Varje vecka
+ Varje månad
+ Varje kvartal
+ Var sjätte månad
+ Årligen
+ Kontrollera schemats datum, tider, mål och upprepning.
+ Du får inte hantera kontrollscheman.
+ Det här schemat är inte tillgängligt.
+ Publicera checklistan innan du schemalägger den.
+ Den här kontrollen är inte tillgänglig eller har redan ändrats.
+ Ange en orsak med högst 4 000 tecken.
+ Checklista slutförd
+ Checklista underkänd
+ Checklista missad
+ Kontrollschema ändrat
+ Undantag från planerad kontroll godkänt
+ När du sparar låses den aktuella publicerade versionen. Ändringar ersätter framtida ej påbörjade kontroller; paus avbryter ej påbörjade kontroller.
+ Tiderna följer den här tidszonen. En överhoppad klocktid flyttas framåt; en upprepad tid inträffar bara en gång.
+ Ange upp till åtta tider i 24-timmarsformat, åtskilda med kommatecken.
+ För månatliga eller mindre frekventa kontroller. Korta månader använder sin sista dag. Referensmånaden styr kvartalsvisa, halvårsvisa och årliga kontroller.
+ Endast för kontroller vid varje arbetspass. Ett valt arbetspass använder sina registrerade startdatum; annars gäller lokala tider varje dag.
+ Kommande kontroller för sju dagar och oavslutade kontroller. Missade kontroller kan fortfarande slutföras.
+
+ Påminnelser om checklistor
+
+
+ Påminnelser gäller kontroller som börjar efter att påminnelserna aktiverats. Meddelanden innehåller en inloggningslänk och följer varje persons aviseringsinställningar. Sammanställningar samlar väntande påminnelser per mottagare.
+
+
+ Aktivera påminnelser om checklistor
+
+
+ Minuter före förfallotiden (0 inaktiverar)
+
+
+ Meddela ansvarig personal om missade kontroller
+
+
+ Samla väntande påminnelser i sammanställningar
+
+
+ Meddela avdelningsadministratörer efter så här många minuters försening (tomt inaktiverar)
+
+
+ Ange ett påminnelseintervall på 0–10080 minuter och ett intervall för avisering till administratörer på 1–43200 minuter, eller lämna det senare tomt.
+
+
+ Påminnelser om checklistor
+
+
+ Det finns uppdateringar av checklistor. Logga in för att granska dina kontroller.
+
+
+ Spara påminnelseinställningar
+
+ Utrustning med serienummer är inte tillgänglig.
+ Tilldelad till
+ Automatisk tilldelning
+ Du har inte tilldelats att utföra den här kontrollen.
+ Enskild person
+ Personalroll
+ Grupp eller station
+ Enhetens besättning
+ Välj en tillgänglig tilldelning i den här organisationen.
+ Beredskapskontroller
+ Välj ett kalenderintervall på högst 93 dagar.
+ Utrustning med serienummer
+ Tid för daglig sammanställning (organisationens tidszon; ersätter skiftstart)
+ Skicka en sammanställning vid skiftstart
+ Ange en enda tid i formatet HH:mm.
diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx
index fe0348737..ba898be5e 100644
--- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx
@@ -534,4 +534,107 @@
Перевірки
+ Розклади
+ Новий розклад
+ Розкладів ще немає.
+ Редагувати розклад
+ Періодичність
+ Часовий пояс
+ Призупинено
+ Примітки до розкладу
+ Дата початку
+ Дата завершення (необов’язково)
+ Місцевий час
+ Дні щотижневих перевірок
+ День місяця
+ Опорний місяць
+ Зміна для перевірок кожної зміни
+ Використовувати місцевий час щодня
+ Термін виконання (хвилини)
+ Розклад увімкнено
+ Зберегти розклад
+ Перевірки до виконання
+ У цьому списку немає перевірок.
+ Заплановано на
+ Кінець терміну
+ Дії
+ Відкрити перевірку
+ Обґрунтувати пропуск
+ Причина обґрунтованого пропуску
+ Заплановано
+ Не виконано
+ Пропуск обґрунтовано
+ Скасовано
+ Кожної зміни
+ Щодня
+ Щотижня
+ Щомісяця
+ Щокварталу
+ Кожні шість місяців
+ Щороку
+ Перевірте дати, час, об’єкт і періодичність розкладу.
+ Ви не можете керувати розкладами перевірок.
+ Цей розклад недоступний.
+ Опублікуйте контрольний список перед плануванням.
+ Ця перевірка недоступна або вже змінена.
+ Укажіть причину довжиною до 4000 символів.
+ Контрольний список завершено
+ Контрольний список не пройдено
+ Контрольний список не виконано
+ Розклад перевірок змінено
+ Пропуск запланованої перевірки обґрунтовано
+ Збереження закріплює поточну опубліковану версію. Зміни замінюють майбутні нерозпочаті перевірки; призупинення скасовує нерозпочаті перевірки.
+ Час відповідає цьому поясу. Пропущений час переноситься вперед; повторений час виконується лише один раз.
+ Введіть до восьми значень часу у 24-годинному форматі через кому.
+ Для щомісячних або рідших перевірок. У коротких місяцях використовується останній день. Опорний місяць визначає щоквартальні, піврічні та щорічні перевірки.
+ Лише для перевірок кожної зміни. Вибрана зміна використовує записані дати початку; інакше місцевий час застосовується щодня.
+ Майбутні перевірки на сім днів і незавершені перевірки. Прострочені перевірки все ще можна виконати.
+
+ Нагадування про контрольні списки
+
+
+ Нагадування стосуються перевірок, що починаються після їх увімкнення. Повідомлення містять посилання для входу та враховують налаштування сповіщень кожної людини. Зведення об’єднують нагадування, що очікують на надсилання, за одержувачем.
+
+
+ Увімкнути нагадування про контрольні списки
+
+
+ Хвилини до кінцевого терміну (0 вимикає)
+
+
+ Сповіщати відповідальний персонал про пропущені перевірки
+
+
+ Об’єднувати нагадування у зведення
+
+
+ Сповістити адміністраторів підрозділу після такої кількості хвилин прострочення (порожнє поле вимикає)
+
+
+ Вкажіть інтервал нагадування від 0 до 10080 хвилин та інтервал сповіщення адміністраторів від 1 до 43200 хвилин або залиште другий порожнім.
+
+
+ Нагадування про контрольні списки
+
+
+ Доступні оновлення контрольних списків. Увійдіть, щоб переглянути свої перевірки.
+
+
+ Зберегти налаштування нагадувань
+
+ Обладнання із серійним номером недоступне.
+ Призначено для
+ Автоматичне призначення
+ Вас не призначено виконувати цю перевірку.
+ Особа
+ Роль персоналу
+ Група або станція
+ Екіпаж підрозділу
+ Виберіть доступне призначення в цій організації.
+ Перевірки готовності
+ Виберіть період календаря тривалістю до 93 днів.
+ Обладнання із серійним номером
+ Час щоденного зведення (часовий пояс організації; замінює початок зміни)
+ Надсилати зведення на початку зміни
+ Введіть один час у форматі HH:mm.
diff --git a/Core/Resgrid.Model/AdpTableBinding.cs b/Core/Resgrid.Model/AdpTableBinding.cs
index 4d929be8b..894a19d5d 100644
--- a/Core/Resgrid.Model/AdpTableBinding.cs
+++ b/Core/Resgrid.Model/AdpTableBinding.cs
@@ -72,17 +72,22 @@ public static AdpTableBinding ViaParent(string tableName, string pkColumn, bool
/// null sweeps every row of the table.
///
public string RowFilterColumn { get; init; }
+ /// Fixed, reviewed discriminator for shared-table families; never supplied by a request.
+ public AdpRowDiscriminator Discriminator { get; init; }
}
+ public sealed record AdpRowDiscriminator(string Column, IReadOnlyList Integers = null, string Text = null, bool OnParent = false);
+
/// One cataloged column inside a binding.
public sealed class AdpColumnSpec
{
- public AdpColumnSpec(string columnName, string fieldId, ProtectedFieldStorageKind storageKind, string companionColumn = null)
+ public AdpColumnSpec(string columnName, string fieldId, ProtectedFieldStorageKind storageKind, string companionColumn = null, bool companionIsBoolean = false)
{
ColumnName = columnName;
FieldId = fieldId;
StorageKind = storageKind;
CompanionColumn = companionColumn;
+ CompanionIsBoolean = companionIsBoolean;
if (storageKind == ProtectedFieldStorageKind.CompanionColumn && string.IsNullOrWhiteSpace(companionColumn))
throw new ArgumentException("Companion storage requires a companion column name.", nameof(companionColumn));
@@ -97,5 +102,7 @@ public AdpColumnSpec(string columnName, string fieldId, ProtectedFieldStorageKin
/// Envelope column for CompanionColumn storage (e.g. "ProtectedLatitudeEnvelope").
public string CompanionColumn { get; }
+ /// Restore a boolean, rather than a decimal, when offboarding this typed companion.
+ public bool CompanionIsBoolean { get; }
}
}
diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs
index 31dbf409e..4af7e7c7f 100644
--- a/Core/Resgrid.Model/AuditLogTypes.cs
+++ b/Core/Resgrid.Model/AuditLogTypes.cs
@@ -214,6 +214,11 @@ public enum AuditLogTypes
ChecklistCompletionSubmitted,
ChecklistWitnessAttested,
ChecklistFileAdded,
- ChecklistFileRemoved
+ ChecklistFileRemoved,
+ ChecklistScheduleAdded,
+ ChecklistScheduleUpdated,
+ ChecklistOccurrenceMissed,
+ ChecklistOccurrenceSkipped,
+ ChecklistReminderSettingsUpdated
}
}
diff --git a/Core/Resgrid.Model/Checklists/ChecklistAssignments.cs b/Core/Resgrid.Model/Checklists/ChecklistAssignments.cs
new file mode 100644
index 000000000..6cf2ebca3
--- /dev/null
+++ b/Core/Resgrid.Model/Checklists/ChecklistAssignments.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Checklists
+{
+ public enum ChecklistAssignmentType { Automatic = 0, User = 1, Role = 2, Group = 3, Unit = 4 }
+ public sealed class ChecklistAssignmentChoice { public int Type { get; set; } public string Id { get; set; } public string Name { get; set; } }
+ public sealed class ChecklistAssetTarget
+ {
+ public int DepartmentId { get; set; }
+ public string Id { get; set; }
+ public string Name { get; set; }
+ public int? UnitId { get; set; }
+ public int? GroupId { get; set; }
+ public string UserId { get; set; }
+ }
+ /// Inventory integration boundary. Attended names honor the actor/grant; routing returns identifiers only.
+ /// Inventory's serialized-asset module replaces the unavailable implementation when installed.
+ public interface IChecklistAssetSource
+ {
+ Task IsAvailableAsync(int departmentId);
+ Task> ListAsync(ChecklistActor actor);
+ Task GetAsync(ChecklistActor actor, string id);
+ Task RoutingAsync(int departmentId, string id);
+ /// Grant-free authorization for a generic notice; must not reveal asset names or content.
+ Task CanReceiveReminderAsync(int departmentId, string userId, string id);
+ }
+ public interface IChecklistAssignmentService
+ {
+ Task ValidateAsync(int departmentId, int type, string id);
+ Task> ChoicesAsync(ChecklistActor actor);
+ Task> MembersAsync(int departmentId, int type, string id);
+ Task CanPerformAsync(ChecklistActor actor, ChecklistSchedule schedule);
+ }
+ public sealed class ChecklistCalendarEntry
+ {
+ public string Id { get; set; }
+ public string OccurrenceId { get; set; }
+ public string Title { get; set; }
+ public DateTime StartUtc { get; set; }
+ public DateTime EndUtc { get; set; }
+ public int State { get; set; }
+ public bool IsRedacted { get; set; }
+ }
+ public sealed class ChecklistShiftStart { public string WorkshiftDayId { get; set; } public DateTime StartUtc { get; set; } public int UnitId { get; set; } }
+}
diff --git a/Core/Resgrid.Model/Checklists/ChecklistContracts.cs b/Core/Resgrid.Model/Checklists/ChecklistContracts.cs
index 04c9f00db..698a3d09f 100644
--- a/Core/Resgrid.Model/Checklists/ChecklistContracts.cs
+++ b/Core/Resgrid.Model/Checklists/ChecklistContracts.cs
@@ -17,20 +17,19 @@ public sealed class ChecklistForm
public bool RequireLocation { get; set; }
public bool RequiresIndependentWitness { get; set; }
public List Sections { get; set; } = new List();
- public static ChecklistForm FromTemplate(ChecklistTemplate template)
+ public static ChecklistForm FromTemplate(ChecklistTemplate template, bool assetsAvailable = false)
{
var form = new ChecklistForm
{
Name = template.Name, Instructions = template.Description, Category = template.SuggestedCategory,
- TargetType = template.SuggestedTargetType == ChecklistTargetType.InventoryAsset ? ChecklistTargetType.Department : template.SuggestedTargetType,
+ TargetType = !assetsAvailable && 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)
+ // Without serialized inventory, retain a required equipment identifier and responsible target.
+ if (!assetsAvailable && 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;
}
@@ -100,6 +99,7 @@ public sealed class ChecklistHistoryEntry { public ChecklistCompletion Completio
public sealed class ChecklistRunView
{
public int VersionNumber { get; set; }
+ public string WitnessAttestation { get; set; }
public ChecklistCompletion Completion { get; set; }
public ChecklistForm Form { get; set; }
public ChecklistTarget Target { get; set; }
diff --git a/Core/Resgrid.Model/Checklists/ChecklistEntities.cs b/Core/Resgrid.Model/Checklists/ChecklistEntities.cs
index 1ed8b1c7c..e2252e1e1 100644
--- a/Core/Resgrid.Model/Checklists/ChecklistEntities.cs
+++ b/Core/Resgrid.Model/Checklists/ChecklistEntities.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
namespace Resgrid.Model.Checklists
{
@@ -36,6 +37,11 @@ public sealed class ChecklistDefinition : ChecklistRow
public sealed class ChecklistDefinitionVersion : ChecklistRow { public int Version { get; set; } }
public sealed class ChecklistOccurrence : ChecklistRow
{
+ public string ScheduleId { get; set; }
+ public int? ScheduleRevision { get; set; }
+ public DateTime? PeriodStartUtc { get; set; }
+ public DateTime? WindowEndUtc { get; set; }
+ public DateTime? MissedOn { get; set; }
public string VersionId { get; set; }
public string CompletionId { get; set; }
public int TargetType { get; set; }
@@ -54,10 +60,17 @@ public sealed class ChecklistCompletion : ChecklistRow
public string WitnessUserId { get; set; }
public DateTime? WitnessedOn { get; set; }
public decimal? Score { get; set; }
- public bool Passed { get; set; }
+ public bool? Passed { get; set; }
+ public string ProtectedScoreEnvelope { get; set; }
+ public string ProtectedPassedEnvelope { 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 ChecklistCompletionItem : ChecklistRow
+ {
+ public string ItemId { get; set; }
+ public bool? IsFailure { get; set; }
+ public string ProtectedIsFailureEnvelope { get; set; }
+ }
public sealed class ChecklistCompletionFile : ChecklistRow
{
public string ItemId { get; set; }
@@ -67,7 +80,19 @@ public sealed class ChecklistCompletionFile : ChecklistRow
public byte[] Data { get; set; }
public int ScanState { get; set; }
}
- public sealed class DepartmentChecklistSettings : ChecklistRow { }
+ public sealed class DepartmentChecklistSettings : ChecklistRow
+ {
+ public bool NotifyAtShiftStart { get; set; } = true;
+ public int? FixedDigestMinute { get; set; }
+ public DateTime? LastDigestSweepUtc { get; set; }
+ public DateTime? DigestActiveFromUtc { get; set; }
+ public bool RemindersEnabled { get; set; }
+ public int NotifyBeforeMinutes { get; set; } = 60;
+ public bool NotifyMissed { get; set; } = true;
+ public bool DigestMode { get; set; } = true;
+ public int? EscalateAfterMinutes { get; set; }
+ public DateTime? RemindersActiveFromUtc { get; set; }
+ }
public static class ChecklistTables
{
public static readonly IReadOnlyDictionary All = new Dictionary
@@ -75,9 +100,29 @@ public static class ChecklistTables
[typeof(ChecklistDefinition)] = "ChecklistDefinitions", [typeof(ChecklistDefinitionVersion)] = "ChecklistDefinitionVersions",
[typeof(ChecklistOccurrence)] = "ChecklistOccurrences", [typeof(ChecklistCompletion)] = "ChecklistCompletions",
[typeof(ChecklistCompletionItem)] = "ChecklistCompletionItems", [typeof(ChecklistCompletionFile)] = "ChecklistCompletionFiles",
- [typeof(DepartmentChecklistSettings)] = "DepartmentChecklistSettings"
+ [typeof(DepartmentChecklistSettings)] = "DepartmentChecklistSettings",
+ [typeof(ChecklistSchedule)] = "ChecklistSchedules"
};
- 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) };
+ public static IReadOnlyDictionary Get, Action Set)> Fields() where T : ChecklistRow
+ {
+ var fields = new Dictionary, Action)>
+ { [All[typeof(T)].ToLowerInvariant() + ".content"] = (x => x.Content, (x, v) => x.Content = v) };
+ // Use the same grant/AAD boundary as Content. Revealing clears the in-memory envelope,
+ // so a subsequent save seals the current value and clears its queryable column again.
+ if (typeof(T) == typeof(ChecklistCompletion))
+ {
+ fields["checklistcompletions.score"] = (x => ((ChecklistCompletion)(ChecklistRow)x).ProtectedScoreEnvelope ?? ((ChecklistCompletion)(ChecklistRow)x).Score?.ToString(CultureInfo.InvariantCulture),
+ (x, v) => { var row = (ChecklistCompletion)(ChecklistRow)x; row.ProtectedScoreEnvelope = Envelope(v); row.Score = decimal.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var score) ? score : null; });
+ fields["checklistcompletions.passed"] = (x => ((ChecklistCompletion)(ChecklistRow)x).ProtectedPassedEnvelope ?? ((ChecklistCompletion)(ChecklistRow)x).Passed?.ToString(),
+ (x, v) => { var row = (ChecklistCompletion)(ChecklistRow)x; row.ProtectedPassedEnvelope = Envelope(v); row.Passed = Boolean(v); });
+ }
+ if (typeof(T) == typeof(ChecklistCompletionItem))
+ fields["checklistcompletionitems.isfailure"] = (x => ((ChecklistCompletionItem)(ChecklistRow)x).ProtectedIsFailureEnvelope ?? ((ChecklistCompletionItem)(ChecklistRow)x).IsFailure?.ToString(),
+ (x, v) => { var row = (ChecklistCompletionItem)(ChecklistRow)x; row.ProtectedIsFailureEnvelope = Envelope(v); row.IsFailure = Boolean(v); });
+ return fields;
+ }
+ private static string Envelope(string value) => ProtectedDataEnvelope.HasEnvelopePrefix(value) ? value : null;
+ // Migration readers may represent a database boolean as either text or 0/1.
+ private static bool? Boolean(string value) => bool.TryParse(value, out var parsed) ? parsed : value == "1" ? true : value == "0" ? false : null;
}
}
diff --git a/Core/Resgrid.Model/Checklists/ChecklistReminders.cs b/Core/Resgrid.Model/Checklists/ChecklistReminders.cs
new file mode 100644
index 000000000..91bf758f1
--- /dev/null
+++ b/Core/Resgrid.Model/Checklists/ChecklistReminders.cs
@@ -0,0 +1,42 @@
+using System;
+
+namespace Resgrid.Model.Checklists
+{
+ public sealed class ChecklistReminderSettingsInput
+ {
+ public bool NotifyAtShiftStart { get; set; } = true;
+ public string FixedDigestTime { get; set; }
+ public int Revision { get; set; }
+ public bool Enabled { get; set; }
+ public int NotifyBeforeMinutes { get; set; } = 60;
+ public bool NotifyMissed { get; set; } = true;
+ public bool DigestMode { get; set; } = true;
+ public int? EscalateAfterMinutes { get; set; }
+ }
+ public enum ChecklistReminderKind { Due = 0, Missed = 1, Escalation = 2, ShiftStart = 3, FixedTime = 4 }
+ public enum ChecklistReminderStatus { Pending = 0, HandedOff = 1, Suppressed = 2 }
+ // Delivery bookkeeping contains routing identifiers only. Never store message bodies,
+ // checklist content, contact details, grants, exception text or rendered outcomes here.
+ public sealed class ChecklistReminder
+ {
+ public string PeriodKey { get; set; } = "";
+ public string Id { get; set; } = Guid.NewGuid().ToString("D");
+ public int DepartmentId { get; set; }
+ public string OccurrenceId { get; set; }
+ public string RecipientUserId { get; set; }
+ public int Kind { get; set; }
+ public int Status { get; set; }
+ public DateTime CreatedOnUtc { get; set; }
+ public DateTime NextAttemptUtc { get; set; }
+ public int Attempts { get; set; }
+ public string ClaimToken { get; set; }
+ public DateTime? ClaimUntilUtc { get; set; }
+ public DateTime? CompletedOnUtc { get; set; }
+ }
+ public sealed class ChecklistReminderSweepResult
+ {
+ public int HandedOff { get; set; }
+ public int Suppressed { get; set; }
+ public int Errors { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Checklists/ChecklistScheduling.cs b/Core/Resgrid.Model/Checklists/ChecklistScheduling.cs
new file mode 100644
index 000000000..d0f259a8e
--- /dev/null
+++ b/Core/Resgrid.Model/Checklists/ChecklistScheduling.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Checklists
+{
+ // Values 0-2 retain the original on-demand occurrence/run state mapping.
+ public enum ChecklistOccurrenceState { InProgress = 0, AwaitingWitness = 1, Submitted = 2, Scheduled = 3, Missed = 4, Skipped = 5, Cancelled = 6 }
+ public sealed class ChecklistSchedule : ChecklistRow
+ {
+ public int AssignmentType { get; set; }
+ public string AssignmentId { get; set; }
+ public string VersionId { get; set; }
+ public int TargetType { get; set; }
+ public string TargetId { get; set; }
+ public int? TargetGroupId { get; set; }
+ public int Frequency { get; set; }
+ public string TimeZoneId { get; set; }
+ public DateTime StartDate { get; set; }
+ public DateTime? EndDate { get; set; }
+ public string ClockMinutes { get; set; }
+ public int Weekdays { get; set; }
+ public int DayOfMonth { get; set; }
+ public int MonthOfYear { get; set; }
+ public int WindowMinutes { get; set; }
+ public string WorkshiftId { get; set; }
+ public bool IsActive { get; set; }
+ public bool IsSuspended { get; set; }
+ public DateTime ActiveFromUtc { get; set; }
+ public DateTime GeneratedThroughUtc { get; set; }
+ public DateTime LastSweepUtc { get; set; }
+ }
+ public sealed class ChecklistScheduleContent { public string Name { get; set; } public string Notes { get; set; } }
+ public sealed class ChecklistScheduleInput
+ {
+ public int AssignmentType { get; set; }
+ public string AssignmentId { get; set; }
+ public string Id { get; set; }
+ public string DefinitionId { get; set; }
+ public int Revision { get; set; }
+ public string Name { get; set; }
+ public string Notes { get; set; }
+ public string TargetId { get; set; }
+ public ChecklistScheduleFrequency Frequency { get; set; } = ChecklistScheduleFrequency.Daily;
+ public string TimeZoneId { get; set; } = "UTC";
+ public DateTime StartDate { get; set; } = DateTime.UtcNow.Date;
+ public DateTime? EndDate { get; set; }
+ public string TimesOfDay { get; set; } = "08:00";
+ public int Weekdays { get; set; } = 127;
+ public int DayOfMonth { get; set; } = 1;
+ public int MonthOfYear { get; set; } = 1;
+ public int WindowMinutes { get; set; } = 60;
+ public string WorkshiftId { get; set; }
+ public bool IsActive { get; set; } = true;
+ }
+ public sealed class ChecklistScheduleView { public ChecklistSchedule Schedule { get; set; } public ChecklistScheduleContent Content { get; set; } }
+ public sealed class ChecklistOccurrenceView
+ {
+ public ChecklistOccurrence Occurrence { get; set; }
+ public string Name { get; set; }
+ public ChecklistTarget Target { get; set; }
+ public bool CanStart { get; set; }
+ public bool CanSkip { get; set; }
+ }
+ public sealed class ChecklistScheduleSweepResult { public int Generated { get; set; } public int Missed { get; set; } public int Errors { get; set; } }
+}
diff --git a/Core/Resgrid.Model/Checklists/ChecklistTemplate.cs b/Core/Resgrid.Model/Checklists/ChecklistTemplate.cs
index 5d6017141..b5c78cc07 100644
--- a/Core/Resgrid.Model/Checklists/ChecklistTemplate.cs
+++ b/Core/Resgrid.Model/Checklists/ChecklistTemplate.cs
@@ -19,8 +19,7 @@ public sealed class ChecklistTemplate
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 string SearchText { get; }
public ChecklistTemplate(string id, string name, string sector, string description,
ChecklistCategory category, ChecklistScheduleFrequency frequency, ChecklistTargetType target,
@@ -36,6 +35,8 @@ public ChecklistTemplate(string id, string name, string sector, string descripti
RequiresIndependentWitness = requiresIndependentWitness;
Keywords = keywords.ToList().AsReadOnly();
Sections = sections.ToList().AsReadOnly();
+ SearchText = string.Join(" ", new[] { Name, Sector, Description }
+ .Concat(Keywords).Concat(Sections.SelectMany(s => s.Items).Select(i => i.Name))).ToLowerInvariant();
}
}
diff --git a/Core/Resgrid.Model/Checklists/ChecklistTemplateCatalog.cs b/Core/Resgrid.Model/Checklists/ChecklistTemplateCatalog.cs
index 77dadd098..20681d918 100644
--- a/Core/Resgrid.Model/Checklists/ChecklistTemplateCatalog.cs
+++ b/Core/Resgrid.Model/Checklists/ChecklistTemplateCatalog.cs
@@ -34,11 +34,16 @@ public static IReadOnlyList GetByCategory(ChecklistCategory c
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)
+ => T(id, name, sector, description, category, frequency, target, false, items);
+
+ private static ChecklistTemplate T(string id, string name, string sector, string description,
+ ChecklistCategory category, ChecklistScheduleFrequency frequency, ChecklistTargetType target,
+ bool requiresIndependentWitness, 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[]
+ requiresIndependentWitness, new[] { category.ToString(), target.ToString() }, new[]
{
new ChecklistTemplateSection(Id(id + ":inspection"), "Readiness checks", checks),
new ChecklistTemplateSection(Id(id + ":handover"), "Findings and handover", new[]
@@ -89,7 +94,7 @@ private static ChecklistTemplate T(string id, string name, string sector, string
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,
+ ChecklistCategory.SafetyAudit, ChecklistScheduleFrequency.PerShift, ChecklistTargetType.Unit, requiresIndependentWitness: true,
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.",
diff --git a/Core/Resgrid.Model/Checklists/ChecklistValidation.cs b/Core/Resgrid.Model/Checklists/ChecklistValidation.cs
index e164c08c0..f07a56aad 100644
--- a/Core/Resgrid.Model/Checklists/ChecklistValidation.cs
+++ b/Core/Resgrid.Model/Checklists/ChecklistValidation.cs
@@ -13,7 +13,7 @@ public static List Validate(ChecklistForm form)
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 (!Enum.IsDefined(typeof(ChecklistCategory), form.Category) || !new[] { ChecklistTargetType.Department, ChecklistTargetType.Unit, ChecklistTargetType.Group, ChecklistTargetType.Personnel, ChecklistTargetType.InventoryAsset }.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);
diff --git a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs
new file mode 100644
index 000000000..204096c98
--- /dev/null
+++ b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Model.Checklists
+{
+ /// Only reviewed routing facts and protected outcomes may cross the Workflow boundary.
+ public static class ChecklistWorkflowPayload
+ {
+ public static readonly int[] Triggers = { 67, 68, 69, 164, 165 };
+ private static readonly string[] Identifiers = { "CompletionId", "DefinitionId", "VersionId", "ItemId", "ScheduleId", "OccurrenceId" };
+ private static void Timing(JObject source, JObject target)
+ {
+ foreach (var name in new[] { "State", "Revision" }) if (source[name]?.Type == JTokenType.Integer) target[name] = source[name].DeepClone();
+ if (source["IsActive"]?.Type == JTokenType.Boolean) target["IsActive"] = source["IsActive"].DeepClone();
+ foreach (var name in new[] { "PeriodStartUtc", "WindowEndUtc" })
+ if ((source[name]?.Type == JTokenType.Date || source[name]?.Type == JTokenType.String) && DateTimeOffset.TryParse(source[name].ToString(), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var date)) target[name] = date.UtcDateTime.ToString("O");
+ }
+ public static string Routing(string payloadJson, string aggregateId)
+ {
+ var source = JObject.Parse(payloadJson ?? "{}"); var safe = new JObject();
+ foreach (var name in Identifiers)
+ if (source[name]?.Type == JTokenType.String && Guid.TryParse(source[name].Value(), out var id)) safe[name] = id.ToString("D");
+ if (safe["CompletionId"] == null && safe["ScheduleId"] == null && safe["OccurrenceId"] == null && Guid.TryParse(aggregateId, out var completion)) safe["CompletionId"] = completion.ToString("D");
+ Timing(source, safe);
+ if (source["TargetType"]?.Type == JTokenType.Integer) safe["TargetType"] = source["TargetType"].DeepClone();
+ if (source["TargetId"]?.Type == JTokenType.String)
+ {
+ var target = source["TargetId"].Value(); var type = safe["TargetType"]?.Value() ?? -1;
+ var structural = type >= 0 && type <= 2 && int.TryParse(target, out var numeric) && numeric > 0
+ || type == (int)ChecklistTargetType.InventoryAsset && Guid.TryParse(target, out _);
+ safe["TargetId"] = structural ? target : ProtectedDataEnvelope.RedactionValue;
+ }
+ safe["Score"] = ProtectedDataEnvelope.RedactionValue; safe["Passed"] = ProtectedDataEnvelope.RedactionValue;
+ safe["is_redacted"] = true; safe["redacted_fields"] = new JArray(new[] { "Score", "Passed", "TargetId" }.Where(n => safe[n]?.Value() == ProtectedDataEnvelope.RedactionValue)); safe["catalog_version"] = ReadinessHistoryFields.CatalogVersion;
+ return safe.ToString(Newtonsoft.Json.Formatting.None);
+ }
+ public static bool IsChecklist(int trigger) => Triggers.Contains(trigger);
+ public static async Task ProjectAsync(int departmentId, object value, IProtectedProjectionService protection, bool wrapped = false)
+ {
+ if (protection == null) throw new InvalidOperationException("Checklist workflow protection is unavailable.");
+ var source = value as JObject ?? (value == null ? new JObject() : JObject.FromObject(value));
+ var payload = wrapped ? source["Payload"] as JObject ?? new JObject() : source;
+ var safe = new JObject();
+ foreach (var name in Identifiers)
+ if (Guid.TryParse(payload[name]?.Value(), out var id)) safe[name] = id.ToString("D");
+ Timing(payload, safe);
+ if (payload["TargetType"]?.Type == JTokenType.Integer) safe["TargetType"] = payload["TargetType"].DeepClone();
+ if (payload["TargetId"]?.Type == JTokenType.String && payload["TargetId"].Value().Length <= 128)
+ safe["TargetId"] = payload["TargetId"].DeepClone();
+ foreach (var name in new[] { "Score", "Passed" })
+ {
+ var token = payload[name];
+ if (token == null || token.Type == JTokenType.Null || name == "Score" && (token.Type == JTokenType.Integer || token.Type == JTokenType.Float) || name == "Passed" && token.Type == JTokenType.Boolean)
+ safe[name] = token?.DeepClone();
+ else safe[name] = ProtectedDataEnvelope.RedactionValue;
+ }
+ var projected = JObject.Parse(await protection.BuildSafeWorkflowPayloadAsync(departmentId, safe)
+ ?? throw new InvalidOperationException("Checklist workflow projection failed."));
+ // A later policy change cannot restore a value already withheld by an earlier projection.
+ if (projected["is_redacted"]?.Value() == true && projected["TargetType"]?.Value() == (int)ChecklistTargetType.Personnel)
+ projected["TargetId"] = ProtectedDataEnvelope.RedactionValue;
+ var redacted = new[] { "Score", "Passed", "TargetId" }.Where(n => projected[n]?.Type == JTokenType.String && projected[n].Value() == ProtectedDataEnvelope.RedactionValue).ToArray();
+ projected["is_redacted"] = projected["is_redacted"]?.Value() == true || redacted.Length > 0;
+ projected["redacted_fields"] = new JArray(redacted);
+ projected["catalog_version"] = Math.Max(projected["catalog_version"]?.Value() ?? 0,
+ payload["catalog_version"]?.Type == JTokenType.Integer ? payload["catalog_version"].Value() : 0);
+ if (!wrapped) return projected.ToString(Newtonsoft.Json.Formatting.None);
+ // The execution context reads Payload; no stored free-form envelope additions survive replay.
+ return new JObject { ["Payload"] = projected }.ToString(Newtonsoft.Json.Formatting.None);
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs b/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs
new file mode 100644
index 000000000..e3234695f
--- /dev/null
+++ b/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Model.Checklists
+{
+ public static class ReadinessHistoryFields
+ {
+ public const int CatalogVersion = 16;
+ public static readonly int[] AuditTypes = Enum.GetValues().Where(x => x.ToString().StartsWith("Checklist", StringComparison.Ordinal)).Select(x => (int)x).ToArray();
+ public static bool IsChecklistAudit(int type) => AuditTypes.Contains(type);
+ public static readonly IReadOnlyDictionary Get, Action Set)> Audits = new Dictionary, Action)>
+ { ["auditlogs.data"] = (x => x.Data, (x, v) => x.Data = v) };
+ public static readonly IReadOnlyDictionary Get, Action Set)> Outbox = new Dictionary, Action)>
+ {
+ ["domaineventoutbox.payloadjson"] = (x => x.PayloadJson, (x, v) => x.PayloadJson = v),
+ ["domaineventoutbox.lasterror"] = (x => x.LastError, (x, v) => x.LastError = v)
+ };
+ public static readonly IReadOnlyDictionary Get, Action Set)> Runs = new Dictionary, Action)>
+ {
+ ["workflowruns.inputpayload"] = (x => x.InputPayload, (x, v) => x.InputPayload = v),
+ ["workflowruns.errormessage"] = (x => x.ErrorMessage, (x, v) => x.ErrorMessage = v)
+ };
+ public static readonly IReadOnlyDictionary Get, Action Set)> Logs = new Dictionary, Action)>
+ {
+ ["workflowrunlogs.renderedoutput"] = (x => x.RenderedOutput, (x, v) => x.RenderedOutput = v),
+ ["workflowrunlogs.actionresult"] = (x => x.ActionResult, (x, v) => x.ActionResult = v),
+ ["workflowrunlogs.errormessage"] = (x => x.ErrorMessage, (x, v) => x.ErrorMessage = v)
+ };
+ }
+}
diff --git a/Core/Resgrid.Model/Records/DomainEventOutboxEntry.cs b/Core/Resgrid.Model/Records/DomainEventOutboxEntry.cs
index 8abc164c0..3e4d3b8c3 100644
--- a/Core/Resgrid.Model/Records/DomainEventOutboxEntry.cs
+++ b/Core/Resgrid.Model/Records/DomainEventOutboxEntry.cs
@@ -41,6 +41,8 @@ public class DomainEventOutboxEntry : IEntity
public int? TriggerEventType { get; set; }
public string PayloadJson { get; set; }
+ /// Allowlisted routing facts with outcomes always withheld; retained when ADP seals the historical payload.
+ public string ReadinessRoutingJson { get; set; }
public string CorrelationId { get; set; }
diff --git a/Core/Resgrid.Model/Repositories/IChecklistReminderRepository.cs b/Core/Resgrid.Model/Repositories/IChecklistReminderRepository.cs
new file mode 100644
index 000000000..ff8952366
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IChecklistReminderRepository.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model.Checklists;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IChecklistReminderRepository
+ {
+ Task> DepartmentsAsync(int afterDepartmentId, CancellationToken ct = default);
+ Task> ForRecipientAsync(int departmentId, string userId, int skip, CancellationToken ct = default);
+ // Caller holds the checklist department transaction lock for enqueue and claim.
+ Task EnqueueAsync(ChecklistReminder reminder, CancellationToken ct = default);
+ Task> ClaimAsync(int departmentId, DateTime now, bool digest, CancellationToken ct = default);
+ Task FinishAsync(ChecklistReminder reminder, ChecklistReminderStatus status, DateTime now, CancellationToken ct = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IChecklistRepository.cs b/Core/Resgrid.Model/Repositories/IChecklistRepository.cs
index 4e9e1e87e..3468c41d0 100644
--- a/Core/Resgrid.Model/Repositories/IChecklistRepository.cs
+++ b/Core/Resgrid.Model/Repositories/IChecklistRepository.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System;
using System.Threading;
using System.Threading.Tasks;
using Resgrid.Model.Checklists;
@@ -8,11 +9,25 @@ namespace Resgrid.Model.Repositories
public interface IChecklistRepository
{
Task LockDepartmentAsync(int departmentId, CancellationToken ct = default);
+ Task LockAccessFenceAsync(CancellationToken ct = default);
+ Task ApplyAccessStateAsync(int departmentId, bool enabled, DateTime nowUtc, CancellationToken ct = default, bool inventoryEnabled = true);
+ Task> CalendarOccurrencesAsync(int departmentId, DateTime fromUtc, DateTime untilUtc, int skip, CancellationToken ct = default);
+ Task> ShiftStartsAsync(int departmentId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default);
+ Task AdvanceDigestSweepAsync(int departmentId, DateTime nowUtc, CancellationToken ct = default);
Task GetAsync(int departmentId, string id, CancellationToken ct = default) where T : ChecklistRow;
Task> ListAsync(int departmentId, string parentId = null, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow;
+ Task> ListChildrenAsync(int departmentId, IReadOnlyCollection parentIds, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow;
Task WriteAsync(T row, bool insert, CancellationToken ct = default) where T : ChecklistRow;
Task ReplaceAnswersAsync(int departmentId, string completionId, IEnumerable items, CancellationToken ct = default);
Task DeleteFileAsync(int departmentId, string id, CancellationToken ct = default);
Task GetFileMetadataAsync(int departmentId, string id);
+ Task> SchedulingDepartmentsAsync(int afterDepartmentId, CancellationToken ct = default);
+ Task> ActiveSchedulesAsync(int departmentId, string afterId, CancellationToken ct = default);
+ Task> ScheduledOccurrencesAsync(int departmentId, string scheduleId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default);
+ Task> DueOccurrencesAsync(int departmentId, DateTime untilUtc, int skip, CancellationToken ct = default);
+ Task> OccurrencesInWindowAsync(int departmentId, string scheduleId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default);
+ Task CancelUnstartedOccurrencesAsync(int departmentId, string scheduleId, DateTime? fromUtc, DateTime nowUtc, CancellationToken ct = default);
+ Task WorkshiftExistsAsync(int departmentId, string workshiftId, CancellationToken ct = default);
+ Task> WorkshiftStartsAsync(int departmentId, string workshiftId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default);
}
}
diff --git a/Core/Resgrid.Model/Repositories/IFeatureFlagRepository.cs b/Core/Resgrid.Model/Repositories/IFeatureFlagRepository.cs
index 9554b95b5..9f414f417 100644
--- a/Core/Resgrid.Model/Repositories/IFeatureFlagRepository.cs
+++ b/Core/Resgrid.Model/Repositories/IFeatureFlagRepository.cs
@@ -6,5 +6,6 @@
///
public interface IFeatureFlagRepository : IRepository
{
+ System.Threading.Tasks.Task TouchEvaluationAsync(int flagId, System.DateTime evaluatedOn, System.Threading.CancellationToken ct = default);
}
}
diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs
index f74659958..53e8ef091 100644
--- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs
@@ -135,6 +135,10 @@ public interface IRmsExternalReferencesRepository : IRepository
{
+ /// Fill a newly allocated checklist event inside its producer transaction; only an empty, unleased Pending row is eligible.
+ Task InitializeChecklistPayloadAsync(DomainEventOutboxEntry entry, CancellationToken cancellationToken = default);
+ /// Replace a checklist payload with its current safe projection while holding its dispatch lease.
+ Task ReplaceChecklistPayloadAsync(DomainEventOutboxEntry leasedEntry, string safePayload, CancellationToken cancellationToken = default);
Task GetNextSequenceAsync(int departmentId, string aggregateId);
/// Leases up to pending rows whose NextAttemptOn has passed (or is null) to .
Task> ClaimPendingBatchAsync(string leaseOwner, TimeSpan leaseDuration, int batchSize, DateTime utcNow, CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs b/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs
index 2d556922e..4bbb27f00 100644
--- a/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs
+++ b/Core/Resgrid.Model/Repositories/IWorkflowRunRepository.cs
@@ -5,6 +5,8 @@ namespace Resgrid.Model.Repositories
{
public interface IWorkflowRunRepository : IRepository
{
+ /// Claim a checklist event attempt once; duplicate queue deliveries cannot execute it twice.
+ Task TryStartChecklistRunAsync(string runId, string workflowId, int departmentId, int attemptNumber, string safePayload);
Task> GetByDepartmentIdPagedAsync(int departmentId, int page, int pageSize);
Task> GetPendingAndRunningByDepartmentIdAsync(int departmentId);
Task> GetRunsByWorkflowIdAsync(string workflowId, int page, int pageSize);
diff --git a/Core/Resgrid.Model/Services/IChecklistReminderService.cs b/Core/Resgrid.Model/Services/IChecklistReminderService.cs
new file mode 100644
index 000000000..59e6967f2
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IChecklistReminderService.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model.Checklists;
+
+namespace Resgrid.Model.Services
+{
+ public interface IChecklistReminderService
+ {
+ Task SweepAsync(DateTime utcNow, CancellationToken ct = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IChecklistsService.cs b/Core/Resgrid.Model/Services/IChecklistsService.cs
index 46e6d6c8b..d5ab48ef6 100644
--- a/Core/Resgrid.Model/Services/IChecklistsService.cs
+++ b/Core/Resgrid.Model/Services/IChecklistsService.cs
@@ -1,4 +1,6 @@
using System.Collections.Generic;
+using System;
+using System.Threading;
using System.Threading.Tasks;
using Resgrid.Model.Checklists;
@@ -7,7 +9,13 @@ namespace Resgrid.Model.Services
public interface IChecklistsService
{
Task CanManageAsync(ChecklistActor actor);
- Task> ListAsync(ChecklistActor actor, int page = 0);
+ Task> CalendarAsync(ChecklistActor actor, DateTime fromUtc, DateTime untilUtc);
+ Task OccurrenceAsync(ChecklistActor actor, string id);
+ Task> AssignmentChoicesAsync(ChecklistActor actor);
+ Task AssetTargetsAvailableAsync(ChecklistActor actor);
+ Task ReminderSettingsAsync(ChecklistActor actor);
+ Task SaveReminderSettingsAsync(ChecklistActor actor, ChecklistReminderSettingsInput input);
+ Task> ListAsync(ChecklistActor actor, int page = 0, bool includeNext = false);
Task GetDefinitionAsync(ChecklistActor actor, string id);
Task SaveDefinitionAsync(ChecklistActor actor, string id, int revision, ChecklistForm form);
Task PublishAsync(ChecklistActor actor, string id, int revision);
@@ -15,11 +23,18 @@ public interface IChecklistsService
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> HistoryAsync(ChecklistActor actor, string definitionId, int page = 0, bool includeNext = false);
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);
+ Task> SchedulesAsync(ChecklistActor actor, string definitionId, int page = 0);
+ Task GetScheduleAsync(ChecklistActor actor, string id);
+ Task SaveScheduleAsync(ChecklistActor actor, ChecklistScheduleInput input);
+ Task> DueAsync(ChecklistActor actor, int page = 0);
+ Task StartOccurrenceAsync(ChecklistActor actor, string occurrenceId);
+ Task SkipOccurrenceAsync(ChecklistActor actor, string occurrenceId, int revision, string reason);
+ Task SweepSchedulesAsync(DateTime utcNow, CancellationToken ct = default);
}
}
diff --git a/Core/Resgrid.Model/Services/IFeatureFlagMutationObserver.cs b/Core/Resgrid.Model/Services/IFeatureFlagMutationObserver.cs
new file mode 100644
index 000000000..7f670ecbf
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IFeatureFlagMutationObserver.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ /// Runs inside the flag/module command transaction. Before acquires locks before policy writes;
+ /// After observes uncached policy and persists dependent operational state before commit.
+ public interface IFeatureFlagMutationObserver
+ {
+ Task BeforeChangeAsync(int? departmentId, CancellationToken ct);
+ Task AfterChangeAsync(Func> evaluate, CancellationToken ct);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IFeatureToggleService.cs b/Core/Resgrid.Model/Services/IFeatureToggleService.cs
index cdec04aa7..044eae947 100644
--- a/Core/Resgrid.Model/Services/IFeatureToggleService.cs
+++ b/Core/Resgrid.Model/Services/IFeatureToggleService.cs
@@ -25,6 +25,7 @@ public interface IFeatureToggleService
/// Full evaluation including the value and the reason (source) it resolved that way.
Task EvaluateAsync(string key, int departmentId, IDictionary context = null);
+ Task EvaluateFreshAsync(string key, int departmentId);
/// Resolved string value for a multivariate flag (or defaultValue when off/unknown).
Task GetValueAsync(string key, int departmentId, string defaultValue = null, IDictionary context = null);
diff --git a/Core/Resgrid.Model/Services/IReadinessHistoryProtectionService.cs b/Core/Resgrid.Model/Services/IReadinessHistoryProtectionService.cs
new file mode 100644
index 000000000..a9b6e1c82
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IReadinessHistoryProtectionService.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface IReadinessHistoryProtectionService
+ {
+ Task ProtectAsync(int departmentId, string rowKey, T entity, IReadOnlyDictionary Get, Action Set)> fields, CancellationToken ct = default) where T : class;
+ Task ForDisplayAsync(int departmentId, T entity, IReadOnlyDictionary Get, Action Set)> fields) where T : class;
+ }
+}
diff --git a/Core/Resgrid.Model/WorkflowRun.cs b/Core/Resgrid.Model/WorkflowRun.cs
index 8916f4227..37cab2769 100644
--- a/Core/Resgrid.Model/WorkflowRun.cs
+++ b/Core/Resgrid.Model/WorkflowRun.cs
@@ -32,7 +32,6 @@ public class WorkflowRun : IEntity
public DateTime? CompletedOn { get; set; }
- [MaxLength(4000)]
public string ErrorMessage { get; set; }
public int AttemptNumber { get; set; } = 1;
diff --git a/Core/Resgrid.Model/WorkflowRunLog.cs b/Core/Resgrid.Model/WorkflowRunLog.cs
index 5041e923a..ebc7d4df4 100644
--- a/Core/Resgrid.Model/WorkflowRunLog.cs
+++ b/Core/Resgrid.Model/WorkflowRunLog.cs
@@ -28,10 +28,8 @@ public class WorkflowRunLog : IEntity
/// The Scriban-rendered content that was passed to the action executor.
public string RenderedOutput { get; set; }
- [MaxLength(4000)]
public string ActionResult { get; set; }
- [MaxLength(4000)]
public string ErrorMessage { get; set; }
public DateTime StartedOn { get; set; }
diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
index 8be28a02e..bd901d578 100644
--- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
+++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
@@ -388,8 +388,14 @@ public static IReadOnlyList GetVariableCatalog(Workf
{
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));
+ case WorkflowTriggerEventType.ChecklistMissed:
+ case WorkflowTriggerEventType.ChecklistScheduleChanged:
+ case WorkflowTriggerEventType.ChecklistOccurrenceSkipped:
+ foreach (var field in new[] { "completion_id", "definition_id", "version_id", "target_type", "target_id", "score", "passed", "item_id", "schedule_id", "occurrence_id", "period_start_utc", "window_end_utc", "state", "is_active", "revision", "url" })
+ list.Add(new TemplateVariableDescriptor("checklist." + field, "Checklist " + field + (field == "score" || field == "passed" ? "; REDACTED when protected. Check protection.is_redacted before comparing results." : ""), field == "passed" || field == "is_active" ? "bool" : field == "score" ? "decimal" : field == "target_type" || field == "state" || field == "revision" ? "int" : "string", false));
+ list.Add(new TemplateVariableDescriptor("protection.is_redacted", "Whether checklist outcomes were withheld", "bool", false));
+ list.Add(new TemplateVariableDescriptor("protection.redacted_fields", "Names of withheld checklist outcomes", "array", false));
+ list.Add(new TemplateVariableDescriptor("protection.catalog_version", "Protection catalog used for this projection", "int", false));
break;
case WorkflowTriggerEventType.CommandEstablished:
case WorkflowTriggerEventType.CommandTransferred:
@@ -1007,4 +1013,3 @@ internal static List Also(this List list, System.Action> action
}
}
}
-
diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs
index eec815af5..15a7e4c8d 100644
--- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs
+++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs
@@ -4,6 +4,9 @@ public enum WorkflowTriggerEventType
{
ChecklistCompleted = 67,
ChecklistFailed = 68,
+ ChecklistMissed = 69,
+ ChecklistScheduleChanged = 164,
+ ChecklistOccurrenceSkipped = 165,
CallAdded = 0,
CallUpdated = 1,
CallClosed = 2,
@@ -184,4 +187,3 @@ public static bool IsRecordsTrigger(WorkflowTriggerEventType type)
}
}
}
-
diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs
index 5cb665723..debad0cf0 100644
--- a/Core/Resgrid.Services/AdpTableBindings.cs
+++ b/Core/Resgrid.Services/AdpTableBindings.cs
@@ -43,7 +43,7 @@ public static IReadOnlyList ForVersionRange(IProtectedFieldCata
// carry the init-only marker column across.
scoped.Add(new AdpTableBinding(binding.TableName, binding.PkColumn, binding.PkIsNumeric,
binding.DepartmentColumn, binding.ParentFkColumn, binding.ParentTable, binding.ParentPkColumn,
- columns) with { ProtectedMarkerColumn = binding.ProtectedMarkerColumn, CarrierColumns = binding.CarrierColumns, RowFilterColumn = binding.RowFilterColumn });
+ columns) with { ProtectedMarkerColumn = binding.ProtectedMarkerColumn, CarrierColumns = binding.CarrierColumns, RowFilterColumn = binding.RowFilterColumn, Discriminator = binding.Discriminator });
}
return scoped;
@@ -57,12 +57,20 @@ AdpColumnSpec Binary(string table, string column) =>
new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ProtectedFieldStorageKind.Binary);
AdpColumnSpec Packed(string table, string column) =>
new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ProtectedFieldStorageKind.PackedJson);
- AdpColumnSpec Companion(string table, string column) =>
+ AdpColumnSpec Companion(string table, string column, bool boolean = false) =>
new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}",
- ProtectedFieldStorageKind.CompanionColumn, $"Protected{column}Envelope");
+ ProtectedFieldStorageKind.CompanionColumn, $"Protected{column}Envelope", boolean);
var bindings = new List
{
+ AdpTableBinding.Direct("AuditLogs", "AuditLogId", true, "DepartmentId", new[] { Text("AuditLogs", "Data") })
+ with { Discriminator = new AdpRowDiscriminator("LogType", Resgrid.Model.Checklists.ReadinessHistoryFields.AuditTypes) },
+ AdpTableBinding.Direct("DomainEventOutbox", "DomainEventOutboxId", true, "DepartmentId", new[] { Text("DomainEventOutbox", "PayloadJson"), Text("DomainEventOutbox", "LastError") })
+ with { Discriminator = new AdpRowDiscriminator("ProducerSubsystem", Text: "Checklists") },
+ AdpTableBinding.Direct("WorkflowRuns", "WorkflowRunId", false, "DepartmentId", new[] { Text("WorkflowRuns", "InputPayload"), Text("WorkflowRuns", "ErrorMessage") })
+ with { Discriminator = new AdpRowDiscriminator("TriggerEventType", Resgrid.Model.Checklists.ChecklistWorkflowPayload.Triggers) },
+ AdpTableBinding.ViaParent("WorkflowRunLogs", "WorkflowRunLogId", false, "WorkflowRunId", "WorkflowRuns", "WorkflowRunId", new[] { Text("WorkflowRunLogs", "RenderedOutput"), Text("WorkflowRunLogs", "ActionResult"), Text("WorkflowRunLogs", "ErrorMessage") })
+ with { Discriminator = new AdpRowDiscriminator("TriggerEventType", Resgrid.Model.Checklists.ChecklistWorkflowPayload.Triggers, OnParent: true) },
AdpTableBinding.Direct("Calls", "CallId", pkIsNumeric: true, "DepartmentId", new[]
{
Text("Calls", "Name"), Text("Calls", "Type"), Text("Calls", "NatureOfCall"),
@@ -498,8 +506,13 @@ AdpColumnSpec Companion(string table, string column) =>
}) 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();
+ AdpTableBinding.Direct(table, "Id", false, "DepartmentId", table switch
+ {
+ "ChecklistCompletionFiles" => new[] { Text(table, "Content"), Binary(table, "Data") },
+ "ChecklistCompletions" => new[] { Text(table, "Content"), Companion(table, "Score"), Companion(table, "Passed", true) },
+ "ChecklistCompletionItems" => new[] { Text(table, "Content"), Companion(table, "IsFailure", true) },
+ _ => new[] { Text(table, "Content") }
+ }) with { ProtectedMarkerColumn = "IsProtected" })).ToList();
}
}
}
diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs
index 5bacb3486..779fa269e 100644
--- a/Core/Resgrid.Services/AuditService.cs
+++ b/Core/Resgrid.Services/AuditService.cs
@@ -5,6 +5,7 @@
using System.Threading;
using System.Threading.Tasks;
using Resgrid.Model;
+using Resgrid.Model.Checklists;
using Resgrid.Model.Repositories;
using Resgrid.Model.Services;
@@ -14,11 +15,13 @@ public class AuditService : IAuditService
{
private readonly IAuditLogsRepository _auditLogsRepository;
private readonly IUserProfileService _userProfileService;
+ private readonly Lazy _history;
- public AuditService(IAuditLogsRepository auditLogsRepository, IUserProfileService userProfileService)
+ public AuditService(IAuditLogsRepository auditLogsRepository, IUserProfileService userProfileService, Lazy history = null)
{
_auditLogsRepository = auditLogsRepository;
_userProfileService = userProfileService;
+ _history = history;
}
public async Task SaveAuditLogAsync(AuditLog auditLog, CancellationToken cancellationToken = default(CancellationToken))
@@ -29,13 +32,13 @@ public AuditService(IAuditLogsRepository auditLogsRepository, IUserProfileServic
public async Task GetAuditLogByIdAsync(int auditLogId)
{
- return await _auditLogsRepository.GetByIdAsync(auditLogId);
+ return await DisplayAsync(await _auditLogsRepository.GetByIdAsync(auditLogId));
}
public async Task> GetAllAuditLogsForDepartmentAsync(int departmentId)
{
var logs = await _auditLogsRepository.GetAllByDepartmentIdAsync(departmentId);
- return logs.ToList();
+ return (await Task.WhenAll(logs.Select(DisplayAsync))).ToList();
}
public async Task> GetAuditLogsForDepartmentPagedAsync(int departmentId, DateTime startDate, DateTime endDate, AuditLogTypes? logType, int page, int pageSize)
@@ -51,7 +54,14 @@ public async Task> GetAuditLogsForDepartmentPagedAsync(int depart
var safeEnd = (endDate == default(DateTime) || endDate < safeStart) ? DateTime.UtcNow : endDate;
var logs = await _auditLogsRepository.GetAuditLogsForDepartmentPagedAsync(departmentId, safeStart, safeEnd, (int?)logType, safePage, safePageSize);
- return logs.ToList();
+ return (await Task.WhenAll(logs.Select(DisplayAsync))).ToList();
+ }
+
+ private Task DisplayAsync(AuditLog log)
+ {
+ if (log == null || !ReadinessHistoryFields.IsChecklistAudit(log.LogType)) return Task.FromResult(log);
+ var history = _history?.Value ?? throw new InvalidOperationException("Readiness history protection is unavailable.");
+ return history.ForDisplayAsync(log.DepartmentId, log, ReadinessHistoryFields.Audits);
}
public string GetAuditLogTypeString(AuditLogTypes logType)
diff --git a/Core/Resgrid.Services/ChecklistAccessMutationObserver.cs b/Core/Resgrid.Services/ChecklistAccessMutationObserver.cs
new file mode 100644
index 000000000..c1b90e375
--- /dev/null
+++ b/Core/Resgrid.Services/ChecklistAccessMutationObserver.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public sealed class ChecklistAccessMutationObserver : IFeatureFlagMutationObserver
+ {
+ private readonly IChecklistRepository _store;
+ private readonly IDepartmentSettingsRepository _settings;
+ private readonly TimeProvider _clock;
+ private readonly List _departments = new();
+ public ChecklistAccessMutationObserver(IChecklistRepository store, IDepartmentSettingsRepository settings, TimeProvider clock = null)
+ { _store = store; _settings = settings; _clock = clock ?? TimeProvider.System; }
+ public async Task BeforeChangeAsync(int? departmentId, CancellationToken ct)
+ {
+ _departments.Clear(); await _store.LockAccessFenceAsync(ct);
+ if (departmentId.HasValue) _departments.Add(departmentId.Value);
+ else for (var after = 0; ;)
+ {
+ var page = await _store.SchedulingDepartmentsAsync(after, ct); if (page.Count == 0) break;
+ _departments.AddRange(page); after = page[^1];
+ }
+ // Same order as scheduling commands: access fence, department, then policy reads/writes.
+ foreach (var department in _departments) await _store.LockDepartmentAsync(department, ct);
+ }
+ public async Task AfterChangeAsync(Func> evaluate, CancellationToken ct)
+ {
+ foreach (var department in _departments)
+ {
+ var row = await _settings.GetDepartmentSettingByIdTypeAsync(department, DepartmentSettingTypes.ModuleSettings);
+ var module = row == null ? new DepartmentModuleSettings() : ObjectSerialization.Deserialize(row.Setting);
+ var enabled = module != null && !module.ChecklistsDisabled && await evaluate(FeatureFlagKeys.ChecklistsSystem, department);
+ await _store.ApplyAccessStateAsync(department, enabled, _clock.GetUtcNow().UtcDateTime, ct, module?.InventoryDisabled != true);
+ }
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ChecklistAssignmentService.cs b/Core/Resgrid.Services/ChecklistAssignmentService.cs
new file mode 100644
index 000000000..df3735b1a
--- /dev/null
+++ b/Core/Resgrid.Services/ChecklistAssignmentService.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading.Tasks;
+using Resgrid.Model.Checklists;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public sealed class UnavailableChecklistAssetSource : IChecklistAssetSource
+ {
+ public Task IsAvailableAsync(int departmentId) => Task.FromResult(false);
+ public Task> ListAsync(ChecklistActor actor) => Task.FromResult(new List());
+ public Task GetAsync(ChecklistActor actor, string id) => Task.FromResult(null);
+ public Task RoutingAsync(int departmentId, string id) => Task.FromResult(null);
+ public Task CanReceiveReminderAsync(int departmentId, string userId, string id) => Task.FromResult(false);
+ }
+ public sealed class ChecklistAssignmentService : IChecklistAssignmentService
+ {
+ private readonly IDepartmentsService _departments;
+ private readonly IDepartmentGroupsService _groups;
+ private readonly IUnitsService _units;
+ private readonly IPersonnelRolesService _roles;
+ public ChecklistAssignmentService(IDepartmentsService departments, IDepartmentGroupsService groups, IUnitsService units, IPersonnelRolesService roles)
+ { _departments = departments; _groups = groups; _units = units; _roles = roles; }
+ public async Task ValidateAsync(int departmentId, int type, string id)
+ {
+ if (type == 0 && string.IsNullOrEmpty(id)) return;
+ if (!Enum.IsDefined(typeof(ChecklistAssignmentType), type) || string.IsNullOrWhiteSpace(id) || id.Length > 128) throw new ChecklistException(400, "AssignmentUnavailable");
+ var valid = false;
+ if (type == 1) { var m = await _departments.GetDepartmentMemberAsync(id, departmentId, true); valid = m?.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true; }
+ else if (int.TryParse(id, NumberStyles.None, CultureInfo.InvariantCulture, out var numeric))
+ {
+ valid = type switch { 2 => (await _roles.GetRoleByIdAsync(numeric))?.DepartmentId == departmentId,
+ 3 => (await _groups.GetGroupByIdAsync(numeric, true))?.DepartmentId == departmentId, 4 => (await _units.GetUnitByIdAsync(numeric))?.DepartmentId == departmentId, _ => false };
+ }
+ if (!valid) throw new ChecklistException(400, "AssignmentUnavailable");
+ }
+ public async Task> MembersAsync(int departmentId, int type, string id)
+ {
+ try { await ValidateAsync(departmentId, type, id); } catch (ChecklistException) { return new HashSet(); }
+ var current = (await _departments.GetAllMembersForDepartmentUnlimitedAsync(departmentId, true)).Where(m => m.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true).Select(m => m.UserId).ToHashSet(StringComparer.Ordinal);
+ IEnumerable assigned = type switch
+ {
+ 0 => current, 1 => new[] { id },
+ 2 => (await _roles.GetAllMembersOfRoleAsync(int.Parse(id, CultureInfo.InvariantCulture))).Select(m => m.UserId),
+ 3 => (await _groups.GetAllMembersForGroupAsync(int.Parse(id, CultureInfo.InvariantCulture))).Where(m => m.DepartmentId == departmentId).Select(m => m.UserId),
+ 4 => (await _units.GetActiveRolesForUnitAsync(int.Parse(id, CultureInfo.InvariantCulture))).Where(m => m.DepartmentId == departmentId).Select(m => m.UserId),
+ _ => Array.Empty()
+ };
+ current.IntersectWith(assigned); return current;
+ }
+ public async Task CanPerformAsync(ChecklistActor actor, ChecklistSchedule schedule) => schedule != null && schedule.DepartmentId == actor.DepartmentId && (schedule.AssignmentType == 0 || (await MembersAsync(actor.DepartmentId, schedule.AssignmentType, schedule.AssignmentId)).Contains(actor.UserId));
+ public async Task> ChoicesAsync(ChecklistActor actor)
+ {
+ var choices = new List();
+ foreach (var member in (await _departments.GetAllMembersForDepartmentUnlimitedAsync(actor.DepartmentId, true)).Where(m => m.DepartmentId == actor.DepartmentId && !m.IsDeleted && m.IsDisabled != true))
+ choices.Add(new ChecklistAssignmentChoice { Type = 1, Id = member.UserId, Name = member.User?.UserName ?? member.UserId });
+ foreach (var role in (await _roles.GetRolesForDepartmentUnlimitedAsync(actor.DepartmentId)).Where(r => r.DepartmentId == actor.DepartmentId)) choices.Add(new ChecklistAssignmentChoice { Type = 2, Id = role.PersonnelRoleId.ToString(CultureInfo.InvariantCulture), Name = role.Name });
+ foreach (var group in (await _groups.GetAllGroupsForDepartmentUnlimitedThinAsync(actor.DepartmentId)).Where(g => g.DepartmentId == actor.DepartmentId)) choices.Add(new ChecklistAssignmentChoice { Type = 3, Id = group.DepartmentGroupId.ToString(CultureInfo.InvariantCulture), Name = group.Name });
+ foreach (var unit in (await _units.GetUnitsForDepartmentAsync(actor.DepartmentId)).Where(u => u.DepartmentId == actor.DepartmentId)) choices.Add(new ChecklistAssignmentChoice { Type = 4, Id = unit.UnitId.ToString(CultureInfo.InvariantCulture), Name = unit.Name });
+ return choices;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ChecklistAuthorizationService.cs b/Core/Resgrid.Services/ChecklistAuthorizationService.cs
index 41445018a..baa64e5e5 100644
--- a/Core/Resgrid.Services/ChecklistAuthorizationService.cs
+++ b/Core/Resgrid.Services/ChecklistAuthorizationService.cs
@@ -17,8 +17,9 @@ public class ChecklistAuthorizationService : IChecklistAuthorizationService
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; }
+ private readonly IChecklistAssetSource _assets;
+ public ChecklistAuthorizationService(IDepartmentsService departments, IDepartmentGroupsService groups, IPersonnelRolesService roles, IPermissionsService permissions, IUnitsService units, IAuthorizationService authorization, IUserProfileService profiles, IChecklistAssetSource assets = null)
+ { _departments = departments; _groups = groups; _roles = roles; _permissions = permissions; _units = units; _authorization = authorization; _profiles = profiles; _assets = assets; }
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.");
@@ -50,6 +51,11 @@ public async Task CanReadAsync(ChecklistActor actor, ChecklistCompletion c
public async Task TargetAsync(ChecklistActor actor, ChecklistTargetType type, string id)
{
await RequireMemberAsync(actor);
+ return await TargetCoreAsync(actor, type, id, () => _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId), () => CanManageAsync(actor));
+ }
+ private async Task TargetCoreAsync(ChecklistActor actor, ChecklistTargetType type, string id,
+ Func> ownGroup, Func> canManage)
+ {
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;
@@ -61,14 +67,19 @@ public async Task TargetAsync(ChecklistActor actor, ChecklistTa
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; }
+ var own = await ownGroup();
+ if (group?.DepartmentId == actor.DepartmentId && (groupId == own?.DepartmentGroupId || await canManage())) { 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; }
}
+ else if (type == ChecklistTargetType.InventoryAsset && Guid.TryParse(id, out _) && _assets != null && await _assets.IsAvailableAsync(actor.DepartmentId))
+ {
+ var asset = await _assets.GetAsync(actor, id);
+ if (asset?.DepartmentId == actor.DepartmentId && asset.Id == id) { name = asset.Name; targetGroupId = asset.GroupId; }
+ }
if (name == null) throw new ChecklistException(404, "Target is unavailable.");
return new ChecklistTarget { Type = type, Id = id, Name = name, GroupId = targetGroupId };
}
@@ -82,11 +93,19 @@ public async Task> TargetsAsync(ChecklistActor actor, Chec
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;
+ case ChecklistTargetType.InventoryAsset:
+ if (_assets == null || !await _assets.IsAvailableAsync(actor.DepartmentId)) return new List();
+ ids = (await _assets.ListAsync(actor)).Where(a => a.DepartmentId == actor.DepartmentId).Select(a => a.Id); break;
default: return new List();
}
var result = new List();
+ // Share only actor state within this listing. Recheck each target and every later command.
+ Task ownGroup = null;
+ Task canManage = null;
foreach (var id in ids)
- try { result.Add(await TargetAsync(actor, type, id)); } catch (ChecklistException ex) when (ex.StatusCode == 404) { }
+ try { result.Add(await TargetCoreAsync(actor, type, id,
+ () => ownGroup ??= _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId),
+ () => canManage ??= CanManageAsync(actor))); } catch (ChecklistException ex) when (ex.StatusCode == 404) { }
return result;
}
}
diff --git a/Core/Resgrid.Services/ChecklistCalendar.cs b/Core/Resgrid.Services/ChecklistCalendar.cs
new file mode 100644
index 000000000..d2046fe7a
--- /dev/null
+++ b/Core/Resgrid.Services/ChecklistCalendar.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Checklists;
+
+namespace Resgrid.Services
+{
+ public partial class ChecklistsService
+ {
+ public async Task AssetTargetsAvailableAsync(ChecklistActor actor) { await _authorization.RequireMemberAsync(actor); return _assets != null && await _assets.IsAvailableAsync(actor.DepartmentId); }
+ public async Task> AssignmentChoicesAsync(ChecklistActor actor)
+ { await RequireWriteAsync(actor, true); return _assignments == null ? new List() : await _assignments.ChoicesAsync(actor); }
+ private Task CanPerformScheduleAsync(ChecklistActor actor, ChecklistSchedule schedule) => schedule == null ? Task.FromResult(false)
+ : schedule.AssignmentType == 0 ? Task.FromResult(true) : _assignments?.CanPerformAsync(actor, schedule) ?? Task.FromResult(false);
+ private async Task RequireRunAssignmentAsync(ChecklistActor actor, ChecklistCompletion run)
+ {
+ var occurrence = await _store.GetAsync(actor.DepartmentId, run.OccurrenceId);
+ if (occurrence?.ScheduleId != null && !await CanPerformScheduleAsync(actor, await _store.GetAsync(actor.DepartmentId, occurrence.ScheduleId)))
+ throw new ChecklistException(403, "AssignmentUnavailable");
+ }
+ public async Task OccurrenceAsync(ChecklistActor actor, string id)
+ {
+ Id(id); await _authorization.RequireMemberAsync(actor);
+ var occurrence = await _store.GetAsync(actor.DepartmentId, id);
+ if (occurrence?.ScheduleId == null) throw new ChecklistException(404, "OccurrenceUnavailable");
+ var target = await _authorization.TargetAsync(actor, (ChecklistTargetType)occurrence.TargetType, occurrence.TargetId);
+ var schedule = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, occurrence.ScheduleId));
+ var enabled = await _access.CanUseChecklistsAsync(actor.DepartmentId); occurrence.Content = null;
+ return new ChecklistOccurrenceView { Occurrence = occurrence, Name = Decode(schedule.Content).Name, Target = target,
+ CanStart = enabled && schedule.IsActive && !schedule.IsSuspended && await CanPerformScheduleAsync(actor, schedule) && new[] { 0, 1, 3, 4 }.Contains(occurrence.State) && occurrence.PeriodStartUtc <= _clock.GetUtcNow().UtcDateTime,
+ CanSkip = enabled && await CanManageAsync(actor) && new[] { 3, 4 }.Contains(occurrence.State) && await _store.GetAsync(actor.DepartmentId, occurrence.CompletionId) == null };
+ }
+ public async Task> CalendarAsync(ChecklistActor actor, DateTime fromUtc, DateTime untilUtc)
+ {
+ await _authorization.RequireMemberAsync(actor);
+ fromUtc = ChecklistRecurrence.Utc(fromUtc); untilUtc = ChecklistRecurrence.Utc(untilUtc);
+ if (untilUtc <= fromUtc || (untilUtc - fromUtc).TotalDays > 93) throw new ChecklistException(400, "CalendarRange");
+ var result = new List();
+ if (!await _access.CanUseChecklistsAsync(actor.DepartmentId)) return result;
+ var names = new Dictionary();
+ for (var skip = 0; ; skip += 500)
+ {
+ var rows = await _store.CalendarOccurrencesAsync(actor.DepartmentId, fromUtc, untilUtc, skip);
+ foreach (var row in rows)
+ {
+ try { await _authorization.TargetAsync(actor, (ChecklistTargetType)row.TargetType, row.TargetId); }
+ catch (ChecklistException ex) when (ex.StatusCode == 403 || ex.StatusCode == 404) { continue; }
+ if (!names.TryGetValue(row.ScheduleId, out var name))
+ {
+ var schedule = await _store.GetAsync(actor.DepartmentId, row.ScheduleId);
+ if (schedule == null) continue;
+ try { name = (Decode((await RevealAsync(actor, schedule)).Content).Name, false); }
+ catch (ChecklistException ex) when (ex.StatusCode == 403) { name = (ProtectedDataEnvelope.RedactionValue, true); }
+ names[row.ScheduleId] = name;
+ }
+ result.Add(new ChecklistCalendarEntry { Id = "checklist:" + row.Id, OccurrenceId = row.Id, Title = name.Name, IsRedacted = name.Redacted,
+ StartUtc = ChecklistRecurrence.Utc(row.PeriodStartUtc.Value), EndUtc = ChecklistRecurrence.Utc(row.WindowEndUtc.Value), State = row.State });
+ }
+ if (rows.Count < 500) break;
+ }
+ return result;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ChecklistModuleMutations.cs b/Core/Resgrid.Services/ChecklistModuleMutations.cs
new file mode 100644
index 000000000..acf3fa009
--- /dev/null
+++ b/Core/Resgrid.Services/ChecklistModuleMutations.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public partial class DepartmentSettingsService
+ {
+ private readonly IUnitOfWork _moduleUnit;
+ private readonly IFeatureFlagMutationObserver _moduleObserver;
+ private readonly Lazy _moduleFlags;
+ private async Task MutateModuleAsync(int departmentId, Func> action, CancellationToken ct)
+ {
+ if (_moduleUnit == null || _moduleFlags == null || _moduleUnit.Transaction != null)
+ throw new InvalidOperationException("Module setting commands require their own transaction.");
+ try
+ {
+ await _moduleUnit.CreateOrGetConnectionAsync(ct);
+ await _moduleObserver.BeforeChangeAsync(departmentId, ct);
+ // Observe expired/scheduled policy before replacing it, even between worker sweeps.
+ await _moduleObserver.AfterChangeAsync(async (key, department) => (await _moduleFlags.Value.EvaluateFreshAsync(key, department)).IsEnabled, ct);
+ var result = await action();
+ await _moduleObserver.AfterChangeAsync(async (key, department) => (await _moduleFlags.Value.EvaluateFreshAsync(key, department)).IsEnabled, ct);
+ _moduleUnit.CommitChanges();
+ await InvalidateSettingCacheAsync(departmentId, DepartmentSettingTypes.ModuleSettings);
+ return result;
+ }
+ catch { _moduleUnit.DiscardChanges(); throw; }
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ChecklistRecurrence.cs b/Core/Resgrid.Services/ChecklistRecurrence.cs
new file mode 100644
index 000000000..d7bc015ad
--- /dev/null
+++ b/Core/Resgrid.Services/ChecklistRecurrence.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Resgrid.Model.Checklists;
+
+namespace Resgrid.Services
+{
+ /// Local calendar recurrence with UTC period identities. No content or protected-read dependency.
+ public static class ChecklistRecurrence
+ {
+ public static int[] ParseTimes(string value)
+ {
+ var parts = (value ?? "").Split(',', StringSplitOptions.TrimEntries);
+ if (parts.Length < 1 || parts.Length > 8) throw new ChecklistException(400, "ScheduleValidation");
+ var times = new List();
+ foreach (var part in parts)
+ {
+ if (!TimeOnly.TryParseExact(part, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var time)) throw new ChecklistException(400, "ScheduleValidation");
+ times.Add(time.Hour * 60 + time.Minute);
+ }
+ return times.Distinct().OrderBy(t => t).ToArray();
+ }
+ public static TimeZoneInfo Zone(string id)
+ {
+ try { return TimeZoneInfo.FindSystemTimeZoneById(id ?? ""); }
+ catch (Exception ex) when (ex is TimeZoneNotFoundException || ex is InvalidTimeZoneException || ex is ArgumentException) { throw new ChecklistException(400, "ScheduleValidation"); }
+ }
+ public static DateTime Utc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc);
+ public static DateTime ToUtc(DateTime local, TimeZoneInfo zone)
+ {
+ local = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
+ // A skipped clock time moves to the first valid minute; a repeated time occurs once, at its earlier instant.
+ while (zone.IsInvalidTime(local)) local = local.AddMinutes(1);
+ return zone.IsAmbiguousTime(local) ? new DateTimeOffset(local, zone.GetAmbiguousTimeOffsets(local).Max()).UtcDateTime : TimeZoneInfo.ConvertTimeToUtc(local, zone);
+ }
+ public static IReadOnlyList Expand(ChecklistSchedule schedule, DateTime fromUtc, DateTime untilUtc, IEnumerable workshiftStarts = null)
+ {
+ fromUtc = Utc(fromUtc); untilUtc = Utc(untilUtc);
+ if (untilUtc <= fromUtc) return Array.Empty();
+ if (untilUtc - fromUtc > TimeSpan.FromDays(8)) throw new ArgumentOutOfRangeException(nameof(untilUtc));
+ var zone = Zone(schedule.TimeZoneId); var result = new SortedSet();
+ var frequency = (ChecklistScheduleFrequency)schedule.Frequency;
+ if (frequency == ChecklistScheduleFrequency.OnDemand) return Array.Empty();
+ if (frequency == ChecklistScheduleFrequency.PerShift && !string.IsNullOrEmpty(schedule.WorkshiftId))
+ {
+ foreach (var start in workshiftStarts ?? Array.Empty())
+ {
+ var utc = Utc(start); var date = TimeZoneInfo.ConvertTimeFromUtc(utc, zone).Date;
+ if (utc >= fromUtc && utc < untilUtc && date >= schedule.StartDate.Date && (!schedule.EndDate.HasValue || date <= schedule.EndDate.Value.Date)) result.Add(utc);
+ }
+ return result.ToArray();
+ }
+ var minutes = schedule.ClockMinutes.Split(',').Select(v => int.Parse(v, CultureInfo.InvariantCulture)).ToArray();
+ var last = TimeZoneInfo.ConvertTimeFromUtc(untilUtc, zone).Date.AddDays(1);
+ for (var date = TimeZoneInfo.ConvertTimeFromUtc(fromUtc, zone).Date.AddDays(-1); date <= last; date = date.AddDays(1))
+ {
+ if (date < schedule.StartDate.Date || schedule.EndDate.HasValue && date > schedule.EndDate.Value.Date) continue;
+ var day = Math.Min(schedule.DayOfMonth, DateTime.DaysInMonth(date.Year, date.Month));
+ var monthDelta = (date.Month - schedule.MonthOfYear + 12) % 12;
+ var matches = frequency switch
+ {
+ ChecklistScheduleFrequency.Daily or ChecklistScheduleFrequency.PerShift => true,
+ ChecklistScheduleFrequency.Weekly => (schedule.Weekdays & (1 << (int)date.DayOfWeek)) != 0,
+ ChecklistScheduleFrequency.Monthly => date.Day == day,
+ ChecklistScheduleFrequency.Quarterly => date.Day == day && monthDelta % 3 == 0,
+ ChecklistScheduleFrequency.SemiAnnual => date.Day == day && monthDelta % 6 == 0,
+ ChecklistScheduleFrequency.Annual => date.Day == day && monthDelta == 0,
+ _ => throw new ChecklistException(400, "ScheduleValidation")
+ };
+ if (!matches) continue;
+ foreach (var minute in minutes)
+ {
+ var utc = ToUtc(date.AddMinutes(minute), zone);
+ if (utc >= fromUtc && utc < untilUtc) result.Add(utc);
+ }
+ }
+ return result.ToArray();
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ChecklistReminderService.cs b/Core/Resgrid.Services/ChecklistReminderService.cs
new file mode 100644
index 000000000..d5d115649
--- /dev/null
+++ b/Core/Resgrid.Services/ChecklistReminderService.cs
@@ -0,0 +1,230 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.Linq;
+using System.Resources;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Localization;
+using Resgrid.Model;
+using Resgrid.Model.Checklists;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public sealed partial class ChecklistReminderService : IChecklistReminderService
+ {
+ private readonly IChecklistRepository _store;
+ private readonly IChecklistReminderRepository _reminders;
+ private readonly IUnitOfWork _uow;
+ private readonly IReadinessAccessService _access;
+ private readonly IDepartmentsService _departments;
+ private readonly IUnitsService _units;
+ private readonly IDepartmentGroupsService _groups;
+ private readonly IAuthorizationService _authorization;
+ private readonly IChecklistAuthorizationService _checklistAuthorization;
+ private readonly ICommunicationService _communication;
+ private readonly IUserProfileService _profiles;
+ private readonly IDepartmentSettingsService _settings;
+ private readonly IChecklistAssignmentService _assignments;
+ private readonly IChecklistAssetSource _assets;
+ private static readonly ResourceManager Strings = new ResourceManager("Resgrid.Localization.Areas.User.Checklists.Checklists", typeof(SupportedLocales).Assembly);
+ public ChecklistReminderService(IChecklistRepository store, IChecklistReminderRepository reminders, IUnitOfWork uow, IReadinessAccessService access,
+ IDepartmentsService departments, IUnitsService units, IDepartmentGroupsService groups, IAuthorizationService authorization,
+ IChecklistAuthorizationService checklistAuthorization, ICommunicationService communication, IUserProfileService profiles, IDepartmentSettingsService settings, IChecklistAssignmentService assignments = null, IChecklistAssetSource assets = null)
+ { _store = store; _reminders = reminders; _uow = uow; _access = access; _departments = departments; _units = units; _groups = groups; _authorization = authorization; _checklistAuthorization = checklistAuthorization; _communication = communication; _profiles = profiles; _settings = settings; _assignments = assignments; _assets = assets; }
+
+ private async Task TransactionAsync(int departmentId, Func> action, CancellationToken ct)
+ {
+ if (_uow.Transaction != null) throw new InvalidOperationException("Checklist reminders own their transaction.");
+ try { await _uow.CreateOrGetConnectionAsync(ct); await _store.LockDepartmentAsync(departmentId, ct); var result = await action(); _uow.CommitChanges(); return result; }
+ catch { _uow.DiscardChanges(); throw; }
+ }
+ private async Task SettingsAsync(int departmentId, CancellationToken ct) => (await _store.ListAsync(departmentId, take: 1, ct: ct)).SingleOrDefault();
+ public async Task SweepAsync(DateTime utcNow, CancellationToken ct = default)
+ {
+ var origin = ChecklistRecurrence.Utc(utcNow); var elapsed = Stopwatch.StartNew(); var result = new ChecklistReminderSweepResult(); var after = 0;
+ while (true)
+ {
+ var departments = await _reminders.DepartmentsAsync(after, ct); if (departments.Count == 0) break;
+ foreach (var departmentId in departments)
+ {
+ ct.ThrowIfCancellationRequested(); after = departmentId;
+ try
+ {
+ await GenerateAsync(departmentId, origin.Add(elapsed.Elapsed), ct);
+ await GenerateTimedAsync(departmentId, origin.Add(elapsed.Elapsed), ct);
+ // Bound work per tenant. Remaining notices are retained for the next sweep.
+ for (var batch = 0; batch < 100; batch++)
+ {
+ var now = origin.Add(elapsed.Elapsed);
+ var claimed = await TransactionAsync(departmentId, async () => await _reminders.ClaimAsync(departmentId, now, (await SettingsAsync(departmentId, ct))?.DigestMode ?? true, ct), ct);
+ if (claimed.Count == 0) break;
+ await DeliverAsync(departmentId, claimed, now, result, ct);
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch { result.Errors++; } // Never persist provider exceptions or sensitive content.
+ }
+ }
+ return result;
+ }
+ private async Task GenerateAsync(int departmentId, DateTime now, CancellationToken ct)
+ {
+ if (!await _access.CanUseChecklistsAsync(departmentId)) return;
+ for (var skip = 0; ; skip += 50)
+ {
+ var count = await TransactionAsync(departmentId, async () =>
+ {
+ var settings = await SettingsAsync(departmentId, ct);
+ if (settings?.RemindersEnabled != true) return 0;
+ var rows = await _store.DueOccurrencesAsync(departmentId, now, skip, ct);
+ foreach (var row in rows)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (!await EligibleOccurrenceAsync(row, settings, ct)) continue;
+ foreach (var kind in new[] { ChecklistReminderKind.Due, ChecklistReminderKind.Missed, ChecklistReminderKind.Escalation })
+ {
+ if (!EligibleKind(row, settings, kind, now)) continue;
+ foreach (var user in await RecipientsAsync(row, kind))
+ await _reminders.EnqueueAsync(new ChecklistReminder { DepartmentId = departmentId, OccurrenceId = row.Id, RecipientUserId = user, Kind = (int)kind, CreatedOnUtc = now, NextAttemptUtc = now }, ct);
+ }
+ }
+ return rows.Count;
+ }, ct);
+ if (count < 50) break;
+ }
+ }
+ private async Task EligibleOccurrenceAsync(ChecklistOccurrence row, DepartmentChecklistSettings settings, CancellationToken ct)
+ {
+ if (settings?.RemindersEnabled != true || !settings.RemindersActiveFromUtc.HasValue || row == null || row.DepartmentId != settings.DepartmentId || row.ScheduleId == null
+ || !row.PeriodStartUtc.HasValue || !row.WindowEndUtc.HasValue || row.PeriodStartUtc < settings.RemindersActiveFromUtc || row.State != 0 && row.State != 1 && row.State != 3 && row.State != 4) return false;
+ var schedule = await _store.GetAsync(row.DepartmentId, row.ScheduleId, ct);
+ if (schedule?.IsActive != true || schedule.IsSuspended || row.PeriodStartUtc < schedule.ActiveFromUtc) return false;
+ var definition = await _store.GetAsync(row.DepartmentId, row.ParentId, ct);
+ return definition != null && !definition.Retired && !definition.DeletedOn.HasValue;
+ }
+ private static bool EligibleKind(ChecklistOccurrence row, DepartmentChecklistSettings settings, ChecklistReminderKind kind, DateTime now) => kind switch
+ {
+ ChecklistReminderKind.Due => row.State != 4 && settings.NotifyBeforeMinutes > 0 && row.WindowEndUtc > now && row.WindowEndUtc <= now.AddMinutes(settings.NotifyBeforeMinutes),
+ ChecklistReminderKind.Missed => row.State == 4 && settings.NotifyMissed,
+ ChecklistReminderKind.Escalation => row.State == 4 && settings.EscalateAfterMinutes.HasValue && row.WindowEndUtc <= now.AddMinutes(-settings.EscalateAfterMinutes.Value),
+ ChecklistReminderKind.ShiftStart => settings.NotifyAtShiftStart && !settings.FixedDigestMinute.HasValue && row.PeriodStartUtc <= now,
+ ChecklistReminderKind.FixedTime => settings.FixedDigestMinute.HasValue && row.PeriodStartUtc <= now,
+ _ => false
+ };
+ private async Task> RecipientsAsync(ChecklistOccurrence row, ChecklistReminderKind kind)
+ {
+ if (row.TargetType == (int)ChecklistTargetType.InventoryAsset)
+ {
+ if (_assets == null || !await _assets.IsAvailableAsync(row.DepartmentId)) return new HashSet();
+ var asset = await _assets.RoutingAsync(row.DepartmentId, row.TargetId);
+ if (asset?.DepartmentId != row.DepartmentId || asset.Id != row.TargetId) return new HashSet();
+ var routing = new ChecklistOccurrence { DepartmentId = row.DepartmentId, ScheduleId = row.ScheduleId, TargetType = asset.UnitId.HasValue ? 1 : asset.UserId != null ? 3 : asset.GroupId.HasValue ? 2 : 0,
+ TargetId = asset.UnitId?.ToString(CultureInfo.InvariantCulture) ?? asset.UserId ?? asset.GroupId?.ToString(CultureInfo.InvariantCulture) ?? row.DepartmentId.ToString(CultureInfo.InvariantCulture) };
+ var recipients = await RecipientsAsync(routing, kind);
+ foreach (var user in recipients.ToArray())
+ if (!await _assets.CanReceiveReminderAsync(row.DepartmentId, user, row.TargetId)) recipients.Remove(user);
+ return recipients;
+ }
+ var department = await _departments.GetDepartmentByIdAsync(row.DepartmentId, true);
+ if (department == null) return new HashSet();
+ var members = (await _departments.GetAllMembersForDepartmentUnlimitedAsync(row.DepartmentId, true))
+ .Where(m => m.DepartmentId == row.DepartmentId && !m.IsDeleted && m.IsDisabled != true && !string.IsNullOrWhiteSpace(m.UserId)).ToList();
+ var allowed = members.Select(m => m.UserId).ToHashSet(StringComparer.Ordinal);
+ var admins = members.Where(m => m.IsAdmin == true || m.UserId == department.ManagingUserId).Select(m => m.UserId).ToHashSet(StringComparer.Ordinal);
+ var users = new HashSet(StringComparer.Ordinal);
+ Unit unit = null; DepartmentGroup group = null;
+ if (row.TargetType == (int)ChecklistTargetType.Unit && int.TryParse(row.TargetId, out var unitId))
+ {
+ unit = await _units.GetUnitByIdAsync(unitId); if (unit?.DepartmentId != row.DepartmentId) return users;
+ users.UnionWith((await _units.GetActiveRolesForUnitAsync(unitId)).Where(r => r.DepartmentId == row.DepartmentId && r.UnitId == unitId && allowed.Contains(r.UserId)).Select(r => r.UserId));
+ if (users.Count == 0 && unit.StationGroupId.HasValue)
+ {
+ group = await _groups.GetGroupByIdAsync(unit.StationGroupId.Value, true);
+ if (group?.DepartmentId == row.DepartmentId) users.UnionWith((await _groups.GetAllMembersForGroupAsync(group.DepartmentGroupId)).Where(m => m.DepartmentId == row.DepartmentId).Select(m => m.UserId));
+ }
+ users.IntersectWith(allowed); if (users.Count == 0) users.UnionWith(admins);
+ }
+ else if (row.TargetType == (int)ChecklistTargetType.Group && int.TryParse(row.TargetId, out var groupId))
+ {
+ group = await _groups.GetGroupByIdAsync(groupId, true); if (group?.DepartmentId != row.DepartmentId) return users;
+ users.UnionWith((await _groups.GetAllMembersForGroupAsync(groupId)).Where(m => m.DepartmentId == row.DepartmentId).Select(m => m.UserId));
+ }
+ else if (row.TargetType == (int)ChecklistTargetType.Personnel) { if (!allowed.Contains(row.TargetId)) return users; users.Add(row.TargetId); }
+ else if (row.TargetType == (int)ChecklistTargetType.Department && row.TargetId == row.DepartmentId.ToString(CultureInfo.InvariantCulture)) users.UnionWith(admins);
+ else return users;
+ var schedule = row.ScheduleId == null ? null : await _store.GetAsync(row.DepartmentId, row.ScheduleId);
+ if (schedule?.AssignmentType > 0) users = _assignments == null ? new HashSet() : await _assignments.MembersAsync(row.DepartmentId, schedule.AssignmentType, schedule.AssignmentId);
+ if (kind == ChecklistReminderKind.Escalation) users = admins;
+ users.IntersectWith(allowed);
+ foreach (var user in users.ToArray())
+ {
+ if (unit != null && !await _authorization.CanUserViewUnitAsync(user, unit.UnitId)
+ || row.TargetType == (int)ChecklistTargetType.Personnel && user != row.TargetId && !await _authorization.CanUserViewPersonAsync(user, row.TargetId, row.DepartmentId)
+ || row.TargetType == (int)ChecklistTargetType.Group && (await _groups.GetGroupForUserAsync(user, row.DepartmentId))?.DepartmentGroupId != group.DepartmentGroupId
+ && !await _checklistAuthorization.CanManageAsync(new ChecklistActor { DepartmentId = row.DepartmentId, UserId = user })) users.Remove(user);
+ }
+ return users;
+ }
+ private async Task DeliverAsync(int departmentId, List claimed, DateTime now, ChecklistReminderSweepResult result, CancellationToken ct)
+ {
+ var eligible = new List(); var elapsed = Stopwatch.StartNew();
+ try
+ {
+ var settings = await SettingsAsync(departmentId, ct); var enabled = await _access.CanUseChecklistsAsync(departmentId);
+ foreach (var notice in claimed)
+ {
+ ct.ThrowIfCancellationRequested();
+ var row = enabled ? await _store.GetAsync(departmentId, notice.OccurrenceId, ct) : null;
+ if (enabled && await EligibleOccurrenceAsync(row, settings, ct) && EligibleKind(row, settings, (ChecklistReminderKind)notice.Kind, now)
+ && await TimedRecipientAsync(row, notice, settings, now, ct) && (await RecipientsAsync(row, (ChecklistReminderKind)notice.Kind)).Contains(notice.RecipientUserId)) eligible.Add(notice);
+ else { await FinishAsync(notice, ChecklistReminderStatus.Suppressed, now, ct); result.Suppressed++; }
+ }
+ if (eligible.Count == 0) return;
+ var user = eligible[0].RecipientUserId;
+ var profile = await _profiles.GetProfileByUserIdAsync(user, false);
+ var culture = Culture(profile?.Language);
+ var department = await _departments.GetDepartmentByIdAsync(departmentId, true);
+ var number = await _settings.GetTextToCallNumberForDepartmentAsync(departmentId);
+ ct.ThrowIfCancellationRequested();
+ // Profile/routing lookups can be slow. Revalidate after those calls, including edits
+ // to timing controls, and leave room in the lease before beginning provider handoff.
+ settings = await SettingsAsync(departmentId, ct);
+ var enabledNow = await _access.CanUseChecklistsAsync(departmentId);
+ foreach (var notice in eligible.ToArray())
+ {
+ var row = enabledNow ? await _store.GetAsync(departmentId, notice.OccurrenceId, ct) : null;
+ if (!enabledNow || !await EligibleOccurrenceAsync(row, settings, ct) || !EligibleKind(row, settings, (ChecklistReminderKind)notice.Kind, now.Add(elapsed.Elapsed))
+ || !await TimedRecipientAsync(row, notice, settings, now.Add(elapsed.Elapsed), ct) || !(await RecipientsAsync(row, (ChecklistReminderKind)notice.Kind)).Contains(user))
+ { await FinishAsync(notice, ChecklistReminderStatus.Suppressed, now.Add(elapsed.Elapsed), ct); eligible.Remove(notice); result.Suppressed++; }
+ }
+ if (eligible.Count == 0) return;
+ if (elapsed.Elapsed >= TimeSpan.FromMinutes(25)) throw new InvalidOperationException("Checklist reminder lease is expiring.");
+ // Generic copy and an authenticated link only; this worker never resolves protected content.
+ var handedOff = enabledNow && await _communication.SendNotificationAsync(user, departmentId,
+ Strings.GetString("ReminderMessage", culture) + " " + (Config.SystemBehaviorConfig.ResgridBaseUrl ?? "").TrimEnd('/') + "/User/Checklists/Due",
+ number, department, Strings.GetString("ReminderTitle", culture), profile);
+ foreach (var notice in eligible) { await FinishAsync(notice, handedOff ? ChecklistReminderStatus.HandedOff : ChecklistReminderStatus.Suppressed, now.Add(elapsed.Elapsed), ct); if (handedOff) result.HandedOff++; else result.Suppressed++; }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch
+ {
+ // Leases also recover process crashes. Provider handoff is at-least-once; the shared
+ // communication service does not expose delivery receipts or idempotency keys.
+ foreach (var notice in claimed) { try { await FinishAsync(notice, ChecklistReminderStatus.Pending, now.Add(elapsed.Elapsed), ct); } catch { /* A completed or superseded claim must never be reset. */ } }
+ result.Errors++;
+ }
+ }
+ private Task FinishAsync(ChecklistReminder notice, ChecklistReminderStatus status, DateTime now, CancellationToken ct) => TransactionAsync(notice.DepartmentId, async () => { await _reminders.FinishAsync(notice, status, now, ct); return true; }, ct);
+ private static CultureInfo Culture(string language)
+ {
+ try { var culture = CultureInfo.GetCultureInfo(language ?? "en"); if (SupportedLocales.GetSupportedCultures().Contains(culture.TwoLetterISOLanguageName)) return culture; }
+ catch (CultureNotFoundException) { }
+ return CultureInfo.GetCultureInfo("en");
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ChecklistReminderSettings.cs b/Core/Resgrid.Services/ChecklistReminderSettings.cs
new file mode 100644
index 000000000..0684aadd6
--- /dev/null
+++ b/Core/Resgrid.Services/ChecklistReminderSettings.cs
@@ -0,0 +1,48 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Checklists;
+
+namespace Resgrid.Services
+{
+ public partial class ChecklistsService
+ {
+ public async Task ReminderSettingsAsync(ChecklistActor actor)
+ {
+ await RequireWriteAsync(actor, true);
+ var row = (await _store.ListAsync(actor.DepartmentId, take: 1)).SingleOrDefault();
+ return row == null ? new ChecklistReminderSettingsInput() : new ChecklistReminderSettingsInput { Revision = row.Revision, Enabled = row.RemindersEnabled, NotifyAtShiftStart = row.NotifyAtShiftStart, FixedDigestTime = row.FixedDigestMinute.HasValue ? System.TimeOnly.MinValue.AddMinutes(row.FixedDigestMinute.Value).ToString("HH:mm", System.Globalization.CultureInfo.InvariantCulture) : null,
+ NotifyBeforeMinutes = row.NotifyBeforeMinutes, NotifyMissed = row.NotifyMissed, DigestMode = row.DigestMode, EscalateAfterMinutes = row.EscalateAfterMinutes };
+ }
+ public async Task SaveReminderSettingsAsync(ChecklistActor actor, ChecklistReminderSettingsInput input)
+ {
+ await RequireWriteAsync(actor, true);
+ if (input == null || input.NotifyBeforeMinutes < 0 || input.NotifyBeforeMinutes > 10080 || input.EscalateAfterMinutes < 1 || input.EscalateAfterMinutes > 43200)
+ throw new ChecklistException(400, "ReminderValidation");
+ int? fixedMinute = null;
+ if (!string.IsNullOrWhiteSpace(input.FixedDigestTime))
+ {
+ if (!System.TimeOnly.TryParseExact(input.FixedDigestTime, "HH:mm", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var time))
+ throw new ChecklistException(400, "ReminderTimeValidation");
+ fixedMinute = time.Hour * 60 + time.Minute;
+ }
+ await TransactionAsync(actor, async events =>
+ {
+ var row = (await _store.ListAsync(actor.DepartmentId, take: 1)).SingleOrDefault();
+ var insert = row == null;
+ if (!insert) { Revision(row, input.Revision); row.Revision++; }
+ else { if (input.Revision != 0) throw new ChecklistException(409, "This item changed. Reload before saving."); row = New(actor); }
+ if (input.Enabled && !row.RemindersEnabled) row.RemindersActiveFromUtc = _clock.GetUtcNow().UtcDateTime;
+ if (insert || row.RemindersEnabled != input.Enabled || row.NotifyAtShiftStart != input.NotifyAtShiftStart || row.FixedDigestMinute != fixedMinute) row.DigestActiveFromUtc = _clock.GetUtcNow().UtcDateTime;
+ row.RemindersEnabled = input.Enabled; row.NotifyBeforeMinutes = input.NotifyBeforeMinutes; row.NotifyMissed = input.NotifyMissed;
+ row.DigestMode = input.DigestMode; row.EscalateAfterMinutes = input.EscalateAfterMinutes;
+ row.NotifyAtShiftStart = input.NotifyAtShiftStart;
+ row.FixedDigestMinute = fixedMinute;
+ row.LastDigestSweepUtc = _clock.GetUtcNow().UtcDateTime;
+ // Content is left intact; only non-sensitive typed notification controls are edited.
+ await PersistAsync(actor, row, insert); await AuditAsync(actor, row, AuditLogTypes.ChecklistReminderSettingsUpdated);
+ return true;
+ });
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ChecklistTimedReminders.cs b/Core/Resgrid.Services/ChecklistTimedReminders.cs
new file mode 100644
index 000000000..148cc01f1
--- /dev/null
+++ b/Core/Resgrid.Services/ChecklistTimedReminders.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Checklists;
+
+namespace Resgrid.Services
+{
+ public sealed partial class ChecklistReminderService
+ {
+ private async Task TimedRecipientAsync(ChecklistOccurrence row, ChecklistReminder notice, DepartmentChecklistSettings settings, DateTime now, CancellationToken ct)
+ {
+ if (notice.Kind < (int)ChecklistReminderKind.ShiftStart) return true;
+ var floor = new[] { settings.DigestActiveFromUtc ?? settings.RemindersActiveFromUtc.Value, settings.RemindersActiveFromUtc.Value, now.AddDays(-1) }.Max();
+ if (notice.CreatedOnUtc < floor) return false;
+ if (notice.Kind == (int)ChecklistReminderKind.FixedTime)
+ {
+ if (!notice.PeriodKey.StartsWith("fixed:", StringComparison.Ordinal) || !DateTime.TryParseExact(notice.PeriodKey.Substring(6), "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day)) return false;
+ var department = await _departments.GetDepartmentByIdAsync(row.DepartmentId, true);
+ var at = ChecklistRecurrence.ToUtc(day.AddMinutes(settings.FixedDigestMinute.Value), ChecklistRecurrence.Zone(department?.TimeZone ?? "UTC"));
+ return at > floor && at <= now;
+ }
+ var parts = notice.PeriodKey.Split(':');
+ if (parts.Length != 3 || parts[0] != "shift" || !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var unitId)) return false;
+ if (!(await _store.ShiftStartsAsync(row.DepartmentId, floor, now, ct)).Any(s => s.WorkshiftDayId == parts[1] && s.UnitId == unitId)) return false;
+ var unit = await _units.GetUnitByIdAsync(unitId); if (unit?.DepartmentId != row.DepartmentId) return false;
+ var crew = await ShiftCrewAsync(unit);
+ return crew.Contains(notice.RecipientUserId) && await AppliesToShiftAsync(row, unit, crew);
+ }
+ private async Task> ShiftCrewAsync(Unit unit)
+ {
+ var crew = (await _units.GetActiveRolesForUnitAsync(unit.UnitId)).Where(r => r.DepartmentId == unit.DepartmentId && r.UnitId == unit.UnitId && !string.IsNullOrWhiteSpace(r.UserId)).Select(r => r.UserId).ToHashSet(StringComparer.Ordinal);
+ if (crew.Count == 0 && unit.StationGroupId.HasValue && (await _groups.GetGroupByIdAsync(unit.StationGroupId.Value, true))?.DepartmentId == unit.DepartmentId)
+ crew.UnionWith((await _groups.GetAllMembersForGroupAsync(unit.StationGroupId.Value)).Where(m => m.DepartmentId == unit.DepartmentId).Select(m => m.UserId));
+ return crew;
+ }
+ private async Task GenerateTimedAsync(int departmentId, DateTime now, CancellationToken ct)
+ {
+ if (!await _access.CanUseChecklistsAsync(departmentId)) return;
+ await TransactionAsync(departmentId, async () =>
+ {
+ var settings = await SettingsAsync(departmentId, ct);
+ if (settings?.RemindersEnabled != true || !settings.RemindersActiveFromUtc.HasValue) return false;
+ // Reconcile the last day on every sweep. Unique recipient/trigger identities suppress replays
+ // while current asset moves and staffing changes can reach their new recipients.
+ var from = new[] { settings.DigestActiveFromUtc ?? settings.RemindersActiveFromUtc.Value, settings.RemindersActiveFromUtc.Value, now.AddDays(-1) }.Max();
+ if (from >= now) return false;
+ var triggers = new List<(string Key, ChecklistReminderKind Kind, int? UnitId, DateTime At)>();
+ if (settings.FixedDigestMinute.HasValue)
+ {
+ var department = await _departments.GetDepartmentByIdAsync(departmentId, true);
+ var zone = ChecklistRecurrence.Zone(department?.TimeZone ?? "UTC");
+ var last = TimeZoneInfo.ConvertTimeFromUtc(ChecklistRecurrence.Utc(now), zone).Date;
+ for (var day = TimeZoneInfo.ConvertTimeFromUtc(ChecklistRecurrence.Utc(from), zone).Date; day <= last; day = day.AddDays(1))
+ {
+ var at = ChecklistRecurrence.ToUtc(day.AddMinutes(settings.FixedDigestMinute.Value), zone);
+ if (at > from && at <= now) triggers.Add(("fixed:" + day.ToString("yyyyMMdd", CultureInfo.InvariantCulture), ChecklistReminderKind.FixedTime, null, at));
+ }
+ }
+ else if (settings.NotifyAtShiftStart)
+ {
+ foreach (var shift in await _store.ShiftStartsAsync(departmentId, from, now, ct))
+ triggers.Add(("shift:" + shift.WorkshiftDayId + ":" + shift.UnitId.ToString(CultureInfo.InvariantCulture), ChecklistReminderKind.ShiftStart, shift.UnitId, ChecklistRecurrence.Utc(shift.StartUtc)));
+ }
+ if (triggers.Count > 0)
+ for (var skip = 0; ; skip += 50)
+ {
+ var occurrences = await _store.DueOccurrencesAsync(departmentId, now, skip, ct);
+ foreach (var row in occurrences)
+ {
+ if (!await EligibleOccurrenceAsync(row, settings, ct)) continue;
+ foreach (var trigger in triggers.Where(t => row.PeriodStartUtc <= t.At))
+ {
+ HashSet crew = null;
+ if (trigger.UnitId.HasValue)
+ {
+ var unit = await _units.GetUnitByIdAsync(trigger.UnitId.Value); if (unit?.DepartmentId != departmentId) continue;
+ crew = await ShiftCrewAsync(unit);
+ if (!await AppliesToShiftAsync(row, unit, crew)) continue;
+ }
+ var recipients = await RecipientsAsync(row, trigger.Kind); if (crew != null) recipients.IntersectWith(crew);
+ foreach (var user in recipients)
+ await _reminders.EnqueueAsync(new ChecklistReminder { DepartmentId = departmentId, OccurrenceId = row.Id, RecipientUserId = user, Kind = (int)trigger.Kind, PeriodKey = trigger.Key, CreatedOnUtc = now, NextAttemptUtc = now }, ct);
+ }
+ }
+ if (occurrences.Count < 50) break;
+ }
+ await _store.AdvanceDigestSweepAsync(departmentId, now, ct); return true;
+ }, ct);
+ }
+ private async Task AppliesToShiftAsync(ChecklistOccurrence row, Unit unit, HashSet crew)
+ {
+ if (row.TargetType == 0) return true;
+ if (row.TargetType == 1) return row.TargetId == unit.UnitId.ToString(CultureInfo.InvariantCulture);
+ if (row.TargetType == 2) return row.TargetId == unit.StationGroupId?.ToString(CultureInfo.InvariantCulture);
+ if (row.TargetType == 3) return crew.Contains(row.TargetId);
+ if (row.TargetType != 5 || _assets == null || !await _assets.IsAvailableAsync(row.DepartmentId)) return false;
+ var asset = await _assets.RoutingAsync(row.DepartmentId, row.TargetId);
+ return asset?.DepartmentId == row.DepartmentId && asset.Id == row.TargetId && (asset.UnitId == unit.UnitId || asset.UserId != null && crew.Contains(asset.UserId) || asset.GroupId.HasValue && asset.GroupId == unit.StationGroupId);
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ChecklistsScheduling.cs b/Core/Resgrid.Services/ChecklistsScheduling.cs
new file mode 100644
index 000000000..cab802e30
--- /dev/null
+++ b/Core/Resgrid.Services/ChecklistsScheduling.cs
@@ -0,0 +1,241 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Resgrid.Model;
+using Resgrid.Model.Checklists;
+
+namespace Resgrid.Services
+{
+ public partial class ChecklistsService
+ {
+ public async Task> SchedulesAsync(ChecklistActor actor, string definitionId, int page = 0)
+ {
+ Id(definitionId); await _authorization.RequireMemberAsync(actor);
+ if (!await CanManageAsync(actor)) throw new ChecklistException(403, "SchedulePermission");
+ if (page < 0 || page > 10000) throw new ChecklistException(400, "ScheduleValidation");
+ var views = new List();
+ foreach (var row in await _store.ListAsync(actor.DepartmentId, definitionId, page * 50, 50))
+ {
+ await RevealAsync(actor, row); views.Add(new ChecklistScheduleView { Schedule = row, Content = Decode(row.Content) });
+ }
+ return views;
+ }
+ public async Task GetScheduleAsync(ChecklistActor actor, string id)
+ {
+ Id(id); await _authorization.RequireMemberAsync(actor);
+ if (!await CanManageAsync(actor)) throw new ChecklistException(403, "SchedulePermission");
+ var row = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, id));
+ return new ChecklistScheduleView { Schedule = row, Content = Decode(row.Content) };
+ }
+ public async Task SaveScheduleAsync(ChecklistActor actor, ChecklistScheduleInput input)
+ {
+ await RequireWriteAsync(actor, true);
+ if (input == null) throw new ChecklistException(400, "ScheduleValidation");
+ Id(input.DefinitionId); if (input.Id != null) Id(input.Id);
+ if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 200 || input.Notes?.Length > 4000 || !Enum.IsDefined(input.Frequency) || input.Frequency == ChecklistScheduleFrequency.OnDemand
+ || input.WindowMinutes < 1 || input.WindowMinutes > 10080 || input.DayOfMonth < 1 || input.DayOfMonth > 31 || input.MonthOfYear < 1 || input.MonthOfYear > 12 || input.Weekdays < 1 || input.Weekdays > 127
+ || input.StartDate.Year < 2000 || input.StartDate.Year > 2200 || input.EndDate?.Date < input.StartDate.Date || input.EndDate?.Year > 2200)
+ throw new ChecklistException(400, "ScheduleValidation");
+ var zone = ChecklistRecurrence.Zone(input.TimeZoneId); var times = ChecklistRecurrence.ParseTimes(input.TimesOfDay);
+ var workshift = string.IsNullOrWhiteSpace(input.WorkshiftId) ? null : input.WorkshiftId;
+ if (workshift != null) { Id(workshift); if (input.Frequency != ChecklistScheduleFrequency.PerShift || !await _store.WorkshiftExistsAsync(actor.DepartmentId, workshift)) throw new ChecklistException(400, "ScheduleValidation"); }
+ return await TransactionAsync(actor, async events =>
+ {
+ if (input.AssignmentType != 0 || !string.IsNullOrEmpty(input.AssignmentId))
+ { if (_assignments == null) throw new ChecklistException(400, "AssignmentUnavailable"); await _assignments.ValidateAsync(actor.DepartmentId, input.AssignmentType, input.AssignmentId); }
+ var definition = await _store.GetAsync(actor.DepartmentId, input.DefinitionId);
+ if (definition?.CurrentVersionId == null || definition.DeletedOn.HasValue || definition.Retired && input.IsActive) throw new ChecklistException(409, "ScheduleRequiresPublished");
+ var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, definition.CurrentVersionId));
+ var target = await _authorization.TargetAsync(actor, Decode(version.Content).TargetType, input.TargetId);
+ var now = _clock.GetUtcNow().UtcDateTime; var insert = input.Id == null;
+ var row = insert ? New(actor, definition.Id) : await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, input.Id));
+ if (row.ParentId != definition.Id) throw new ChecklistException(404, "ScheduleUnavailable");
+ if (!insert) { Revision(row, input.Revision); await _store.CancelUnstartedOccurrencesAsync(actor.DepartmentId, row.Id, input.IsActive ? now : null, now); }
+ if (insert || !row.IsActive || row.IsSuspended) row.ActiveFromUtc = now;
+ if (!insert) row.Revision++;
+ row.VersionId = version.Id; row.TargetType = (int)target.Type; row.TargetId = target.Id; row.TargetGroupId = target.GroupId;
+ row.AssignmentType = input.AssignmentType; row.AssignmentId = input.AssignmentId;
+ row.Frequency = (int)input.Frequency; row.TimeZoneId = zone.Id; row.StartDate = input.StartDate.Date; row.EndDate = input.EndDate?.Date;
+ row.ClockMinutes = string.Join(",", times); row.Weekdays = input.Weekdays; row.DayOfMonth = input.DayOfMonth; row.MonthOfYear = input.MonthOfYear;
+ row.WindowMinutes = input.WindowMinutes; row.WorkshiftId = workshift; row.IsActive = input.IsActive; row.IsSuspended = false; row.GeneratedThroughUtc = now; row.LastSweepUtc = now;
+ row.Content = JsonConvert.SerializeObject(new ChecklistScheduleContent { Name = input.Name.Trim(), Notes = input.Notes });
+ await PersistAsync(actor, row, insert); await AuditAsync(actor, row, insert ? AuditLogTypes.ChecklistScheduleAdded : AuditLogTypes.ChecklistScheduleUpdated);
+ await ScheduleEventAsync(row, null, WorkflowTriggerEventType.ChecklistScheduleChanged, events, now);
+ return row.Id;
+ }, accessFence: true);
+ }
+ public async Task> DueAsync(ChecklistActor actor, int page = 0)
+ {
+ await _authorization.RequireMemberAsync(actor); if (page < 0 || page > 10000) throw new ChecklistException(400, "ScheduleValidation");
+ var enabled = await _access.CanUseChecklistsAsync(actor.DepartmentId); var manage = await CanManageAsync(actor); var views = new List();
+ foreach (var occurrence in await _store.DueOccurrencesAsync(actor.DepartmentId, _clock.GetUtcNow().UtcDateTime.AddDays(7), page * 50))
+ {
+ ChecklistTarget target;
+ try { target = await _authorization.TargetAsync(actor, (ChecklistTargetType)occurrence.TargetType, occurrence.TargetId); }
+ catch (ChecklistException ex) when (ex.StatusCode == 404) { continue; }
+ var schedule = await _store.GetAsync(actor.DepartmentId, occurrence.ScheduleId);
+ if (schedule == null) continue;
+ var assigned = await CanPerformScheduleAsync(actor, schedule);
+ // The response omits stored Content/envelopes. Names come through the attended read boundary.
+ await RevealAsync(actor, schedule);
+ var metadata = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(occurrence)); metadata.Content = null;
+ views.Add(new ChecklistOccurrenceView { Occurrence = metadata, Name = Decode(schedule.Content).Name, Target = target,
+ CanStart = enabled && assigned && schedule.IsActive && !schedule.IsSuspended && occurrence.PeriodStartUtc <= _clock.GetUtcNow().UtcDateTime, CanSkip = enabled && manage && (occurrence.State == 3 || occurrence.State == 4) && await _store.GetAsync(actor.DepartmentId, occurrence.CompletionId) == null });
+ }
+ return views;
+ }
+ public async Task StartOccurrenceAsync(ChecklistActor actor, string occurrenceId)
+ {
+ Id(occurrenceId); await RequireWriteAsync(actor);
+ return await TransactionAsync(actor, async events =>
+ {
+ var row = await _store.GetAsync(actor.DepartmentId, occurrenceId);
+ if (row?.ScheduleId == null || row.State == (int)ChecklistOccurrenceState.Skipped || row.State == (int)ChecklistOccurrenceState.Cancelled || row.PeriodStartUtc > _clock.GetUtcNow().UtcDateTime) throw new ChecklistException(409, "OccurrenceUnavailable");
+ var existing = await _store.GetAsync(actor.DepartmentId, row.CompletionId);
+ var schedule = await _store.GetAsync(actor.DepartmentId, row.ScheduleId);
+ if (!await CanPerformScheduleAsync(actor, schedule)) throw new ChecklistException(403, "AssignmentPermission");
+ if (existing != null) { await RequireRunReadAsync(actor, existing); return existing.Id; }
+ var definition = await _store.GetAsync(actor.DepartmentId, row.ParentId);
+ if (schedule?.IsActive != true || schedule.IsSuspended || definition == null || definition.Retired || definition.DeletedOn.HasValue) throw new ChecklistException(409, "OccurrenceUnavailable");
+ var target = await _authorization.TargetAsync(actor, (ChecklistTargetType)row.TargetType, row.TargetId);
+ var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.VersionId));
+ if (version.ParentId != row.ParentId) throw new ChecklistException(409, "OccurrenceUnavailable");
+ // Never copy the schedule/version envelope to another AAD identity. Capture the target with this actor's grant.
+ row.Content = JsonConvert.SerializeObject(target); row.State = row.MissedOn.HasValue ? (int)ChecklistOccurrenceState.Missed : (int)ChecklistOccurrenceState.InProgress; row.Revision++;
+ await PersistAsync(actor, row, false);
+ var completion = New