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(actor, row.ParentId); completion.Id = row.CompletionId; + completion.OccurrenceId = row.Id; completion.VersionId = row.VersionId; completion.TargetType = row.TargetType; completion.TargetId = row.TargetId; completion.TargetGroupId = target.GroupId; + completion.Content = "{}"; completion.Passed = false; await PersistAsync(actor, completion, true); await AuditAsync(actor, completion, AuditLogTypes.ChecklistCompletionStarted); + return completion.Id; + }); + } + public async Task SkipOccurrenceAsync(ChecklistActor actor, string occurrenceId, int revision, string reason) + { + Id(occurrenceId); await RequireWriteAsync(actor, true); + if (string.IsNullOrWhiteSpace(reason) || reason.Length > 4000) throw new ChecklistException(400, "SkipReasonRequired"); + await TransactionAsync(actor, async events => + { + var row = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, occurrenceId)); Revision(row, revision); + if (row.ScheduleId == null || row.State != 3 && row.State != 4 || await _store.GetAsync(actor.DepartmentId, row.CompletionId) != null) throw new ChecklistException(409, "OccurrenceUnavailable"); + await _authorization.TargetAsync(actor, (ChecklistTargetType)row.TargetType, row.TargetId); + row.State = (int)ChecklistOccurrenceState.Skipped; row.Revision++; row.Content = JsonConvert.SerializeObject(new { Reason = reason.Trim() }); + await PersistAsync(actor, row, false); await AuditAsync(actor, row, AuditLogTypes.ChecklistOccurrenceSkipped); + var schedule = await _store.GetAsync(actor.DepartmentId, row.ScheduleId); + await ScheduleEventAsync(schedule, row, WorkflowTriggerEventType.ChecklistOccurrenceSkipped, events, _clock.GetUtcNow().UtcDateTime); return true; + }); + } + private async Task ScheduleEventAsync(ChecklistSchedule schedule, ChecklistOccurrence occurrence, WorkflowTriggerEventType trigger, List events, DateTime now) + { + var entry = await _outbox.EnqueueAsync(schedule.DepartmentId, "Checklists", new DomainEventEnvelope + { + EventName = trigger.ToString(), AggregateType = occurrence == null ? "ChecklistSchedule" : "ChecklistOccurrence", AggregateId = occurrence?.Id ?? schedule.Id, + AggregateVersion = occurrence?.Revision ?? schedule.Revision, Trigger = trigger, OccurredOn = now, + Payload = new { ScheduleId = schedule.Id, OccurrenceId = occurrence?.Id, CompletionId = occurrence?.CompletionId, DefinitionId = schedule.ParentId, VersionId = occurrence?.VersionId ?? schedule.VersionId, + TargetType = occurrence?.TargetType ?? schedule.TargetType, TargetId = occurrence?.TargetId ?? schedule.TargetId, State = occurrence?.State, IsActive = schedule.IsActive, Revision = occurrence?.Revision ?? schedule.Revision, + PeriodStartUtc = occurrence?.PeriodStartUtc, WindowEndUtc = occurrence?.WindowEndUtc } + }); events.Add(entry.DomainEventOutboxId); + } + private async Task WorkerWriteAsync(T row, bool insert, CancellationToken ct) where T : ChecklistRow + { + var result = await _write.Value.PrepareRecordsEntityWriteAsync(row.DepartmentId, row, (T)null, row.Id, ChecklistTables.Fields(), () => row.IsProtected = true, null, null, true, ct); + if (result?.Success != true || result.IsProtected && HasPlaintextFields(row)) throw new InvalidOperationException("Checklist scheduling requires an available ADP catalog and write broker."); + await _store.WriteAsync(row, insert, ct); + } + private async Task WorkerAuditAsync(ChecklistRow row, AuditLogTypes type, DateTime now, CancellationToken ct) + { + var audit = await _audit.InsertAsync(new AuditLog { DepartmentId = row.DepartmentId, ObjectDepartmentId = row.DepartmentId, ObjectId = row.Id, UserId = "system", LogType = (int)type, Message = type.ToString(), LoggedOn = now, Successful = true, ServerName = Environment.MachineName }, ct); + audit.Data = JsonConvert.SerializeObject(new { row.Id, row.Revision }); + var result = await _write.Value.PrepareRecordsEntityWriteAsync(row.DepartmentId, audit, null, audit.AuditLogId.ToString(CultureInfo.InvariantCulture), ReadinessHistoryFields.Audits, null, null, null, true, ct); + if (result?.Success != true || result.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(audit.Data)) throw new InvalidOperationException("Checklist scheduling audit protection is unavailable."); + await _audit.UpdateAsync(audit, ct); + } + public async Task SweepSchedulesAsync(DateTime utcNow, CancellationToken ct = default) + { + var result = new ChecklistScheduleSweepResult(); var now = ChecklistRecurrence.Utc(utcNow); var afterDepartment = 0; + while (true) + { + var departments = await _store.SchedulingDepartmentsAsync(afterDepartment, ct); if (departments.Count == 0) break; + foreach (var department in departments) + { + var afterSchedule = ""; + while (true) + { + var schedules = await _store.ActiveSchedulesAsync(department, afterSchedule, ct); if (schedules.Count == 0) break; + foreach (var candidate in schedules) + { + ct.ThrowIfCancellationRequested(); var events = new List(); var generated = 0; var missed = 0; + try + { + await _uow.CreateOrGetConnectionAsync(ct); await _store.LockDepartmentAsync(department, ct); + var schedule = await _store.GetAsync(department, candidate.Id, ct); + if (!schedule.IsActive) { _uow.CommitChanges(); continue; } + var enabled = await _access.CanUseChecklistsAsync(department) && (schedule.TargetType != (int)ChecklistTargetType.InventoryAsset || _assets != null && await _assets.IsAvailableAsync(department)); + if (!enabled) + { + schedule.IsSuspended = true; schedule.UpdatedOn = now; await WorkerWriteAsync(schedule, false, ct); _uow.CommitChanges(); continue; + } + var definition = await _store.GetAsync(department, schedule.ParentId, ct); + if (definition == null || definition.Retired || definition.DeletedOn.HasValue) + { + schedule.IsActive = false; schedule.Revision++; await _store.CancelUnstartedOccurrencesAsync(department, schedule.Id, null, now, ct); + await WorkerWriteAsync(schedule, false, ct); await WorkerAuditAsync(schedule, AuditLogTypes.ChecklistScheduleUpdated, now, ct); await ScheduleEventAsync(schedule, null, WorkflowTriggerEventType.ChecklistScheduleChanged, events, now); + } + else + { + if (schedule.IsSuspended) + { + await _store.CancelUnstartedOccurrencesAsync(department, schedule.Id, null, now, ct); schedule.IsSuspended = false; schedule.Revision++; schedule.ActiveFromUtc = now; schedule.GeneratedThroughUtc = now; schedule.LastSweepUtc = now; + } + var from = ChecklistRecurrence.Utc(schedule.GeneratedThroughUtc); + // Workshift days can be added/moved inside an already generated horizon. Reconcile their future periods each sweep. + if (schedule.WorkshiftId != null) from = new[] { now, schedule.LastSweepUtc }.Min(); + var until = new[] { now.AddDays(7), from.AddDays(schedule.WorkshiftId == null ? 7 : 8) }.Min(); + var starts = schedule.WorkshiftId == null ? null : await _store.WorkshiftStartsAsync(department, schedule.WorkshiftId, from, until, ct); + var periods = ChecklistRecurrence.Expand(schedule, from, until, starts); + var existing = await _store.OccurrencesInWindowAsync(department, schedule.Id, from, until, ct); + foreach (var removed in existing.Where(o => schedule.WorkshiftId != null && o.State == 3 && o.PeriodStartUtc >= now && !periods.Contains(o.PeriodStartUtc.Value))) + { removed.State = 6; removed.Revision++; removed.UpdatedOn = now; await WorkerWriteAsync(removed, false, ct); } + foreach (var period in periods) + { + if (existing.Any(o => o.PeriodStartUtc == period && o.TargetType == schedule.TargetType && o.TargetId == schedule.TargetId && new[] { 0, 1, 2, 5 }.Contains(o.State))) continue; + var prior = existing.FirstOrDefault(o => o.ScheduleRevision == schedule.Revision && o.PeriodStartUtc == period); + if (prior != null) + { + if (prior.State == 6 && period >= now) { prior.State = 3; prior.Revision++; prior.UpdatedOn = now; await WorkerWriteAsync(prior, false, ct); generated++; } + continue; + } + var occurrence = new ChecklistOccurrence { DepartmentId = department, ParentId = schedule.ParentId, VersionId = schedule.VersionId, CompletionId = Guid.NewGuid().ToString(), ScheduleId = schedule.Id, ScheduleRevision = schedule.Revision, + TargetType = schedule.TargetType, TargetId = schedule.TargetId, State = (int)ChecklistOccurrenceState.Scheduled, PeriodStartUtc = period, WindowEndUtc = period.AddMinutes(schedule.WindowMinutes), CreatedBy = "system", CreatedOn = now, UpdatedOn = now }; + await WorkerWriteAsync(occurrence, true, ct); generated++; + } + if (until > schedule.GeneratedThroughUtc) schedule.GeneratedThroughUtc = until; + schedule.LastSweepUtc = new[] { now, until }.Min(); + foreach (var occurrence in await _store.ScheduledOccurrencesAsync(department, schedule.Id, schedule.ActiveFromUtc, now, ct)) + { + if (!occurrence.WindowEndUtc.HasValue || occurrence.WindowEndUtc >= now || occurrence.WindowEndUtc < schedule.ActiveFromUtc) continue; + occurrence.State = (int)ChecklistOccurrenceState.Missed; occurrence.MissedOn = now; occurrence.Revision++; occurrence.UpdatedOn = now; + await WorkerWriteAsync(occurrence, false, ct); await WorkerAuditAsync(occurrence, AuditLogTypes.ChecklistOccurrenceMissed, now, ct); + await ScheduleEventAsync(schedule, occurrence, WorkflowTriggerEventType.ChecklistMissed, events, now); missed++; + } + await WorkerWriteAsync(schedule, false, ct); + } + _uow.CommitChanges(); result.Generated += generated; result.Missed += missed; + await _outbox.DispatchAfterCommitAsync(events, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { _uow.DiscardChanges(); throw; } + catch { _uow.DiscardChanges(); result.Errors++; Resgrid.Framework.Logging.LogError($"Checklist scheduling failed for department {department}, schedule {candidate.Id}."); } + } + afterSchedule = schedules.Last().Id; + } + } + afterDepartment = departments.Last(); + } + return result; + } + } +} diff --git a/Core/Resgrid.Services/ChecklistsService.cs b/Core/Resgrid.Services/ChecklistsService.cs index b73f06f18..843feb8bc 100644 --- a/Core/Resgrid.Services/ChecklistsService.cs +++ b/Core/Resgrid.Services/ChecklistsService.cs @@ -17,7 +17,7 @@ namespace Resgrid.Services { - public class ChecklistsService : IChecklistsService + public partial class ChecklistsService : IChecklistsService { private readonly IChecklistRepository _store; private readonly IChecklistAuthorizationService _authorization; @@ -28,10 +28,13 @@ public class ChecklistsService : IChecklistsService private readonly Lazy _read; private readonly Lazy _write; private readonly IRecordAttachmentScanner _scanner; + private readonly TimeProvider _clock; + private readonly IChecklistAssignmentService _assignments; + private readonly IChecklistAssetSource _assets; public ChecklistsService(IChecklistRepository store, IChecklistAuthorizationService authorization, IReadinessAccessService access, IUnitOfWork uow, IAuditLogsRepository audit, IDomainEventOutboxService outbox, Lazy read, - Lazy write, IRecordAttachmentScanner scanner) - { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; } + Lazy write, IRecordAttachmentScanner scanner, TimeProvider clock = null, IChecklistAssignmentService assignments = null, IChecklistAssetSource assets = null) + { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; _clock = clock ?? TimeProvider.System; _assignments = assignments; _assets = assets; } public Task CanManageAsync(ChecklistActor actor) => _authorization.CanManageAsync(actor); private async Task RequireWriteAsync(ChecklistActor actor, bool manage = false) @@ -49,31 +52,44 @@ private async Task RequireWriteAsync(ChecklistActor actor, bool manage = false) private async Task RevealAsync(ChecklistActor actor, T row) where T : ChecklistRow { if (row == null) throw new ChecklistException(404, "Checklist item is unavailable."); + var hadPlaintext = HasPlaintextFields(row); var result = await _read.Value.ResolveRecordsEntitiesForReadAsync(actor.DepartmentId, new[] { (row, row.Id) }, ChecklistTables.Fields(), actor.GrantToken, actor.UserId); - if (result == null || result.RedactedFields.Count > 0) throw new ChecklistException(403, "Unlock protected data to use this checklist."); + if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && hadPlaintext) throw new ChecklistException(403, "Unlock protected data to use this checklist."); return row; } + private static bool HasPlaintextFields(T row) where T : ChecklistRow => ChecklistTables.Fields().Values + .Select(f => f.Get(row)).Any(v => !string.IsNullOrEmpty(v) && !ProtectedDataEnvelope.HasEnvelopePrefix(v) && v != ProtectedDataEnvelope.RedactionValue); private async Task SealAsync(ChecklistActor actor, T row) where T : ChecklistRow { var result = await _write.Value.PrepareRecordsEntityWriteAsync(actor.DepartmentId, row, (T)null, row.Id, ChecklistTables.Fields(), () => row.IsProtected = true, actor.GrantToken, actor.UserId, false); - if (result?.Success != true) throw new ChecklistException(403, "Protected data could not be saved. Unlock it and retry."); + if (result?.Success != true || result.IsProtected && HasPlaintextFields(row)) throw new ChecklistException(403, "Protected data could not be saved. Unlock it and retry."); } private async Task PersistAsync(ChecklistActor actor, T row, bool insert) where T : ChecklistRow { row.UpdatedOn = DateTime.UtcNow; await SealAsync(actor, row); await _store.WriteAsync(row, insert); } private async Task AuditAsync(ChecklistActor actor, ChecklistRow row, AuditLogTypes type, object detail = null) { - await _audit.InsertAsync(new AuditLog { DepartmentId = actor.DepartmentId, ObjectDepartmentId = actor.DepartmentId, UserId = actor.UserId, + var data = JsonConvert.SerializeObject(new { row.Id, row.Revision, Detail = detail }); + // Allocate the identity inside this command's existing transaction without staging content in SQL. + var audit = await _audit.InsertAsync(new AuditLog { DepartmentId = actor.DepartmentId, ObjectDepartmentId = actor.DepartmentId, UserId = actor.UserId, ObjectId = row.Id, LogType = (int)type, LoggedOn = DateTime.UtcNow, Successful = true, Message = type.ToString(), - Data = JsonConvert.SerializeObject(new { row.Id, row.Revision, Detail = detail }), ServerName = Environment.MachineName }, CancellationToken.None); + ServerName = Environment.MachineName }, CancellationToken.None); + audit.Data = data; + var protectedWrite = await _write.Value.PrepareRecordsEntityWriteAsync(actor.DepartmentId, audit, null, + audit.AuditLogId.ToString(System.Globalization.CultureInfo.InvariantCulture), ReadinessHistoryFields.Audits, null, actor.GrantToken, actor.UserId, false); + if (protectedWrite?.Success != true || protectedWrite.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(audit.Data)) + throw new ChecklistException(403, "Protected data could not be saved. Unlock it and retry."); + await _audit.UpdateAsync(audit, CancellationToken.None); } - private async Task TransactionAsync(ChecklistActor actor, Func, Task> action) + private async Task TransactionAsync(ChecklistActor actor, Func, Task> action, bool accessFence = false) { if (_uow.Transaction != null) throw new InvalidOperationException("Checklist commands own their transaction."); var events = new List(); T result; try { await _uow.CreateOrGetConnectionAsync(CancellationToken.None); + if (accessFence) await _store.LockAccessFenceAsync(); await _store.LockDepartmentAsync(actor.DepartmentId); + await RequireWriteAsync(actor); result = await action(events); _uow.CommitChanges(); } @@ -81,30 +97,30 @@ private async Task TransactionAsync(ChecklistActor actor, Func, await _outbox.DispatchAfterCommitAsync(events); return result; } - public async Task> ListAsync(ChecklistActor actor, int page = 0) + public async Task> ListAsync(ChecklistActor actor, int page = 0, bool includeNext = false) { await _authorization.RequireMemberAsync(actor); if (page < 0 || page > 10000) throw new ChecklistException(400, "Invalid page."); var manage = await CanManageAsync(actor); var result = new List(); - foreach (var row in await _store.ListAsync(actor.DepartmentId, skip: page * 50, take: 50)) - { - if (row.DeletedOn.HasValue || !manage && row.CurrentVersionId == null) continue; + foreach (var row in await ReadPageAsync(actor.DepartmentId, null, page, includeNext, + row => Task.FromResult(!row.DeletedOn.HasValue && (manage || row.CurrentVersionId != null)))) result.Add(await DefinitionViewAsync(actor, row, manage)); - } return result; } private async Task DefinitionViewAsync(ChecklistActor actor, ChecklistDefinition row, bool manage) { if (row == null || row.DeletedOn.HasValue) throw new ChecklistException(404, "Checklist definition is unavailable."); + var published = row.CurrentVersionId == null ? null : await RevealAsync(actor, + await _store.GetAsync(actor.DepartmentId, row.CurrentVersionId)); string content; if (manage) content = (await RevealAsync(actor, row)).Content; else { if (row.CurrentVersionId == null) throw new ChecklistException(404, "Checklist is not published."); - content = (await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.CurrentVersionId))).Content; + content = published.Content; row.Content = null; } - return new ChecklistDefinitionView { Definition = row, Form = Decode(content), PublishedForm = row.CurrentVersionId == null ? null : Decode((await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.CurrentVersionId))).Content) }; + return new ChecklistDefinitionView { Definition = row, Form = Decode(content), PublishedForm = published == null ? null : Decode(published.Content) }; } public async Task GetDefinitionAsync(ChecklistActor actor, string id) { @@ -114,6 +130,7 @@ public async Task GetDefinitionAsync(ChecklistActor act public async Task SaveDefinitionAsync(ChecklistActor actor, string id, int revision, ChecklistForm form) { await RequireWriteAsync(actor, true); Valid(ChecklistValidation.Validate(form)); if (id != null) Id(id); + if (form.TargetType == ChecklistTargetType.InventoryAsset && !await AssetTargetsAvailableAsync(actor)) throw new ChecklistException(400, "AssetUnavailable"); foreach (var section in form.Sections) { section.Id = Guid.Parse(section.Id).ToString("D"); @@ -185,7 +202,7 @@ public async Task StartAsync(ChecklistActor actor, string definitionId, occurrence.Content = JsonConvert.SerializeObject(target); await PersistAsync(actor, occurrence, true); var completion = New(actor, definition.Id); completion.Id = completionId; completion.TargetGroupId = target.GroupId; completion.VersionId = version.Id; completion.OccurrenceId = occurrence.Id; completion.TargetId = target.Id; completion.TargetType = (int)target.Type; - completion.Content = "{}"; await PersistAsync(actor, completion, true); await AuditAsync(actor, completion, AuditLogTypes.ChecklistCompletionStarted); return completion.Id; + completion.Content = "{}"; completion.Passed = false; await PersistAsync(actor, completion, true); await AuditAsync(actor, completion, AuditLogTypes.ChecklistCompletionStarted); return completion.Id; }); } private async Task RequireRunReadAsync(ChecklistActor actor, ChecklistCompletion row) @@ -203,22 +220,52 @@ private async Task RunViewAsync(ChecklistActor actor, Checklis foreach (var item in await _store.ListAsync(actor.DepartmentId, row.Id, take: 250)) input.Answers.Add(Decode((await RevealAsync(actor, item)).Content)); var files = await _store.ListAsync(actor.DepartmentId, row.Id, take: 500); foreach (var file in files) await RevealAsync(actor, file); - return new ChecklistRunView { VersionNumber = version.Version, Completion = row, Form = Decode(version.Content), Target = Decode(occurrence.Content), Input = input, Files = files }; + return new ChecklistRunView { WitnessAttestation = WitnessAttestation(row.Content), VersionNumber = version.Version, Completion = row, Form = Decode(version.Content), Target = Decode(occurrence.Content), Input = input, Files = files }; + } + private static string WitnessAttestation(string content) + { + try + { + var value = JObject.Parse(content ?? "{}")["WitnessAttestation"]; + return value?.Type == JTokenType.String ? value.Value() : null; + } + catch (JsonException) { return null; } } public async Task GetRunAsync(ChecklistActor actor, string id) { Id(id); await _authorization.RequireMemberAsync(actor); return await RunViewAsync(actor, await _store.GetAsync(actor.DepartmentId, id)); } - public async Task> HistoryAsync(ChecklistActor actor, string definitionId, int page = 0) + public async Task> HistoryAsync(ChecklistActor actor, string definitionId, int page = 0, bool includeNext = false) { Id(definitionId); await _authorization.RequireMemberAsync(actor); if (page < 0 || page > 10000) throw new ChecklistException(400, "Invalid page."); var result = new List(); - foreach (var row in await _store.ListAsync(actor.DepartmentId, definitionId, page * 50, 50)) - if (await _authorization.CanReadAsync(actor, row)) + foreach (var row in await ReadPageAsync(actor.DepartmentId, definitionId, page, includeNext, + row => _authorization.CanReadAsync(actor, row))) + { + await RevealAsync(actor, row); + var occurrence = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.OccurrenceId)); + row.Content = null; result.Add(new ChecklistHistoryEntry { Completion = row, TargetName = Decode(occurrence.Content).Name }); + } + return result; + } + // Page authorized rows, so deleted drafts or another member's runs cannot hide the next page. + private async Task> ReadPageAsync(int departmentId, string parentId, int page, bool includeNext, + Func> visible) where T : ChecklistRow + { + var result = new List(); + var remaining = page * 50; + var take = includeNext ? 51 : 50; + for (var skip = 0; ; skip += 100) + { + var batch = await _store.ListAsync(departmentId, parentId, skip, 100); + foreach (var row in batch) { - var occurrence = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.OccurrenceId)); - row.Content = null; result.Add(new ChecklistHistoryEntry { Completion = row, TargetName = Decode(occurrence.Content).Name }); + if (!await visible(row)) continue; + if (remaining > 0) { remaining--; continue; } + result.Add(row); + if (result.Count == take) return result; } - return result; + if (batch.Count < 100) return result; + } } private static string SubmissionHash(ChecklistRunInput input, IEnumerable files) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { @@ -233,6 +280,7 @@ public async Task SaveRunAsync(ChecklistActor actor, string id, ChecklistRu { var row = await _store.GetAsync(actor.DepartmentId, id); if (row == null || row.CreatedBy != actor.UserId) throw new ChecklistException(404, "Only the author can edit this run."); + await RequireRunAssignmentAsync(actor, row); var view = await RunViewAsync(actor, row); var evaluation = ChecklistValidation.Evaluate(view.Form, input, Evidence(view.Files), submit); Valid(evaluation.Errors); foreach (var answer in input.Answers) answer.ItemId = Guid.Parse(answer.ItemId).ToString("D"); @@ -260,13 +308,14 @@ public async Task SaveRunAsync(ChecklistActor actor, string id, ChecklistRu if (row.State == (int)ChecklistRunState.Submitted) await EventAsync(actor, row, WorkflowTriggerEventType.ChecklistCompleted, null, events); } await PersistAsync(actor, row, false); await AuditAsync(actor, row, submit ? AuditLogTypes.ChecklistCompletionSubmitted : AuditLogTypes.ChecklistProgressSaved, - new { row.State, row.Score, row.Passed, FailedItemIds = evaluation.FailedItemIds }); return row.Revision; + new { row.State }); return row.Revision; }); } private async Task AdvanceOccurrenceAsync(ChecklistActor actor, ChecklistCompletion completion) { var occurrence = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, completion.OccurrenceId)); - occurrence.State = completion.State; occurrence.Revision++; await PersistAsync(actor, occurrence, false); + occurrence.State = completion.State == (int)ChecklistRunState.Submitted || !occurrence.MissedOn.HasValue ? completion.State : (int)ChecklistOccurrenceState.Missed; + occurrence.Revision++; await PersistAsync(actor, occurrence, false); } private async Task EventAsync(ChecklistActor actor, ChecklistCompletion row, WorkflowTriggerEventType trigger, string itemId, List events) { @@ -293,9 +342,11 @@ await TransactionAsync(actor, async events => if (row.State != (int)ChecklistRunState.AwaitingWitness || !view.Form.RequiresIndependentWitness) throw new ChecklistException(409, "This run is not awaiting a witness."); var content = JObject.Parse(row.Content); content["WitnessAttestation"] = attestation; row.Content = content.ToString(Formatting.None); row.WitnessUserId = actor.UserId; row.WitnessedOn = DateTime.UtcNow; row.State = (int)ChecklistRunState.Submitted; row.Revision++; - await AdvanceOccurrenceAsync(actor, row); await PersistAsync(actor, row, false); + await AdvanceOccurrenceAsync(actor, row); + await EventAsync(actor, row, WorkflowTriggerEventType.ChecklistCompleted, null, events); + await PersistAsync(actor, row, false); await AuditAsync(actor, row, AuditLogTypes.ChecklistWitnessAttested, new { row.SubmissionHash }); - await EventAsync(actor, row, WorkflowTriggerEventType.ChecklistCompleted, null, events); return true; + return true; }); } public async Task AddFileAsync(ChecklistActor actor, string id, string itemId, string fileName, string contentType, byte[] data) @@ -321,6 +372,7 @@ await TransactionAsync(actor, async events => { var row = await _store.GetAsync(actor.DepartmentId, id); if (row?.CreatedBy != actor.UserId || row.State != (int)ChecklistRunState.InProgress) throw new ChecklistException(409, "Submitted evidence is immutable."); + await RequireRunAssignmentAsync(actor, row); var view = await RunViewAsync(actor, row); if (!view.Form.Sections.SelectMany(s => s.Items).Any(i => i.Id == itemId)) throw new ChecklistException(404, "The evidence item does not belong to this version."); var checksum = Convert.ToHexString(SHA256.HashData(clean.Data)); @@ -329,7 +381,7 @@ await TransactionAsync(actor, async events => var file = New(actor, row.Id); file.ItemId = itemId; file.ContentType = clean.ContentType; file.Size = clean.Data.Length; file.Sha256 = checksum; file.Data = clean.Data; file.ScanState = (int)scan.State; file.Content = clean.FileName; var protection = await _write.Value.PrepareRecordsBinaryWriteAsync(actor.DepartmentId, "checklistcompletionfiles.data", file.Id, file.Data, bytes => file.Data = bytes, () => file.IsProtected = true, actor.GrantToken, actor.UserId, false); - if (protection?.Success != true) throw new ChecklistException(403, "Protected evidence could not be saved."); + if (protection?.Success != true || protection.IsProtected && !ProtectedReadService.IsBinaryEnveloped(file.Data)) throw new ChecklistException(403, "Protected evidence could not be saved."); await PersistAsync(actor, file, true); row.Revision++; await PersistAsync(actor, row, false); await AuditAsync(actor, file, AuditLogTypes.ChecklistFileAdded, new { CompletionId = row.Id, itemId, file.Size, file.ScanState }); return true; }); @@ -356,6 +408,7 @@ await TransactionAsync(actor, async events => if (file == null) throw new ChecklistException(404, "Evidence is unavailable."); var row = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, file.ParentId)); if (row.CreatedBy != actor.UserId || row.State != (int)ChecklistRunState.InProgress) throw new ChecklistException(409, "Submitted evidence is immutable."); + await RequireRunAssignmentAsync(actor, row); await _store.DeleteFileAsync(actor.DepartmentId, id); row.Revision++; await PersistAsync(actor, row, false); await AuditAsync(actor, file, AuditLogTypes.ChecklistFileRemoved, new { CompletionId = row.Id }); return true; }); diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index dea4d38d4..fb28229d1 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -678,7 +678,7 @@ public async Task SendNotificationAsync(string userId, int departmentId, s if (profile == null || profile.SendNotificationPush) { var spm = new StandardPushMessage(); - spm.Title = "Notification"; + spm.Title = title; spm.SubTitle = $"{title} {message}"; spm.DepartmentCode = department?.Code; spm.DepartmentId = departmentId; diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index a6bb56788..09b0abe2c 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -364,6 +364,7 @@ await _userSessionService.RevokeAllAsync(userIdToDelete, userIdToDelete, Logging.LogInfo($"DeleteService::Executing pending department deletion for DepartmentId {item.SourceId}, requested by UserId {item.QueuedByUserId} on {item.QueuedOn:u}, scheduled for {item.ToBeCompletedOn:u}"); var result = await _deleteRepository.DeleteDepartmentAndUsersAsync(departmentId); + if (!result) throw new InvalidOperationException("Department deletion is not supported by the configured database provider."); // Write the execution audit record only after the delete succeeds: retried // attempts must not each leave an "executed" row. The row is written directly diff --git a/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs b/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs index bc415a6c7..0aaf3ffc8 100644 --- a/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs +++ b/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs @@ -555,7 +555,9 @@ private async Task DecryptRowColumnAsync(Func> var plaintext = _cryptoService.DecryptText(dek, companion, context.DepartmentId, spec.FieldId, row.RowKey); - setValues[spec.ColumnName] = decimal.Parse(plaintext, CultureInfo.InvariantCulture); + setValues[spec.ColumnName] = spec.CompanionIsBoolean + ? (object)(plaintext == "1" || plaintext != "0" && bool.Parse(plaintext)) + : decimal.Parse(plaintext, CultureInfo.InvariantCulture); setValues[spec.CompanionColumn] = null; return ColumnOutcome.Changed; } diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index bfc882415..38e4d0515 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -13,7 +13,7 @@ namespace Resgrid.Services { - public class DepartmentSettingsService : IDepartmentSettingsService + public partial class DepartmentSettingsService : IDepartmentSettingsService { private static string DisableAutoAvailableCacheKey = "DSetAutoAvailable_{0}"; private static string StripeCustomerCacheKey = "DSetStripeCus_{0}"; @@ -47,16 +47,26 @@ public class DepartmentSettingsService : IDepartmentSettingsService private readonly ICacheProvider _cacheProvider; public DepartmentSettingsService(IDepartmentSettingsRepository departmentSettingsRepository, IAddressService addressService, - IGeoLocationProvider geoLocationProvider, ICacheProvider cacheProvider) + IGeoLocationProvider geoLocationProvider, ICacheProvider cacheProvider, + Resgrid.Model.Repositories.Queries.IUnitOfWork moduleUnit = null, IFeatureFlagMutationObserver moduleObserver = null, Lazy moduleFlags = null) { _departmentSettingsRepository = departmentSettingsRepository; _addressService = addressService; _geoLocationProvider = geoLocationProvider; _cacheProvider = cacheProvider; + _moduleUnit = moduleUnit; _moduleObserver = moduleObserver; _moduleFlags = moduleFlags; } public async Task SaveOrUpdateSettingAsync(int departmentId, string setting, DepartmentSettingTypes type, CancellationToken cancellationToken = default(CancellationToken)) { + if (type == DepartmentSettingTypes.ModuleSettings && _moduleObserver != null) + return await MutateModuleAsync(departmentId, async () => + { + var row = await _departmentSettingsRepository.GetDepartmentSettingByIdTypeAsync(departmentId, type) + ?? new DepartmentSetting { DepartmentId = departmentId, SettingType = (int)type }; + row.Setting = setting; + return await _departmentSettingsRepository.SaveOrUpdateAsync(row, cancellationToken); + }, cancellationToken); var savedSetting = await GetSettingByDepartmentIdType(departmentId, type); DepartmentSetting result; @@ -87,6 +97,12 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting public async Task DeleteSettingAsync(int departmentId, DepartmentSettingTypes type, CancellationToken cancellationToken = default(CancellationToken)) { + if (type == DepartmentSettingTypes.ModuleSettings && _moduleObserver != null) + return await MutateModuleAsync(departmentId, async () => + { + var row = await _departmentSettingsRepository.GetDepartmentSettingByIdTypeAsync(departmentId, type); + return row != null && await _departmentSettingsRepository.DeleteAsync(row, cancellationToken); + }, cancellationToken); var savedSetting = await GetSettingByDepartmentIdType(departmentId, type); if (savedSetting != null) diff --git a/Core/Resgrid.Services/FeatureFlagMutations.cs b/Core/Resgrid.Services/FeatureFlagMutations.cs new file mode 100644 index 000000000..fe2b64355 --- /dev/null +++ b/Core/Resgrid.Services/FeatureFlagMutations.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public partial class FeatureToggleService + { + private readonly IUnitOfWork _mutationUnit; + private readonly IFeatureFlagMutationObserver _mutationObserver; + private bool _mutationActive; + private bool _invalidateFlags; + private readonly HashSet _invalidateOverrides = new(); + private readonly List _committedAudits = new(); + private async Task MutateFlagAsync(Func> action, CancellationToken ct) + { + if (_mutationObserver == null || _mutationUnit == null) return await action(); + if (_mutationActive || _mutationUnit.Transaction != null) throw new InvalidOperationException("Feature flag commands own their transaction."); + try + { + _mutationActive = true; await _mutationUnit.CreateOrGetConnectionAsync(ct); + await _mutationObserver.BeforeChangeAsync(null, ct); + // Observe expired/scheduled policy before replacing it, even between worker sweeps. + await _mutationObserver.AfterChangeAsync(async (key, department) => (await EvaluateFreshAsync(key, department)).IsEnabled, ct); + var result = await action(); + await _mutationObserver.AfterChangeAsync(async (key, department) => (await EvaluateFreshAsync(key, department)).IsEnabled, ct); + _mutationUnit.CommitChanges(); _mutationActive = false; + if (_invalidateFlags) await InvalidateFlagCacheAsync(); + foreach (var department in _invalidateOverrides) await InvalidateDepartmentOverrideCacheAsync(department); + foreach (var audit in _committedAudits) audit(); + return result; + } + catch { _mutationUnit.DiscardChanges(); throw; } + finally { _mutationActive = false; _invalidateFlags = false; _invalidateOverrides.Clear(); _committedAudits.Clear(); } + } + } +} diff --git a/Core/Resgrid.Services/FeatureToggleService.cs b/Core/Resgrid.Services/FeatureToggleService.cs index 1936e3eba..b5033dc8f 100644 --- a/Core/Resgrid.Services/FeatureToggleService.cs +++ b/Core/Resgrid.Services/FeatureToggleService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; @@ -22,7 +22,7 @@ namespace Resgrid.Services /// evaluation hot path is served entirely from cache; management writes invalidate the relevant /// cache entries and emit audit events. /// - public class FeatureToggleService : IFeatureToggleService + public partial class FeatureToggleService : IFeatureToggleService { private const string AllFlagsCacheKey = "FeatureFlags_All"; private const string AllRulesCacheKey = "FeatureFlagTargetingRules_All"; @@ -45,7 +45,7 @@ public class FeatureToggleService : IFeatureToggleService public FeatureToggleService(IFeatureFlagRepository featureFlagRepository, IFeatureFlagOverrideRepository featureFlagOverrideRepository, IFeatureFlagTargetingRuleRepository featureFlagTargetingRuleRepository, IFeatureFlagPrerequisiteRepository featureFlagPrerequisiteRepository, IFeatureFlagUsageRepository featureFlagUsageRepository, ICacheProvider cacheProvider, IEventAggregator eventAggregator, - ISubscriptionsService subscriptionsService, IDepartmentsService departmentsService) + ISubscriptionsService subscriptionsService, IDepartmentsService departmentsService, Resgrid.Model.Repositories.Queries.IUnitOfWork mutationUnit = null, IFeatureFlagMutationObserver mutationObserver = null) { _featureFlagRepository = featureFlagRepository; _featureFlagOverrideRepository = featureFlagOverrideRepository; @@ -55,7 +55,7 @@ public FeatureToggleService(IFeatureFlagRepository featureFlagRepository, IFeatu _cacheProvider = cacheProvider; _eventAggregator = eventAggregator; _subscriptionsService = subscriptionsService; - _departmentsService = departmentsService; + _departmentsService = departmentsService; _mutationUnit = mutationUnit; _mutationObserver = mutationObserver; } private static TimeSpan CacheLength => TimeSpan.FromMinutes(FeatureFlagsConfig.CacheDurationMinutes <= 0 ? 60 : FeatureFlagsConfig.CacheDurationMinutes); @@ -80,6 +80,8 @@ public async Task IsEnabledAsync(string key, int departmentId, bool defaul } } + public Task EvaluateFreshAsync(string key, int departmentId) => EvaluateInternalAsync(key, departmentId, null, false, new HashSet(), true); + public async Task EvaluateAsync(string key, int departmentId, IDictionary context = null) { return await EvaluateInternalAsync(key, departmentId, context, false, new HashSet()); @@ -113,13 +115,13 @@ public async Task GetDepartmentFlagStateHashAsync(int departmentId) return ComputeStateHash(evaluations); } - private async Task EvaluateInternalAsync(string key, int departmentId, IDictionary context, bool defaultValue, HashSet visited) + private async Task EvaluateInternalAsync(string key, int departmentId, IDictionary context, bool defaultValue, HashSet visited, bool bypassCache = false) { // 0) Subsystem master switch. if (!FeatureFlagsConfig.FeatureFlagsEnabled) return BuildDefault(key, defaultValue, FeatureFlagEvaluationSource.SubsystemDisabled); - var flags = await GetAllFlagsAsync(includeArchived: true); + var flags = await GetAllFlagsAsync(includeArchived: true, bypassCache: bypassCache || _mutationActive); var flag = flags.FirstOrDefault(f => string.Equals(f.FlagKey, key, StringComparison.OrdinalIgnoreCase)); // 1) Unknown flag -> caller/code default. @@ -147,14 +149,14 @@ private async Task EvaluateInternalAsync(string key, int return evaluation = Build(flag, false, flag.OffValue, FeatureFlagEvaluationSource.Schedule); // 5) Prerequisites. - var prerequisites = (await GetAllPrerequisitesAsync()).Where(p => p.FeatureFlagId == flag.FeatureFlagId).ToList(); + var prerequisites = (await GetAllPrerequisitesAsync(bypassCache)).Where(p => p.FeatureFlagId == flag.FeatureFlagId).ToList(); foreach (var prerequisite in prerequisites) { var required = flags.FirstOrDefault(f => f.FeatureFlagId == prerequisite.RequiredFeatureFlagId); if (required == null) continue; - var requiredEvaluation = await EvaluateInternalAsync(required.FlagKey, departmentId, context, false, visited); + var requiredEvaluation = await EvaluateInternalAsync(required.FlagKey, departmentId, context, false, visited, bypassCache); var satisfied = string.IsNullOrEmpty(prerequisite.RequiredValue) ? requiredEvaluation.IsEnabled : requiredEvaluation.IsEnabled && string.Equals(requiredEvaluation.Value, prerequisite.RequiredValue, StringComparison.OrdinalIgnoreCase); @@ -164,7 +166,7 @@ private async Task EvaluateInternalAsync(string key, int } // 6) Per-department override (explicit, non-expired) wins over rollout/targeting. - var overrides = await GetOverridesForDepartmentAsync(departmentId); + var overrides = await GetOverridesForDepartmentAsync(departmentId, bypassCache || _mutationActive); var departmentOverride = overrides.FirstOrDefault(o => o.FeatureFlagId == flag.FeatureFlagId); if (departmentOverride != null && (!departmentOverride.ExpiresOn.HasValue || departmentOverride.ExpiresOn.Value > now)) return evaluation = Build(flag, departmentOverride.IsEnabled, departmentOverride.FlagValue, FeatureFlagEvaluationSource.Override); @@ -178,7 +180,7 @@ private async Task EvaluateInternalAsync(string key, int } // 8) Targeting rules (first match by priority). - var rules = (await GetAllTargetingRulesAsync()).Where(r => r.FeatureFlagId == flag.FeatureFlagId).OrderBy(r => r.Priority).ToList(); + var rules = (await GetAllTargetingRulesAsync(bypassCache)).Where(r => r.FeatureFlagId == flag.FeatureFlagId).OrderBy(r => r.Priority).ToList(); foreach (var rule in rules) { if (!await RuleMatchesAsync(rule, departmentId, context)) @@ -426,7 +428,7 @@ async Task> fetch() } List flags; - if (!bypassCache && SystemBehaviorConfig.CacheEnabled) + if (!bypassCache && !_mutationActive && SystemBehaviorConfig.CacheEnabled) flags = await _cacheProvider.RetrieveAsync(AllFlagsCacheKey, fetch, CacheLength); else flags = await fetch(); @@ -441,7 +443,7 @@ public async Task GetFlagByKeyAsync(string key, bool bypassCache = return flags.FirstOrDefault(f => string.Equals(f.FlagKey, key, StringComparison.OrdinalIgnoreCase)); } - private async Task> GetAllTargetingRulesAsync() + private async Task> GetAllTargetingRulesAsync(bool bypassCache = false) { async Task> fetch() { @@ -449,13 +451,13 @@ async Task> fetch() return all?.ToList() ?? new List(); } - if (SystemBehaviorConfig.CacheEnabled) + if (!bypassCache && !_mutationActive && SystemBehaviorConfig.CacheEnabled) return await _cacheProvider.RetrieveAsync(AllRulesCacheKey, fetch, CacheLength) ?? new List(); return await fetch(); } - private async Task> GetAllPrerequisitesAsync() + private async Task> GetAllPrerequisitesAsync(bool bypassCache = false) { async Task> fetch() { @@ -463,7 +465,7 @@ async Task> fetch() return all?.ToList() ?? new List(); } - if (SystemBehaviorConfig.CacheEnabled) + if (!bypassCache && !_mutationActive && SystemBehaviorConfig.CacheEnabled) return await _cacheProvider.RetrieveAsync(AllPrereqsCacheKey, fetch, CacheLength) ?? new List(); return await fetch(); @@ -477,7 +479,7 @@ async Task> fetch() return all?.ToList() ?? new List(); } - if (!bypassCache && SystemBehaviorConfig.CacheEnabled) + if (!bypassCache && !_mutationActive && SystemBehaviorConfig.CacheEnabled) return await _cacheProvider.RetrieveAsync(string.Format(DepartmentOverridesCacheKey, departmentId), fetch, CacheLength) ?? new List(); return await fetch(); @@ -487,7 +489,8 @@ async Task> fetch() #region Flag management - public async Task SaveFlagAsync(FeatureFlag flag, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public Task SaveFlagAsync(FeatureFlag flag, string userId, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => SaveFlagAsyncCore(flag, userId, cancellationToken), cancellationToken); + private async Task SaveFlagAsyncCore(FeatureFlag flag, string userId, CancellationToken cancellationToken = default(CancellationToken)) { if (flag == null) throw new ArgumentNullException(nameof(flag)); @@ -520,7 +523,8 @@ async Task> fetch() return saved; } - public async Task ArchiveFlagAsync(string key, string userId, bool archived = true, CancellationToken cancellationToken = default(CancellationToken)) + public Task ArchiveFlagAsync(string key, string userId, bool archived = true, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => ArchiveFlagAsyncCore(key, userId, archived, cancellationToken), cancellationToken); + private async Task ArchiveFlagAsyncCore(string key, string userId, bool archived = true, CancellationToken cancellationToken = default(CancellationToken)) { var flag = await GetFlagByKeyAsync(key, bypassCache: true); if (flag == null) @@ -538,7 +542,8 @@ async Task> fetch() return true; } - public async Task SetGlobalEnabledAsync(string key, bool enabled, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public Task SetGlobalEnabledAsync(string key, bool enabled, string userId, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => SetGlobalEnabledAsyncCore(key, enabled, userId, cancellationToken), cancellationToken); + private async Task SetGlobalEnabledAsyncCore(string key, bool enabled, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var flag = await GetFlagByKeyAsync(key, bypassCache: true); if (flag == null) @@ -556,7 +561,8 @@ async Task> fetch() return saved; } - public async Task SetRolloutPercentageAsync(string key, int percentage, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public Task SetRolloutPercentageAsync(string key, int percentage, string userId, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => SetRolloutPercentageAsyncCore(key, percentage, userId, cancellationToken), cancellationToken); + private async Task SetRolloutPercentageAsyncCore(string key, int percentage, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var flag = await GetFlagByKeyAsync(key, bypassCache: true); if (flag == null) @@ -574,7 +580,8 @@ async Task> fetch() return saved; } - public async Task DeleteFlagAsync(string key, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public Task DeleteFlagAsync(string key, string userId, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => DeleteFlagAsyncCore(key, userId, cancellationToken), cancellationToken); + private async Task DeleteFlagAsyncCore(string key, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var flag = await GetFlagByKeyAsync(key, bypassCache: true); if (flag == null) @@ -619,7 +626,8 @@ public async Task> GetOverridesForFlagAsync(string key return all?.Where(o => o.FeatureFlagId == flag.FeatureFlagId).ToList() ?? new List(); } - public async Task SetDepartmentOverrideAsync(string key, int departmentId, bool isEnabled, string value, string reason, DateTime? expiresOn, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public Task SetDepartmentOverrideAsync(string key, int departmentId, bool isEnabled, string value, string reason, DateTime? expiresOn, string userId, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => SetDepartmentOverrideAsyncCore(key, departmentId, isEnabled, value, reason, expiresOn, userId, cancellationToken), cancellationToken); + private async Task SetDepartmentOverrideAsyncCore(string key, int departmentId, bool isEnabled, string value, string reason, DateTime? expiresOn, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var flag = await GetFlagByKeyAsync(key, bypassCache: true); if (flag == null) @@ -655,7 +663,8 @@ public async Task> GetOverridesForFlagAsync(string key return saved; } - public async Task RemoveDepartmentOverrideAsync(string key, int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public Task RemoveDepartmentOverrideAsync(string key, int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => RemoveDepartmentOverrideAsyncCore(key, departmentId, userId, cancellationToken), cancellationToken); + private async Task RemoveDepartmentOverrideAsyncCore(string key, int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var flag = await GetFlagByKeyAsync(key, bypassCache: true); if (flag == null) @@ -686,7 +695,8 @@ public async Task> GetTargetingRulesForFlagAsync( return (await GetAllTargetingRulesAsync()).Where(r => r.FeatureFlagId == flag.FeatureFlagId).OrderBy(r => r.Priority).ToList(); } - public async Task SaveTargetingRuleAsync(FeatureFlagTargetingRule rule, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public Task SaveTargetingRuleAsync(FeatureFlagTargetingRule rule, string userId, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => SaveTargetingRuleAsyncCore(rule, userId, cancellationToken), cancellationToken); + private async Task SaveTargetingRuleAsyncCore(FeatureFlagTargetingRule rule, string userId, CancellationToken cancellationToken = default(CancellationToken)) { if (rule == null) throw new ArgumentNullException(nameof(rule)); @@ -712,7 +722,8 @@ public async Task> GetTargetingRulesForFlagAsync( return saved; } - public async Task RemoveTargetingRuleAsync(int targetingRuleId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public Task RemoveTargetingRuleAsync(int targetingRuleId, string userId, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => RemoveTargetingRuleAsyncCore(targetingRuleId, userId, cancellationToken), cancellationToken); + private async Task RemoveTargetingRuleAsyncCore(int targetingRuleId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var rule = (await _featureFlagTargetingRuleRepository.GetAllAsync())?.FirstOrDefault(r => r.FeatureFlagTargetingRuleId == targetingRuleId); if (rule == null) @@ -735,7 +746,8 @@ public async Task> GetPrerequisitesForFlagAsync(st return (await GetAllPrerequisitesAsync()).Where(p => p.FeatureFlagId == flag.FeatureFlagId).ToList(); } - public async Task AddPrerequisiteAsync(string key, string requiredKey, string requiredValue, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public Task AddPrerequisiteAsync(string key, string requiredKey, string requiredValue, string userId, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => AddPrerequisiteAsyncCore(key, requiredKey, requiredValue, userId, cancellationToken), cancellationToken); + private async Task AddPrerequisiteAsyncCore(string key, string requiredKey, string requiredValue, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var flag = await GetFlagByKeyAsync(key, bypassCache: true); var required = await GetFlagByKeyAsync(requiredKey, bypassCache: true); @@ -762,7 +774,8 @@ public async Task> GetPrerequisitesForFlagAsync(st return saved; } - public async Task RemovePrerequisiteAsync(int prerequisiteId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public Task RemovePrerequisiteAsync(int prerequisiteId, string userId, CancellationToken cancellationToken = default(CancellationToken)) => MutateFlagAsync(() => RemovePrerequisiteAsyncCore(prerequisiteId, userId, cancellationToken), cancellationToken); + private async Task RemovePrerequisiteAsyncCore(int prerequisiteId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var prereq = (await _featureFlagPrerequisiteRepository.GetAllAsync())?.FirstOrDefault(p => p.FeatureFlagPrerequisiteId == prerequisiteId); if (prereq == null) @@ -856,11 +869,7 @@ private void RecordEvaluation(int featureFlagId, int departmentId, bool? isEnabl var now = DateTime.UtcNow; foreach (var flagId in touchedFlagIds) { - var flag = await _featureFlagRepository.GetByIdAsync(flagId); - if (flag == null) - continue; - flag.LastEvaluatedOn = now; - await _featureFlagRepository.SaveOrUpdateAsync(flag, cancellationToken); + await _featureFlagRepository.TouchEvaluationAsync(flagId, now, cancellationToken); } return flushed; @@ -892,6 +901,7 @@ public async Task> GetStaleFlagsAsync(int? olderThanDays = nul public async Task InvalidateFlagCacheAsync() { + if (_mutationActive) { _invalidateFlags = true; return; } await _cacheProvider.RemoveAsync(AllFlagsCacheKey); await _cacheProvider.RemoveAsync(AllRulesCacheKey); await _cacheProvider.RemoveAsync(AllPrereqsCacheKey); @@ -899,11 +909,13 @@ public async Task InvalidateFlagCacheAsync() public async Task InvalidateDepartmentOverrideCacheAsync(int departmentId) { + if (_mutationActive) { _invalidateOverrides.Add(departmentId); return; } await _cacheProvider.RemoveAsync(string.Format(DepartmentOverridesCacheKey, departmentId)); } private void PublishAudit(int departmentId, string userId, AuditLogTypes type, string before, object after) { + if (_mutationActive) { _committedAudits.Add(() => PublishAudit(departmentId, userId, type, before, after)); return; } try { _eventAggregator.SendMessage(new AuditEvent diff --git a/Core/Resgrid.Services/GdprDataExportService.cs b/Core/Resgrid.Services/GdprDataExportService.cs index ece4a4b5b..efe55f229 100644 --- a/Core/Resgrid.Services/GdprDataExportService.cs +++ b/Core/Resgrid.Services/GdprDataExportService.cs @@ -13,6 +13,7 @@ using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Checklists; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -35,6 +36,8 @@ public class GdprDataExportService : IGdprDataExportService private readonly IShiftsService _shiftsService; private readonly IEmailService _emailService; private readonly IChecklistRepository _checklists; + private readonly IChecklistReminderRepository _checklistReminders; + private readonly Lazy _checklistProtection; public GdprDataExportService( IGdprDataExportRequestRepository repository, @@ -50,9 +53,10 @@ public GdprDataExportService( ICertificationService certificationService, ITrainingService trainingService, IShiftsService shiftsService, - IEmailService emailService, IChecklistRepository checklists = null) + IEmailService emailService, IChecklistRepository checklists, Lazy checklistProtection, IChecklistReminderRepository checklistReminders) { _repository = repository; + _checklistReminders = checklistReminders ?? throw new ArgumentNullException(nameof(checklistReminders)); _userProfileService = userProfileService; _memberSensitiveDataService = memberSensitiveDataService; _emergencyContactService = emergencyContactService; @@ -66,7 +70,8 @@ public GdprDataExportService( _trainingService = trainingService; _shiftsService = shiftsService; _emailService = emailService; - _checklists = checklists; + _checklists = checklists ?? throw new ArgumentNullException(nameof(checklists)); + _checklistProtection = checklistProtection ?? throw new ArgumentNullException(nameof(checklistProtection)); } public async Task CreateExportRequestAsync(string userId, int departmentId, CancellationToken cancellationToken = default) @@ -181,7 +186,7 @@ private async Task BuildExportZipAsync(string userId, int departmentId) await AddJsonEntry(archive, "certifications.json", await BuildCertificationsDataAsync(userId), ledger); await AddJsonEntry(archive, "trainings.json", await BuildTrainingsDataAsync(userId), ledger); await AddJsonEntry(archive, "shifts.json", await BuildShiftsDataAsync(userId), ledger); - if (_checklists != null) await AddJsonEntry(archive, "checklists.json", await BuildChecklistDataAsync(userId, departmentId), ledger); + await AddJsonEntry(archive, "checklists.json", await BuildChecklistDataAsync(userId, departmentId), ledger); // Written last, so it can report what every other entry withheld. Only present when // something actually was: a member of an unprotected department gets the archive they @@ -225,19 +230,60 @@ public void Record(string fileName, string path) private async Task BuildChecklistDataAsync(string userId, int departmentId) { + if (_checklistProtection == null) throw new InvalidOperationException("Checklist export protection is unavailable."); + async Task Safe(T row) where T : ChecklistRow => await _checklistProtection.Value.ForDisplayAsync(departmentId, row, ChecklistTables.Fields()); + async Task> Children(IReadOnlyCollection parentIds) where T : ChecklistRow + { + var children = new List(); + for (var offset = 0; ; offset += 100) + { + var batch = await _checklists.ListChildrenAsync(departmentId, parentIds, offset); + foreach (var child in batch) children.Add(await Safe(child)); + if (batch.Count < 100) return children.ToLookup(child => child.ParentId); + } + } var records = new List(); + var relatedOccurrences = new HashSet(); for (var skip = 0; ; skip += 100) { var batch = await _checklists.ListAsync(departmentId, skip: skip); - foreach (var completion in batch.Where(c => c.CreatedBy == userId || c.WitnessUserId == userId || c.TargetType == (int)Resgrid.Model.Checklists.ChecklistTargetType.Personnel && c.TargetId == userId)) + var completions = batch.Where(c => c.CreatedBy == userId || c.WitnessUserId == userId || c.TargetType == (int)ChecklistTargetType.Personnel && c.TargetId == userId).ToList(); + if (completions.Count > 0) { - var files = await _checklists.ListAsync(departmentId, completion.Id, take: 500); - records.Add(new { Completion = completion, Answers = await _checklists.ListAsync(departmentId, completion.Id, take: 250), Files = files }); + var ids = completions.Select(c => c.Id).ToArray(); + var answers = await Children(ids); + var files = await Children(ids); + foreach (var completion in completions) + { + relatedOccurrences.Add(completion.OccurrenceId); + records.Add(new { Completion = await Safe(completion), Answers = answers[completion.Id], Files = files[completion.Id] }); + } } if (batch.Count < 100) break; } - // AddJsonEntry applies the existing recursive ADP redaction and records omissions in the manifest. - return records; + var schedules = new List(); var occurrences = new List(); var ownedSchedules = new HashSet(); + for (var skip = 0; ; skip += 100) + { + var batch = await _checklists.ListAsync(departmentId, skip: skip); + foreach (var row in batch.Where(s => s.CreatedBy == userId || s.TargetType == (int)ChecklistTargetType.Personnel && s.TargetId == userId)) + { ownedSchedules.Add(row.Id); schedules.Add(await Safe(row)); } + if (batch.Count < 100) break; + } + for (var skip = 0; ; skip += 100) + { + var batch = await _checklists.ListAsync(departmentId, skip: skip); + foreach (var row in batch.Where(o => relatedOccurrences.Contains(o.Id) || ownedSchedules.Contains(o.ScheduleId) || o.TargetType == (int)ChecklistTargetType.Personnel && o.TargetId == userId)) occurrences.Add(await Safe(row)); + if (batch.Count < 100) break; + } + // Mask cataloged fields even before enrollment migration finishes. The archive ledger records REDACTED values. + var reminders = new List(); + for (var skip = 0; ; skip += 100) + { + var batch = await _checklistReminders.ForRecipientAsync(departmentId, userId, skip); + reminders.AddRange(batch.Select(r => (object)new { r.Id, r.OccurrenceId, r.Kind, r.Status, r.CreatedOnUtc, r.CompletedOnUtc })); + if (batch.Count < 100) break; + } + return new { Completions = records, Schedules = schedules, Occurrences = occurrences, Reminders = reminders }; } private static async Task AddJsonEntry(ZipArchive archive, string fileName, object data, RedactionLedger ledger) diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index d200c3162..ddb442da5 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -683,9 +683,15 @@ void Prevention(string table, string column, ProtectedFieldClassification classi Prevention("RmsPreventionAttachments", "Description", ProtectedFieldClassification.Sensitive); Prevention("RmsPreventionAttachments", "Data", ProtectedFieldClassification.Phi, ProtectedFieldStorageKind.Binary); + foreach (var history in new[] { ("AuditLogs", "Data"), ("DomainEventOutbox", "PayloadJson"), ("DomainEventOutbox", "LastError"), ("WorkflowRuns", "InputPayload"), ("WorkflowRuns", "ErrorMessage"), ("WorkflowRunLogs", "RenderedOutput"), ("WorkflowRunLogs", "ActionResult"), ("WorkflowRunLogs", "ErrorMessage") }) + list.Add(new ProtectedFieldDefinition(history.Item1.ToLowerInvariant() + "." + history.Item2.ToLowerInvariant(), OperationalFamily, history.Item1, history.Item2, ProtectedFieldStorageKind.Text, + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, Resgrid.Model.Checklists.ReadinessHistoryFields.CatalogVersion)); + foreach (var outcome in new[] { (Table: "ChecklistCompletions", Column: "Score"), (Table: "ChecklistCompletions", Column: "Passed"), (Table: "ChecklistCompletionItems", Column: "IsFailure") }) + list.Add(new ProtectedFieldDefinition(outcome.Table.ToLowerInvariant() + "." + outcome.Column.ToLowerInvariant(), OperationalFamily, outcome.Table, outcome.Column, ProtectedFieldStorageKind.CompanionColumn, + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 15)); foreach (var table in Resgrid.Model.Checklists.ChecklistTables.All.Values) list.Add(new ProtectedFieldDefinition(table.ToLowerInvariant() + ".content", OperationalFamily, table, "Content", ProtectedFieldStorageKind.Text, - ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 14)); + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, table == "ChecklistSchedules" ? 17 : 14)); list.Add(new ProtectedFieldDefinition("checklistcompletionfiles.data", OperationalFamily, "ChecklistCompletionFiles", "Data", ProtectedFieldStorageKind.Binary, ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 14)); return list; diff --git a/Core/Resgrid.Services/ReadinessAccessService.cs b/Core/Resgrid.Services/ReadinessAccessService.cs index 5341d5f21..d37d576ed 100644 --- a/Core/Resgrid.Services/ReadinessAccessService.cs +++ b/Core/Resgrid.Services/ReadinessAccessService.cs @@ -27,10 +27,10 @@ public async Task CanUseChecklistsAsync(int departmentId) try { - if (!await _flags.IsEnabledAsync(FeatureFlagKeys.ChecklistsSystem, departmentId)) + if ((await _flags.EvaluateFreshAsync(FeatureFlagKeys.ChecklistsSystem, departmentId))?.IsEnabled != true) return false; - var settings = await _settings.GetDepartmentModuleSettingsAsync(departmentId); + var settings = await _settings.GetDepartmentModuleSettingsAsync(departmentId, true); return settings != null && !settings.ChecklistsDisabled; } catch (Exception ex) diff --git a/Core/Resgrid.Services/ReadinessHistoryProtectionService.cs b/Core/Resgrid.Services/ReadinessHistoryProtectionService.cs new file mode 100644 index 000000000..664e1d58b --- /dev/null +++ b/Core/Resgrid.Services/ReadinessHistoryProtectionService.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// History is encrypted at rest and redacted for Workflow/audit viewers; this seam never decrypts or accepts a user grant. + public class ReadinessHistoryProtectionService : IReadinessHistoryProtectionService + { + private readonly IProtectedWriteService _write; + private readonly IDepartmentDataProtectionService _policy; + public ReadinessHistoryProtectionService(IProtectedWriteService write, IDepartmentDataProtectionService policy) { _write = write; _policy = policy; } + public async Task ProtectAsync(int departmentId, string rowKey, T entity, IReadOnlyDictionary Get, Action Set)> fields, CancellationToken ct = default) where T : class + { + if (fields.Values.Any(f => f.Get(entity) == ProtectedDataEnvelope.RedactionValue)) + throw new InvalidOperationException("A redacted history view cannot replace stored history."); + var result = await _write.PrepareRecordsEntityWriteAsync(departmentId, entity, null, rowKey, fields, null, null, null, true, ct); + if (result?.Success != true || result.IsProtected && fields.Values.Any(f => !string.IsNullOrEmpty(f.Get(entity)) && !ProtectedDataEnvelope.HasEnvelopePrefix(f.Get(entity)))) + throw new InvalidOperationException("Readiness history protection is unavailable or requires a catalog upgrade."); + } + public async Task ForDisplayAsync(int departmentId, T entity, IReadOnlyDictionary Get, Action Set)> fields) where T : class + { + if (entity == null) return null; + // Never mutate repository objects: a masked value must not be saved over an envelope by a later command. + var copy = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(entity, new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore })); + // History views use their explicit fields; navigation objects can contain unprojected parent data. + if (copy is WorkflowRun run) run.Workflow = null; + if (copy is WorkflowRunLog log) { log.WorkflowRun = null; log.WorkflowStep = null; } + bool enforced; + try { enforced = await _policy.IsProtectionEnforcedAsync(departmentId); } + catch { enforced = true; } + foreach (var field in fields.Values) + if (!string.IsNullOrEmpty(field.Get(copy)) && (enforced || ProtectedDataEnvelope.HasEnvelopePrefix(field.Get(copy)))) field.Set(copy, ProtectedDataEnvelope.RedactionValue); + return copy; + } + } +} diff --git a/Core/Resgrid.Services/Records/DomainEventOutboxService.cs b/Core/Resgrid.Services/Records/DomainEventOutboxService.cs index 37350bb78..e712c4728 100644 --- a/Core/Resgrid.Services/Records/DomainEventOutboxService.cs +++ b/Core/Resgrid.Services/Records/DomainEventOutboxService.cs @@ -4,6 +4,8 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model.Checklists; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; @@ -27,11 +29,17 @@ public class DomainEventOutboxService : IDomainEventOutboxService private readonly IDomainEventOutboxRepository _outboxRepository; private readonly IEventAggregator _eventAggregator; + private readonly Lazy _protection; + private readonly Lazy _history; + private Task ProtectHistoryAsync(DomainEventOutboxEntry entry, CancellationToken ct) => (_history?.Value ?? throw new InvalidOperationException("Readiness history protection is unavailable.")) + .ProtectAsync(entry.DepartmentId, entry.DomainEventOutboxId.ToString(System.Globalization.CultureInfo.InvariantCulture), entry, ReadinessHistoryFields.Outbox, ct); - public DomainEventOutboxService(IDomainEventOutboxRepository outboxRepository, IEventAggregator eventAggregator) + public DomainEventOutboxService(IDomainEventOutboxRepository outboxRepository, IEventAggregator eventAggregator, Lazy protection = null, Lazy history = null) { _outboxRepository = outboxRepository; _eventAggregator = eventAggregator; + _protection = protection; + _history = history; } public async Task EnqueueAsync(int departmentId, string producerSubsystem, DomainEventEnvelope envelope, CancellationToken cancellationToken = default) @@ -53,7 +61,9 @@ public async Task EnqueueAsync(int departmentId, string AggregateVersion = envelope.AggregateVersion, Sequence = await _outboxRepository.GetNextSequenceAsync(departmentId, envelope.AggregateId), TriggerEventType = envelope.Trigger.HasValue ? (int?)envelope.Trigger.Value : null, - PayloadJson = JsonConvert.SerializeObject(envelope.Payload ?? new object()), + PayloadJson = producerSubsystem == "Checklists" + ? await ChecklistWorkflowPayload.ProjectAsync(departmentId, envelope.Payload, _protection?.Value) + : JsonConvert.SerializeObject(envelope.Payload ?? new object()), CorrelationId = envelope.CorrelationId, CausationId = envelope.CausationId, OriginClient = (int)envelope.OriginClient, @@ -64,7 +74,14 @@ public async Task EnqueueAsync(int departmentId, string CreatedOn = now }; - return await _outboxRepository.InsertAsync(entry, cancellationToken, true); + if (producerSubsystem != "Checklists") return await _outboxRepository.InsertAsync(entry, cancellationToken, true); + var payload = entry.PayloadJson; + entry.ReadinessRoutingJson = ChecklistWorkflowPayload.Routing(payload, entry.AggregateId); + entry.PayloadJson = "{}"; // Allocate the numeric AAD identity without writing content to SQL. + entry = await _outboxRepository.InsertAsync(entry, cancellationToken, true); + entry.PayloadJson = payload; await ProtectHistoryAsync(entry, cancellationToken); + if (!await _outboxRepository.InitializeChecklistPayloadAsync(entry, cancellationToken)) throw new InvalidOperationException("Checklist event initialization failed."); + return entry; } public async Task DispatchAfterCommitAsync(IEnumerable domainEventOutboxIds, CancellationToken cancellationToken = default) @@ -135,7 +152,18 @@ private async Task DispatchOneAsync(DomainEventOutboxEntry entry, Cancella { try { - _eventAggregator.SendMessage(new DomainEventDispatchedEvent + var dispatchPayload = entry.PayloadJson; + if (entry.ProducerSubsystem == "Checklists") + { + var original = entry.PayloadJson; var oldError = entry.LastError; + var source = ProtectedDataEnvelope.HasEnvelopePrefix(original) ? entry.ReadinessRoutingJson : original; + if (string.IsNullOrEmpty(source)) throw new InvalidOperationException("Checklist event routing metadata is unavailable."); + dispatchPayload = await ChecklistWorkflowPayload.ProjectAsync(entry.DepartmentId, JObject.Parse(source), _protection?.Value); + await ProtectHistoryAsync(entry, cancellationToken); + if ((entry.PayloadJson != original || entry.LastError != oldError) && !await _outboxRepository.ReplaceChecklistPayloadAsync(entry, entry.PayloadJson, cancellationToken)) + throw new InvalidOperationException("Checklist outbox lease changed before projection was saved."); + } + var dispatched = new DomainEventDispatchedEvent { DepartmentId = entry.DepartmentId, EventId = entry.EventId, @@ -146,22 +174,29 @@ private async Task DispatchOneAsync(DomainEventOutboxEntry entry, Cancella AggregateId = entry.AggregateId, Sequence = entry.Sequence, TriggerEventType = entry.TriggerEventType, - PayloadJson = entry.PayloadJson, + PayloadJson = dispatchPayload, CorrelationId = entry.CorrelationId, CausationId = entry.CausationId, OriginClient = entry.OriginClient, IsReplay = entry.Attempts > 1, OccurredOn = entry.OccurredOn - }); + }; + _eventAggregator.SendMessage(dispatched); + await _eventAggregator.SendMessageAsync(dispatched); - await _outboxRepository.MarkDispatchedAsync(entry.DomainEventOutboxId, DateTime.UtcNow, cancellationToken); - return true; + return await _outboxRepository.MarkDispatchedAsync(entry.DomainEventOutboxId, DateTime.UtcNow, cancellationToken); } catch (Exception ex) { - Logging.LogException(ex, $"Outbox dispatch failed for event {entry.EventId} (attempt {entry.Attempts})."); + var error = entry.ProducerSubsystem == "Checklists" ? "Checklist subscriber delivery failed." : ex.Message; + if (entry.ProducerSubsystem == "Checklists") + { + entry.LastError = error; await ProtectHistoryAsync(entry, cancellationToken); error = entry.LastError; + } + if (entry.ProducerSubsystem == "Checklists") Logging.LogError($"Outbox dispatch failed for event {entry.EventId} (attempt {entry.Attempts})."); + else Logging.LogException(ex, $"Outbox dispatch failed for event {entry.EventId} (attempt {entry.Attempts})."); var terminal = entry.Attempts >= MaximumAttempts; - await _outboxRepository.MarkFailedAsync(entry.DomainEventOutboxId, ex.Message, terminal ? (DateTime?)null : DateTime.UtcNow.Add(BackoffFor(entry.Attempts)), terminal, cancellationToken); + await _outboxRepository.MarkFailedAsync(entry.DomainEventOutboxId, error, terminal ? (DateTime?)null : DateTime.UtcNow.Add(BackoffFor(entry.Attempts)), terminal, cancellationToken); return false; } } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index b149a5cd3..f3b495d63 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -18,6 +18,10 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().PreserveExistingDefaults().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -84,6 +88,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/SubscriptionsService.cs b/Core/Resgrid.Services/SubscriptionsService.cs index c9beb087e..56aec2164 100644 --- a/Core/Resgrid.Services/SubscriptionsService.cs +++ b/Core/Resgrid.Services/SubscriptionsService.cs @@ -734,7 +734,8 @@ public async Task> GetCurrentPaymentAddonsForDepartmentAsync( if (planAddonIds == null || planAddonIds.Count == 0) return new List(); - var client = new RestClient(Config.SystemBehaviorConfig.BillingApiBaseUrl, configureSerialization: s => s.UseNewtonsoftJson()); + var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) { Timeout = TimeSpan.FromMilliseconds(BillingApiTimeoutMs) }; + using var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); var request = new RestRequest($"/api/Billing/GetCurrentPaymentAddonsForDepartmentPost", Method.Post); request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); request.AddHeader("Content-Type", "application/json; charset=utf-8"); @@ -775,11 +776,21 @@ public async Task> GetCurrentPaymentAddonsForDepartmentAsync( return paymentAddons; } - public async Task> GetAllAddonPlansByTypeAsync(PlanAddonTypes planAddonType) + public Task> GetAllAddonPlansByTypeAsync(PlanAddonTypes planAddonType) + { + if (planAddonType == PlanAddonTypes.ReadinessPro && Config.SystemBehaviorConfig.CacheEnabled && + !string.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !string.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) + return _cacheProvider.RetrieveAsync($"AddonPlansByType_{(int)planAddonType}", + () => LoadAddonPlansByTypeAsync(planAddonType), TimeSpan.FromMinutes(5)); + return LoadAddonPlansByTypeAsync(planAddonType); + } + + private async Task> LoadAddonPlansByTypeAsync(PlanAddonTypes planAddonType) { if (!String.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) && !String.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) { - var client = new RestClient(Config.SystemBehaviorConfig.BillingApiBaseUrl, configureSerialization: s => s.UseNewtonsoftJson()); + var options = new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) { Timeout = TimeSpan.FromMilliseconds(BillingApiTimeoutMs) }; + using var client = new RestClient(options, configureSerialization: s => s.UseNewtonsoftJson()); var request = new RestRequest($"/api/Billing/GetAllAddonPlansByType", Method.Get); request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); request.AddHeader("Content-Type", "application/json"); diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 1d5bce772..003ad310a 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -80,7 +80,12 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve { case WorkflowTriggerEventType.ChecklistCompleted: case WorkflowTriggerEventType.ChecklistFailed: - obj["checklist"] = new ScriptObject { ["completion_id"] = "11111111-1111-1111-1111-111111111111", ["definition_id"] = "22222222-2222-2222-2222-222222222222", ["version_id"] = "33333333-3333-3333-3333-333333333333", ["target_type"] = 1, ["target_id"] = "12", ["score"] = 75m, ["passed"] = false, ["item_id"] = "44444444-4444-4444-4444-444444444444", ["url"] = "/User/Checklists/CompletionDetail/11111111-1111-1111-1111-111111111111" }; + case WorkflowTriggerEventType.ChecklistMissed: + case WorkflowTriggerEventType.ChecklistScheduleChanged: + case WorkflowTriggerEventType.ChecklistOccurrenceSkipped: + obj["protection"] = new ScriptObject { ["is_redacted"] = false, ["redacted_fields"] = new ScriptArray(), ["catalog_version"] = 0 }; + var scheduled = eventType != WorkflowTriggerEventType.ChecklistCompleted && eventType != WorkflowTriggerEventType.ChecklistFailed; + obj["checklist"] = new ScriptObject { ["completion_id"] = "11111111-1111-1111-1111-111111111111", ["definition_id"] = "22222222-2222-2222-2222-222222222222", ["version_id"] = "33333333-3333-3333-3333-333333333333", ["schedule_id"] = scheduled ? "55555555-5555-5555-5555-555555555555" : null, ["occurrence_id"] = "66666666-6666-6666-6666-666666666666", ["period_start_utc"] = "2026-09-08T08:00:00.0000000Z", ["window_end_utc"] = "2026-09-08T09:00:00.0000000Z", ["state"] = 4, ["revision"] = 2, ["is_active"] = true, ["target_type"] = 1, ["target_id"] = "12", ["score"] = 75m, ["passed"] = false, ["item_id"] = "44444444-4444-4444-4444-444444444444", ["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Checklists/" + (scheduled ? "Due" : "CompletionDetail/11111111-1111-1111-1111-111111111111") }; break; case WorkflowTriggerEventType.CallAdded: case WorkflowTriggerEventType.CallUpdated: @@ -954,4 +959,3 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType } } } - diff --git a/Core/Resgrid.Services/WorkflowService.cs b/Core/Resgrid.Services/WorkflowService.cs index 1c6eefce8..2b6c13700 100644 --- a/Core/Resgrid.Services/WorkflowService.cs +++ b/Core/Resgrid.Services/WorkflowService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -9,6 +9,7 @@ using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Checklists; using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; @@ -30,6 +31,9 @@ public class WorkflowService : IWorkflowService private readonly IWorkflowTemplateContextBuilder _contextBuilder; private readonly ISubscriptionsService _subscriptionsService; private readonly IRecordsExportService _recordsExportService; + private readonly Lazy _protectedProjection; + private readonly Lazy _history; + private IReadinessHistoryProtectionService History => _history?.Value ?? throw new InvalidOperationException("Readiness history protection is unavailable."); public WorkflowService( IWorkflowRepository workflowRepository, @@ -42,9 +46,11 @@ public WorkflowService( IWorkflowActionExecutorFactory executorFactory, IWorkflowTemplateContextBuilder contextBuilder, ISubscriptionsService subscriptionsService, - IRecordsExportService recordsExportService) + IRecordsExportService recordsExportService, Lazy protectedProjection = null, Lazy history = null) { _recordsExportService = recordsExportService; + _protectedProjection = protectedProjection; + _history = history; _workflowRepository = workflowRepository; _stepRepository = stepRepository; _credentialRepository = credentialRepository; @@ -295,6 +301,20 @@ public async Task ExecuteWorkflowAsync( { var workflow = await _workflowRepository.GetByIdAsync(workflowId); if (workflow == null) return null; + if (workflow.DepartmentId != departmentId) throw new InvalidOperationException("Workflow tenant mismatch."); + var checklist = ChecklistWorkflowPayload.IsChecklist(workflow.TriggerEventType); + if (checklist) + { + if (string.IsNullOrEmpty(existingRunId)) throw new InvalidOperationException("Checklist workflows require a durable event run."); + eventPayloadJson = await ChecklistWorkflowPayload.ProjectAsync(departmentId, JObject.Parse(eventPayloadJson), _protectedProjection?.Value, wrapped: true); + var persistedInput = new WorkflowRun { InputPayload = eventPayloadJson }; + await History.ProtectAsync(departmentId, existingRunId, persistedInput, ReadinessHistoryFields.Runs, cancellationToken); + if (!await _runRepository.TryStartChecklistRunAsync(existingRunId, workflowId, departmentId, attemptNumber, persistedInput.InputPayload)) + { + var duplicate = await _runRepository.GetByIdAsync(existingRunId); + return duplicate?.DepartmentId == departmentId && duplicate.WorkflowId == workflowId ? duplicate : null; + } + } // Resolve whether this is a free-plan department once per execution var plan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(departmentId); @@ -307,6 +327,7 @@ public async Task ExecuteWorkflowAsync( run = await _runRepository.GetByIdAsync(existingRunId); if (run == null) { + if (checklist) throw new InvalidOperationException("The claimed checklist workflow run no longer exists."); Logging.LogError($"WorkflowService.ExecuteWorkflowAsync: WorkflowRun '{existingRunId}' not found for workflowId '{workflowId}'. Recreating run record."); run = new WorkflowRun { @@ -324,9 +345,11 @@ public async Task ExecuteWorkflowAsync( } else { + if (run.DepartmentId != departmentId || run.WorkflowId != workflowId) throw new InvalidOperationException("Workflow run tenant mismatch."); + if (checklist) run.InputPayload = eventPayloadJson; run.Status = (int)WorkflowRunStatus.Running; run.AttemptNumber = attemptNumber; - await _runRepository.UpdateAsync(run, cancellationToken); + await UpdateRunAsync(run, cancellationToken); } } else @@ -355,11 +378,11 @@ public async Task ExecuteWorkflowAsync( } catch (Exception ex) { - Logging.LogException(ex); + if (checklist) Logging.LogError($"Checklist workflow failed for run {run.WorkflowRunId}."); else Logging.LogException(ex); run.Status = (int)WorkflowRunStatus.Failed; run.ErrorMessage = $"Failed to build template context: {ex.Message}"; run.CompletedOn = DateTime.UtcNow; - await _runRepository.UpdateAsync(run, cancellationToken); + await UpdateRunAsync(run, cancellationToken); return run; } @@ -369,7 +392,7 @@ public async Task ExecuteWorkflowAsync( run.Status = (int)WorkflowRunStatus.Failed; run.ErrorMessage = "Template context builder returned a null context object."; run.CompletedOn = DateTime.UtcNow; - await _runRepository.UpdateAsync(run, cancellationToken); + await UpdateRunAsync(run, cancellationToken); return run; } @@ -411,7 +434,7 @@ public async Task ExecuteWorkflowAsync( logEntry.RenderedOutput = step.ConditionExpression; logEntry.DurationMs = sw.ElapsedMilliseconds; logEntry.CompletedOn = DateTime.UtcNow; - await _runLogRepository.InsertAsync(logEntry, cancellationToken); + await InsertLogAsync(departmentId, checklist, logEntry, cancellationToken); continue; } @@ -428,7 +451,7 @@ public async Task ExecuteWorkflowAsync( logEntry.RenderedOutput = step.ConditionExpression; logEntry.DurationMs = sw.ElapsedMilliseconds; logEntry.CompletedOn = DateTime.UtcNow; - await _runLogRepository.InsertAsync(logEntry, cancellationToken); + await InsertLogAsync(departmentId, checklist, logEntry, cancellationToken); continue; } @@ -443,7 +466,7 @@ public async Task ExecuteWorkflowAsync( logEntry.RenderedOutput = conditionResult; logEntry.DurationMs = sw.ElapsedMilliseconds; logEntry.CompletedOn = DateTime.UtcNow; - await _runLogRepository.InsertAsync(logEntry, cancellationToken); + await InsertLogAsync(departmentId, checklist, logEntry, cancellationToken); continue; } } @@ -465,7 +488,7 @@ public async Task ExecuteWorkflowAsync( logEntry.ErrorMessage = $"Daily {actionType} send limit of {dailyLimit} reached for this department. Step skipped."; logEntry.DurationMs = sw.ElapsedMilliseconds; logEntry.CompletedOn = DateTime.UtcNow; - await _runLogRepository.InsertAsync(logEntry, cancellationToken); + await InsertLogAsync(departmentId, checklist, logEntry, cancellationToken); anyFailure = true; continue; } @@ -498,7 +521,7 @@ public async Task ExecuteWorkflowAsync( sw.Stop(); logEntry.DurationMs = sw.ElapsedMilliseconds; logEntry.CompletedOn = DateTime.UtcNow; - await _runLogRepository.InsertAsync(logEntry, cancellationToken); + await InsertLogAsync(departmentId, checklist, logEntry, cancellationToken); anyFailure = true; continue; } @@ -561,7 +584,7 @@ public async Task ExecuteWorkflowAsync( logEntry.ErrorMessage = "A report export can only be attached to a Records trigger."; logEntry.DurationMs = sw.ElapsedMilliseconds; logEntry.CompletedOn = DateTime.UtcNow; - await _runLogRepository.InsertAsync(logEntry, cancellationToken); + await InsertLogAsync(departmentId, checklist, logEntry, cancellationToken); anyFailure = true; continue; } @@ -586,7 +609,7 @@ public async Task ExecuteWorkflowAsync( logEntry.ErrorMessage = $"Report export failed: {exportEx.Message}"; logEntry.DurationMs = sw.ElapsedMilliseconds; logEntry.CompletedOn = DateTime.UtcNow; - await _runLogRepository.InsertAsync(logEntry, cancellationToken); + await InsertLogAsync(departmentId, checklist, logEntry, cancellationToken); anyFailure = true; Logging.LogException(exportEx); continue; @@ -647,10 +670,10 @@ public async Task ExecuteWorkflowAsync( logEntry.DurationMs = sw.ElapsedMilliseconds; logEntry.CompletedOn = DateTime.UtcNow; anyFailure = true; - Logging.LogException(ex); + if (checklist) Logging.LogError($"Checklist workflow failed for run {run.WorkflowRunId}."); else Logging.LogException(ex); } - await _runLogRepository.InsertAsync(logEntry, cancellationToken); + await InsertLogAsync(departmentId, checklist, logEntry, cancellationToken); } // Determine retry or final status @@ -674,7 +697,7 @@ public async Task ExecuteWorkflowAsync( } run.CompletedOn = DateTime.UtcNow; - await _runRepository.UpdateAsync(run, cancellationToken); + await UpdateRunAsync(run, cancellationToken); return run; } @@ -689,37 +712,42 @@ public async Task CancelWorkflowRunAsync(string workflowRunId, Cancellatio run.Status = (int)WorkflowRunStatus.Cancelled; run.CompletedOn = DateTime.UtcNow; - await _runRepository.UpdateAsync(run, cancellationToken); + await UpdateRunAsync(run, cancellationToken); return true; } // ── Run Queries ─────────────────────────────────────────────────────────────── public async Task GetWorkflowRunByIdAsync(string workflowRunId, CancellationToken cancellationToken = default) - => await _runRepository.GetByIdAsync(workflowRunId); + => await DisplayRunAsync(await _runRepository.GetByIdAsync(workflowRunId)); public async Task> GetRunsByDepartmentIdAsync(int departmentId, int page, int pageSize, CancellationToken cancellationToken = default) { var results = await _runRepository.GetByDepartmentIdPagedAsync(departmentId, page, pageSize); - return results?.ToList() ?? new List(); + return await DisplayRunsAsync(results); } public async Task> GetRunsByWorkflowIdAsync(string workflowId, int page, int pageSize, CancellationToken cancellationToken = default) { var results = await _runRepository.GetRunsByWorkflowIdAsync(workflowId, page, pageSize); - return results?.ToList() ?? new List(); + return await DisplayRunsAsync(results); } public async Task> GetPendingAndRunningRunsByDepartmentIdAsync(int departmentId, CancellationToken cancellationToken = default) { var results = await _runRepository.GetPendingAndRunningByDepartmentIdAsync(departmentId); - return results?.ToList() ?? new List(); + return await DisplayRunsAsync(results); } public async Task> GetLogsForRunAsync(string workflowRunId, CancellationToken cancellationToken = default) { var results = await _runLogRepository.GetByWorkflowRunIdAsync(workflowRunId); - return results?.OrderBy(l => l.StartedOn).ToList() ?? new List(); + var run = await _runRepository.GetByIdAsync(workflowRunId); + if (run == null) return new List(); + var logs = results?.OrderBy(l => l.StartedOn).ToList() ?? new List(); + if (ChecklistWorkflowPayload.IsChecklist(run.TriggerEventType)) + return (await Task.WhenAll(logs.Select(log => History.ForDisplayAsync(run.DepartmentId, log, ReadinessHistoryFields.Logs)))).ToList(); + return logs; } public async Task GetWorkflowHealthAsync(string workflowId, CancellationToken cancellationToken = default) @@ -727,8 +755,7 @@ public async Task GetWorkflowHealthAsync(string workflowI var workflow = await _workflowRepository.GetByIdAsync(workflowId); if (workflow == null) return null; - var allRuns = (await _runRepository.GetRunsByWorkflowIdAsync(workflowId, 1, 10000))?.ToList() - ?? new List(); + var allRuns = await DisplayRunsAsync(await _runRepository.GetRunsByWorkflowIdAsync(workflowId, 1, 10000)); var now = DateTime.UtcNow; var runs24h = allRuns.Where(r => r.StartedOn >= now.AddHours(-24)).ToList(); @@ -770,6 +797,31 @@ public async Task GetWorkflowHealthAsync(string workflowI }; } + private async Task UpdateRunAsync(WorkflowRun run, CancellationToken ct) + { + if (ChecklistWorkflowPayload.IsChecklist(run.TriggerEventType)) + await History.ProtectAsync(run.DepartmentId, run.WorkflowRunId, run, ReadinessHistoryFields.Runs, ct); + await _runRepository.UpdateAsync(run, ct); + } + + private async Task InsertLogAsync(int departmentId, bool checklist, WorkflowRunLog log, CancellationToken ct) + { + if (checklist) await History.ProtectAsync(departmentId, log.WorkflowRunLogId, log, ReadinessHistoryFields.Logs, ct); + await _runLogRepository.InsertAsync(log, ct); + } + + private async Task DisplayRunAsync(WorkflowRun run) + { + if (run == null || !ChecklistWorkflowPayload.IsChecklist(run.TriggerEventType)) return run; + var copy = await History.ForDisplayAsync(run.DepartmentId, run, ReadinessHistoryFields.Runs); + if (copy.Logs != null) + copy.Logs = await Task.WhenAll(copy.Logs.Select(log => History.ForDisplayAsync(run.DepartmentId, log, ReadinessHistoryFields.Logs))); + return copy; + } + + private async Task> DisplayRunsAsync(IEnumerable runs) + => (await Task.WhenAll((runs ?? Enumerable.Empty()).Select(DisplayRunAsync))).ToList(); + public async Task ClearPendingRunsAsync(int departmentId, CancellationToken cancellationToken = default) { var pending = await _runRepository.GetPendingAndRunningByDepartmentIdAsync(departmentId); @@ -779,12 +831,9 @@ public async Task ClearPendingRunsAsync(int departmentId, CancellationToke { run.Status = (int)WorkflowRunStatus.Cancelled; run.CompletedOn = DateTime.UtcNow; - await _runRepository.UpdateAsync(run, cancellationToken); + await UpdateRunAsync(run, cancellationToken); } return true; } } } - - - diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index f2aea5403..c28e9c716 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -402,14 +402,23 @@ public async Task BuildContextAsync( } case WorkflowTriggerEventType.ChecklistCompleted: case WorkflowTriggerEventType.ChecklistFailed: + case WorkflowTriggerEventType.ChecklistMissed: + case WorkflowTriggerEventType.ChecklistScheduleChanged: + case WorkflowTriggerEventType.ChecklistOccurrenceSkipped: { var checklistEvent = TryDeserialize(eventPayloadJson); var payload = checklistEvent?.Payload ?? new JObject(); var checklist = new ScriptObject(); - foreach (var pair in new[] { ("completion_id", "CompletionId"), ("definition_id", "DefinitionId"), ("version_id", "VersionId"), ("target_type", "TargetType"), ("target_id", "TargetId"), ("score", "Score"), ("passed", "Passed"), ("item_id", "ItemId") }) + foreach (var pair in new[] { ("completion_id", "CompletionId"), ("definition_id", "DefinitionId"), ("version_id", "VersionId"), ("target_type", "TargetType"), ("target_id", "TargetId"), ("score", "Score"), ("passed", "Passed"), ("item_id", "ItemId"), ("schedule_id", "ScheduleId"), ("occurrence_id", "OccurrenceId"), ("period_start_utc", "PeriodStartUtc"), ("window_end_utc", "WindowEndUtc"), ("state", "State"), ("is_active", "IsActive"), ("revision", "Revision") }) checklist[pair.Item1] = ToScriptValue(payload[pair.Item2]); - checklist["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Checklists/CompletionDetail/{(string)payload["CompletionId"]}"; + checklist["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Checklists/" + (payload["ScheduleId"] == null ? $"CompletionDetail/{(string)payload["CompletionId"]}" : "Due"); scriptObject["checklist"] = checklist; + scriptObject["protection"] = new ScriptObject + { + ["is_redacted"] = payload["is_redacted"]?.Value() == true, + ["redacted_fields"] = ToScriptValue(payload["redacted_fields"] ?? new JArray()), + ["catalog_version"] = payload["catalog_version"]?.Value() ?? 0 + }; break; } case WorkflowTriggerEventType.RecordCreated: @@ -1293,4 +1302,3 @@ private static T TryDeserialize(string json) where T : class } } } - diff --git a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs index 9a3748f85..4a3c3c0d4 100644 --- a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json; using Resgrid.Config; using Resgrid.Model; +using Resgrid.Model.Checklists; using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Queue; @@ -27,6 +28,9 @@ public class WorkflowEventProvider : IWorkflowEventProvider private static IDepartmentsService _departmentsService; private static ISubscriptionsService _subscriptionsService; private static IProtectedProjectionService _protectedProjectionService; + private static Lazy _history; + private static Task ProtectChecklistRunAsync(WorkflowRun run) => (_history?.Value ?? throw new InvalidOperationException("Readiness history protection is unavailable.")) + .ProtectAsync(run.DepartmentId, run.WorkflowRunId, run, ReadinessHistoryFields.Runs); // Per-minute rate limit tracker: departmentId → (window start, count) private static readonly System.Collections.Concurrent.ConcurrentDictionary _rateLimitTracker @@ -55,7 +59,7 @@ public WorkflowEventProvider( IWorkflowRunRepository runRepository, IDepartmentsService departmentsService, ISubscriptionsService subscriptionsService, - IProtectedProjectionService protectedProjectionService) + IProtectedProjectionService protectedProjectionService, Lazy history = null) { _eventAggregator = eventAggregator; _outboundQueueProvider = outboundQueueProvider; @@ -64,6 +68,7 @@ public WorkflowEventProvider( _departmentsService = departmentsService; _subscriptionsService = subscriptionsService; _protectedProjectionService = protectedProjectionService; + _history = history; RegisterListeners(); } @@ -132,17 +137,17 @@ private void RegisterListeners() // (plan section 5.6). The legacy LogAdded compatibility projection rides the same road with // TriggerEventType = LogAdded and a LogAddedEvent-shaped payload, so existing LogAdded workflows keep // their exact log.* contract without a legacy Logs row ever being written. - _eventAggregator.AddListener(e => HandleDomainEvent(e)); + _eventAggregator.AddAsyncListener(HandleDomainEventAsync); } - private static void HandleDomainEvent(DomainEventDispatchedEvent dispatched) + private static async Task HandleDomainEventAsync(DomainEventDispatchedEvent dispatched) { if (dispatched == null || !dispatched.TriggerEventType.HasValue || !Enum.IsDefined(typeof(WorkflowTriggerEventType), dispatched.TriggerEventType.Value)) return; // Replays (activation, legacy indexing, operator re-drives) must never flood workflows. - if (dispatched.IsReplay) + if (dispatched.IsReplay && dispatched.ProducerSubsystem != "Checklists") return; var trigger = (WorkflowTriggerEventType)dispatched.TriggerEventType.Value; @@ -165,11 +170,11 @@ private static void HandleDomainEvent(DomainEventDispatchedEvent dispatched) return; compatibility.DepartmentId = dispatched.DepartmentId; - HandleEvent(dispatched.DepartmentId, trigger, compatibility, dispatched); + await HandleEventAsync(dispatched.DepartmentId, trigger, compatibility, dispatched); return; } - HandleEvent(dispatched.DepartmentId, trigger, evt, dispatched); + await HandleEventAsync(dispatched.DepartmentId, trigger, evt, dispatched); } /// @@ -179,6 +184,11 @@ private static void HandleDomainEvent(DomainEventDispatchedEvent dispatched) /// with its reason rather than dropped. Legacy in-process events keep today's behaviour. /// private static async void HandleEvent(int departmentId, WorkflowTriggerEventType eventType, object eventObj, DomainEventDispatchedEvent envelope = null) + { + await HandleEventAsync(departmentId, eventType, eventObj, envelope); + } + + private static async Task HandleEventAsync(int departmentId, WorkflowTriggerEventType eventType, object eventObj, DomainEventDispatchedEvent envelope = null) { try { @@ -192,7 +202,23 @@ private static async void HandleEvent(int departmentId, WorkflowTriggerEventType if (workflows == null || workflows.Count == 0) return; - payloadJson = await _protectedProjectionService.BuildSafeWorkflowPayloadAsync(departmentId, eventObj); + payloadJson = envelope.ProducerSubsystem == "Checklists" + ? await ChecklistWorkflowPayload.ProjectAsync(departmentId, eventObj, _protectedProjectionService, wrapped: true) + : await _protectedProjectionService.BuildSafeWorkflowPayloadAsync(departmentId, eventObj); + if (envelope.ProducerSubsystem == "Checklists") + { + // Retry a run whose database insert succeeded but whose queue send did not. + // Its stable run ID is claimed atomically by the worker before executing actions. + foreach (var workflow in workflows.ToList()) + { + var existing = await _runRepository.GetByWorkflowAndEventAsync(workflow.WorkflowId, envelope.EventId); + if (existing == null) continue; + if (existing.DepartmentId != departmentId) throw new InvalidOperationException("Workflow event tenant mismatch."); + if (existing.Status == (int)WorkflowRunStatus.Pending) await RequeueChecklistRunAsync(existing, payloadJson); + workflows.Remove(workflow); + } + if (workflows.Count == 0) return; + } } // ── Plan-aware rate limiting ───────────────────────────────────────── @@ -242,8 +268,7 @@ private static async void HandleEvent(int departmentId, WorkflowTriggerEventType foreach (var workflow in workflows) { - if (await IsDuplicateAsync(workflow.WorkflowId, envelope)) - continue; + if (envelope?.ProducerSubsystem != "Checklists" && await IsDuplicateAsync(workflow.WorkflowId, envelope)) continue; var run = WorkflowRunEnvelope.Apply(new WorkflowRun { @@ -260,10 +285,17 @@ private static async void HandleEvent(int departmentId, WorkflowTriggerEventType try { + if (envelope?.ProducerSubsystem == "Checklists") await ProtectChecklistRunAsync(run); run = await _runRepository.InsertAsync(run, CancellationToken.None); } catch (Exception ex) when (envelope != null && WorkflowRunEnvelope.IsDuplicateKeyViolation(ex)) { + if (envelope.ProducerSubsystem == "Checklists") + { + var existing = await _runRepository.GetByWorkflowAndEventAsync(workflow.WorkflowId, envelope.EventId); + if (existing == null || existing.DepartmentId != departmentId) throw; + if (existing.Status == (int)WorkflowRunStatus.Pending) await RequeueChecklistRunAsync(existing, payloadJson); + } // Two dispatchers (post-commit and the worker sweep) raced on the same event; the index is the backstop. Framework.Logging.LogInfo($"Workflow {workflow.WorkflowId} already has a run for event {envelope.EventId}; duplicate dispatch ignored."); continue; @@ -281,7 +313,8 @@ private static async void HandleEvent(int departmentId, WorkflowTriggerEventType EnqueuedOn = DateTime.UtcNow }; - await _outboundQueueProvider.EnqueueWorkflow(queueItem); + if (!await _outboundQueueProvider.EnqueueWorkflow(queueItem) && envelope != null) + throw new InvalidOperationException("Workflow queue did not accept the event run."); // Increment free-plan daily counter after each successful enqueue if (isFreePlan) @@ -290,10 +323,20 @@ private static async void HandleEvent(int departmentId, WorkflowTriggerEventType } catch (Exception ex) { + if (envelope != null) throw; // The durable dispatcher owns retries and value-free failure recording. Framework.Logging.LogException(ex); } } + private static async Task RequeueChecklistRunAsync(WorkflowRun run, string safePayload) + { + if (!await _outboundQueueProvider.EnqueueWorkflow(new WorkflowQueueItem + { + WorkflowId = run.WorkflowId, WorkflowRunId = run.WorkflowRunId, DepartmentId = run.DepartmentId, + TriggerEventType = run.TriggerEventType, EventPayloadJson = safePayload, AttemptNumber = 1, EnqueuedOn = DateTime.UtcNow + })) throw new InvalidOperationException("Workflow queue did not accept the event run."); + } + /// One initial run per (WorkflowId, EventId): a retry or a second dispatcher reuses the existing run. private static async Task IsDuplicateAsync(string workflowId, DomainEventDispatchedEvent envelope) { @@ -339,6 +382,7 @@ private static async Task RecordSkippedAsync(System.Collections.Generic.List value; + private static string Q(string value) => "[" + N(value) + "]"; + public override void Up() + { + foreach (var slot in new[] { ("ChecklistCompletions", "Score"), ("ChecklistCompletions", "Passed"), ("ChecklistCompletionItems", "IsFailure") }) + if (!Schema.Table(N(slot.Item1)).Column(N("Protected" + slot.Item2 + "Envelope")).Exists()) + Alter.Table(N(slot.Item1)).AddColumn(N("Protected" + slot.Item2 + "Envelope")).AsString(int.MaxValue).Nullable(); + Alter.Column(N("Passed")).OnTable(N("ChecklistCompletions")).AsBoolean().Nullable(); + Alter.Column(N("IsFailure")).OnTable(N("ChecklistCompletionItems")).AsBoolean().Nullable(); + } + public override void Down() + { + // Never discard encrypted outcomes or invent a false result during rollback. + Execute.WithConnection((connection, transaction) => + { + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistCompletions")} WHERE {Q("ProtectedScoreEnvelope")} IS NOT NULL OR {Q("ProtectedPassedEnvelope")} IS NOT NULL OR {Q("Passed")} IS NULL) + (SELECT COUNT(*) FROM {Q("ChecklistCompletionItems")} WHERE {Q("ProtectedIsFailureEnvelope")} IS NOT NULL OR {Q("IsFailure")} IS NULL)"; + if (Convert.ToInt64(command.ExecuteScalar()) != 0) + throw new InvalidOperationException("Decrypt checklist outcomes through the authorized ADP process before rolling back migration 192."); + }); + Alter.Column(N("Passed")).OnTable(N("ChecklistCompletions")).AsBoolean().NotNullable(); + Alter.Column(N("IsFailure")).OnTable(N("ChecklistCompletionItems")).AsBoolean().NotNullable(); + Delete.Column(N("ProtectedScoreEnvelope")).FromTable(N("ChecklistCompletions")); + Delete.Column(N("ProtectedPassedEnvelope")).FromTable(N("ChecklistCompletions")); + Delete.Column(N("ProtectedIsFailureEnvelope")).FromTable(N("ChecklistCompletionItems")); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0193_ReadinessHistoryProtection.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0193_ReadinessHistoryProtection.cs new file mode 100644 index 000000000..e71f1f4b2 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0193_ReadinessHistoryProtection.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Text.Json; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(193)] + public class M0193_ReadinessHistoryProtection : Migration + { + private static string N(string value) => value; + private static string Q(string value) => "[" + N(value) + "]"; + public override void Up() + { + if (!Schema.Table(N("DomainEventOutbox")).Column(N("ReadinessRoutingJson")).Exists()) + Alter.Table(N("DomainEventOutbox")).AddColumn(N("ReadinessRoutingJson")).AsString(int.MaxValue).Nullable(); + foreach (var field in new[] { ("WorkflowRuns", "ErrorMessage"), ("WorkflowRunLogs", "ErrorMessage"), ("WorkflowRunLogs", "ActionResult") }) + Alter.Column(N(field.Item2)).OnTable(N(field.Item1)).AsString(int.MaxValue).Nullable(); + Execute.WithConnection((connection, transaction) => + { + long cursor = 0; + while (true) + { + var rows = new List<(long Id, string Payload, string Aggregate)>(); + using (var select = connection.CreateCommand()) + { + select.Transaction = transaction; + select.CommandText = $"SELECT TOP (200) {Q("DomainEventOutboxId")}, {Q("PayloadJson")}, {Q("AggregateId")} FROM {Q("DomainEventOutbox")} WHERE {Q("ProducerSubsystem")} = 'Checklists' AND {Q("ReadinessRoutingJson")} IS NULL AND {Q("DomainEventOutboxId")} > @cursor ORDER BY {Q("DomainEventOutboxId")}"; + var parameter = select.CreateParameter(); parameter.ParameterName = "@cursor"; parameter.Value = cursor; select.Parameters.Add(parameter); + using var reader = select.ExecuteReader(); + while (rows.Count < 200 && reader.Read()) rows.Add((Convert.ToInt64(reader[0]), Convert.ToString(reader[1]), Convert.ToString(reader[2]))); + } + if (rows.Count == 0) break; + foreach (var row in rows) + { + // The historical payload stays intact. Only reviewed structural facts enter the routing copy. + var safe = Routing(row.Payload, row.Aggregate); + using var update = connection.CreateCommand(); update.Transaction = transaction; + update.CommandText = $"UPDATE {Q("DomainEventOutbox")} SET {Q("ReadinessRoutingJson")} = @safe WHERE {Q("DomainEventOutboxId")} = @id"; + foreach (var value in new[] { ("@safe", (object)safe), ("@id", (object)row.Id) }) { var parameter = update.CreateParameter(); parameter.ParameterName = value.Item1; parameter.Value = value.Item2; update.Parameters.Add(parameter); } + update.ExecuteNonQuery(); cursor = row.Id; + } + } + }); + } + private static string Routing(string json, string aggregate) + { + try + { + using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json); var root = document.RootElement; + var safe = new Dictionary(); + foreach (var name in new[] { "CompletionId", "DefinitionId", "VersionId", "ItemId" }) + if (root.TryGetProperty(name, out var field) && field.ValueKind == JsonValueKind.String && Guid.TryParse(field.GetString(), out var id)) safe[name] = id.ToString("D"); + if (!safe.ContainsKey("CompletionId") && Guid.TryParse(aggregate, out var completion)) safe["CompletionId"] = completion.ToString("D"); + var type = root.TryGetProperty("TargetType", out var targetType) && targetType.TryGetInt32(out var parsed) ? parsed : -1; + if (type >= 0) safe["TargetType"] = type; + var redacted = new List { "Score", "Passed" }; + if (root.TryGetProperty("TargetId", out var target) && target.ValueKind == JsonValueKind.String) + { + var text = target.GetString(); + var structural = type >= 0 && type <= 2 && int.TryParse(text, out var numeric) && numeric > 0 || type == 5 && Guid.TryParse(text, out _); + safe["TargetId"] = structural ? text : "REDACTED"; + if (!structural) redacted.Add("TargetId"); + } + safe["Score"] = "REDACTED"; safe["Passed"] = "REDACTED"; safe["is_redacted"] = true; safe["redacted_fields"] = redacted; safe["catalog_version"] = 16; + return JsonSerializer.Serialize(safe); + } + catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException) + { throw new InvalidOperationException("A historical checklist event requires repair before its safe routing payload can be created."); } + } + public override void Down() + { + Execute.WithConnection((connection, transaction) => + { + foreach (var field in new[] { ("AuditLogs", "Data"), ("DomainEventOutbox", "PayloadJson"), ("DomainEventOutbox", "LastError"), ("WorkflowRuns", "InputPayload"), ("WorkflowRuns", "ErrorMessage"), ("WorkflowRunLogs", "RenderedOutput"), ("WorkflowRunLogs", "ErrorMessage"), ("WorkflowRunLogs", "ActionResult") }) + { + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = $"SELECT COUNT(*) FROM {Q(field.Item1)} WHERE {Q(field.Item2)} LIKE 'rgdp:%'"; + if (Convert.ToInt64(command.ExecuteScalar()) > 0) throw new InvalidOperationException("Complete ADP offboarding before rolling back readiness history protection."); + } + }); + // Keep widened columns: narrowing may truncate legitimate historical content. + Delete.Column(N("ReadinessRoutingJson")).FromTable(N("DomainEventOutbox")); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0194_AddChecklistScheduling.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0194_AddChecklistScheduling.cs new file mode 100644 index 000000000..afef6e827 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0194_AddChecklistScheduling.cs @@ -0,0 +1,73 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(194)] + public class M0194_AddChecklistScheduling : Migration + { + private static string N(string value) => value; + private static string Q(string value) => "[" + N(value) + "]"; + public override void Up() + { + Create.Table(N("ChecklistSchedules")) + .WithColumn(N("Id")).AsString(36).PrimaryKey() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("ParentId")).AsString(36).NotNullable() + .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() + .WithColumn(N("Revision")).AsInt32().NotNullable() + .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable() + .WithColumn(N("VersionId")).AsString(36).NotNullable() + .WithColumn(N("TargetType")).AsInt32().NotNullable() + .WithColumn(N("TargetId")).AsString(128).NotNullable() + .WithColumn(N("TargetGroupId")).AsInt32().Nullable() + .WithColumn(N("Frequency")).AsInt32().NotNullable() + .WithColumn(N("TimeZoneId")).AsString(128).NotNullable() + .WithColumn(N("StartDate")).AsDate().NotNullable() + .WithColumn(N("EndDate")).AsDate().Nullable() + .WithColumn(N("ClockMinutes")).AsString(64).NotNullable() + .WithColumn(N("Weekdays")).AsInt32().NotNullable() + .WithColumn(N("DayOfMonth")).AsInt32().NotNullable() + .WithColumn(N("MonthOfYear")).AsInt32().NotNullable() + .WithColumn(N("WindowMinutes")).AsInt32().NotNullable() + .WithColumn(N("WorkshiftId")).AsString(36).Nullable() + .WithColumn(N("IsActive")).AsBoolean().NotNullable() + .WithColumn(N("IsSuspended")).AsBoolean().NotNullable() + .WithColumn(N("ActiveFromUtc")).AsDateTime2().NotNullable() + .WithColumn(N("GeneratedThroughUtc")).AsDateTime2().NotNullable() + .WithColumn(N("LastSweepUtc")).AsDateTime2().NotNullable(); + Create.Index(N("UX_ChecklistSchedules_TenantId")).OnTable(N("ChecklistSchedules")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("Id")).Ascending().WithOptions().Unique(); + Create.Index(N("IX_ChecklistSchedules_ParentDate")).OnTable(N("ChecklistSchedules")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("ParentId")).Ascending().OnColumn(N("CreatedOn")).Descending(); + Create.Index(N("IX_ChecklistSchedules_Active")).OnTable(N("ChecklistSchedules")).OnColumn(N("IsActive")).Ascending().OnColumn(N("DepartmentId")).Ascending(); + Foreign("ChecklistSchedules", "ParentId", "ChecklistDefinitions"); + Foreign("ChecklistSchedules", "VersionId", "ChecklistDefinitionVersions"); + Alter.Table(N("ChecklistOccurrences")) + .AddColumn(N("ScheduleId")).AsString(36).Nullable() + .AddColumn(N("ScheduleRevision")).AsInt32().Nullable() + .AddColumn(N("PeriodStartUtc")).AsDateTime2().Nullable() + .AddColumn(N("WindowEndUtc")).AsDateTime2().Nullable() + .AddColumn(N("MissedOn")).AsDateTime2().Nullable(); + Foreign("ChecklistOccurrences", "ScheduleId", "ChecklistSchedules"); + Execute.Sql($"CREATE UNIQUE INDEX {Q("UX_ChecklistOccurrences_Period")} ON {Q("ChecklistOccurrences")} ({Q("DepartmentId")},{Q("ScheduleId")},{Q("ScheduleRevision")},{Q("PeriodStartUtc")}) WHERE {Q("ScheduleId")} IS NOT NULL"); + Create.Index(N("IX_ChecklistOccurrences_Due")).OnTable(N("ChecklistOccurrences")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("State")).Ascending().OnColumn(N("WindowEndUtc")).Ascending(); + } + private void Foreign(string child, string column, string parent) => Create.ForeignKey(N("FK_" + child + "_" + column)).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N(column)).ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + public override void Down() + { + Execute.WithConnection((connection, transaction) => + { + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistSchedules")}) + (SELECT COUNT(*) FROM {Q("ChecklistOccurrences")} WHERE {Q("ScheduleId")} IS NOT NULL)"; + if (Convert.ToInt64(command.ExecuteScalar()) > 0) throw new InvalidOperationException("Scheduling history must be preserved; populated scheduling storage cannot be rolled back."); + }); + Delete.ForeignKey(N("FK_ChecklistOccurrences_ScheduleId")).OnTable(N("ChecklistOccurrences")); + Delete.Index(N("UX_ChecklistOccurrences_Period")).OnTable(N("ChecklistOccurrences")); + Delete.Index(N("IX_ChecklistOccurrences_Due")).OnTable(N("ChecklistOccurrences")); + foreach (var column in new[] { "ScheduleId", "ScheduleRevision", "PeriodStartUtc", "WindowEndUtc", "MissedOn" }) Delete.Column(N(column)).FromTable(N("ChecklistOccurrences")); + Delete.Table(N("ChecklistSchedules")); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0195_AddChecklistReminders.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0195_AddChecklistReminders.cs new file mode 100644 index 000000000..c6d6f7dcd --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0195_AddChecklistReminders.cs @@ -0,0 +1,49 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(195)] + public class M0195_AddChecklistReminders : Migration + { + private static string N(string value) => value; + private static string Q(string value) => "[" + N(value) + "]"; + public override void Up() + { + Alter.Table(N("DepartmentChecklistSettings")) + .AddColumn(N("RemindersEnabled")).AsBoolean().NotNullable().WithDefaultValue(false) + .AddColumn(N("NotifyBeforeMinutes")).AsInt32().NotNullable().WithDefaultValue(60) + .AddColumn(N("NotifyMissed")).AsBoolean().NotNullable().WithDefaultValue(true) + .AddColumn(N("DigestMode")).AsBoolean().NotNullable().WithDefaultValue(true) + .AddColumn(N("EscalateAfterMinutes")).AsInt32().Nullable() + .AddColumn(N("RemindersActiveFromUtc")).AsDateTime2().Nullable(); + Create.Table(N("ChecklistReminders")) + .WithColumn(N("Id")).AsString(36).PrimaryKey() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("OccurrenceId")).AsString(36).NotNullable() + .WithColumn(N("RecipientUserId")).AsString(128).NotNullable() + .WithColumn(N("Kind")).AsInt32().NotNullable() + .WithColumn(N("Status")).AsInt32().NotNullable() + .WithColumn(N("CreatedOnUtc")).AsDateTime2().NotNullable() + .WithColumn(N("NextAttemptUtc")).AsDateTime2().NotNullable() + .WithColumn(N("Attempts")).AsInt32().NotNullable() + .WithColumn(N("ClaimToken")).AsString(36).Nullable() + .WithColumn(N("ClaimUntilUtc")).AsDateTime2().Nullable() + .WithColumn(N("CompletedOnUtc")).AsDateTime2().Nullable(); + Create.ForeignKey(N("FK_ChecklistReminders_Occurrence")).FromTable(N("ChecklistReminders")).ForeignColumns(N("DepartmentId"), N("OccurrenceId")).ToTable(N("ChecklistOccurrences")).PrimaryColumns(N("DepartmentId"), N("Id")); + Create.Index(N("UX_ChecklistReminders_Recipient")).OnTable(N("ChecklistReminders")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("OccurrenceId")).Ascending().OnColumn(N("RecipientUserId")).Ascending().OnColumn(N("Kind")).Ascending().WithOptions().Unique(); + Create.Index(N("IX_ChecklistReminders_Pending")).OnTable(N("ChecklistReminders")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("Status")).Ascending().OnColumn(N("NextAttemptUtc")).Ascending(); + } + public override void Down() + { + Execute.WithConnection((connection, transaction) => + { + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistReminders")}) + (SELECT COUNT(*) FROM {Q("DepartmentChecklistSettings")})"; + if (Convert.ToInt64(command.ExecuteScalar()) > 0) throw new InvalidOperationException("Reminder settings and delivery history must be preserved; populated reminder storage cannot be rolled back."); + }); + Delete.Table(N("ChecklistReminders")); + foreach (var column in new[] { "RemindersEnabled", "NotifyBeforeMinutes", "NotifyMissed", "DigestMode", "EscalateAfterMinutes", "RemindersActiveFromUtc" }) Delete.Column(N(column)).FromTable(N("DepartmentChecklistSettings")); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0196_CompleteChecklistScheduling.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0196_CompleteChecklistScheduling.cs new file mode 100644 index 000000000..7009c8ca5 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0196_CompleteChecklistScheduling.cs @@ -0,0 +1,42 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(196)] + public class M0196_CompleteChecklistScheduling : Migration + { + private static string N(string value) => value; + private static string Q(string value) => "[" + N(value) + "]"; + public override void Up() + { + Create.Table(N("ChecklistAccessFence")).WithColumn(N("Id")).AsInt32().PrimaryKey(); + Insert.IntoTable(N("ChecklistAccessFence")).Row(new System.Collections.Generic.Dictionary { [N("Id")] = 1 }); + Alter.Table(N("ChecklistSchedules")).AddColumn(N("AssignmentType")).AsInt32().NotNullable().WithDefaultValue(0).AddColumn(N("AssignmentId")).AsString(128).Nullable(); + Alter.Table(N("DepartmentChecklistSettings")).AddColumn(N("NotifyAtShiftStart")).AsBoolean().NotNullable().WithDefaultValue(true) + .AddColumn(N("FixedDigestMinute")).AsInt32().Nullable().AddColumn(N("LastDigestSweepUtc")).AsDateTime2().Nullable().AddColumn(N("DigestActiveFromUtc")).AsDateTime2().Nullable(); + Alter.Table(N("ChecklistReminders")).AddColumn(N("PeriodKey")).AsString(80).NotNullable().WithDefaultValue(""); + Delete.Index(N("UX_ChecklistReminders_Recipient")).OnTable(N("ChecklistReminders")); + RecipientIndex(true); + } + private void RecipientIndex(bool period) + { + var index = Create.Index(N("UX_ChecklistReminders_Recipient")).OnTable(N("ChecklistReminders")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("OccurrenceId")).Ascending().OnColumn(N("RecipientUserId")).Ascending().OnColumn(N("Kind")).Ascending(); + if (period) index = index.OnColumn(N("PeriodKey")).Ascending(); + index.WithOptions().Unique(); + } + public override void Down() + { + Execute.WithConnection((connection, transaction) => + { + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistSchedules")} WHERE {Q("AssignmentType")}<>0) + (SELECT COUNT(*) FROM {Q("ChecklistReminders")} WHERE {Q("PeriodKey")}<>'') + (SELECT COUNT(*) FROM {Q("DepartmentChecklistSettings")} WHERE {Q("DigestActiveFromUtc")} IS NOT NULL OR {Q("NotifyAtShiftStart")} = 0 OR {Q("LastDigestSweepUtc")} IS NOT NULL OR {Q("FixedDigestMinute")} IS NOT NULL)"; + if (Convert.ToInt64(command.ExecuteScalar()) > 0) throw new InvalidOperationException("Assignment and digest history must be preserved before rollback."); + }); + Delete.Index(N("UX_ChecklistReminders_Recipient")).OnTable(N("ChecklistReminders")); RecipientIndex(false); + Delete.Column(N("PeriodKey")).FromTable(N("ChecklistReminders")); + foreach (var column in new[] { "NotifyAtShiftStart", "FixedDigestMinute", "LastDigestSweepUtc", "DigestActiveFromUtc" }) Delete.Column(N(column)).FromTable(N("DepartmentChecklistSettings")); + Delete.Table(N("ChecklistAccessFence")); Delete.Column(N("AssignmentType")).FromTable(N("ChecklistSchedules")); Delete.Column(N("AssignmentId")).FromTable(N("ChecklistSchedules")); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0192_ProtectChecklistOutcomesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0192_ProtectChecklistOutcomesPg.cs new file mode 100644 index 000000000..dc0a95edb --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0192_ProtectChecklistOutcomesPg.cs @@ -0,0 +1,36 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(192)] + public class M0192_ProtectChecklistOutcomesPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + private static string Q(string value) => "\"" + N(value) + "\""; + public override void Up() + { + foreach (var slot in new[] { ("ChecklistCompletions", "Score"), ("ChecklistCompletions", "Passed"), ("ChecklistCompletionItems", "IsFailure") }) + if (!Schema.Table(N(slot.Item1)).Column(N("Protected" + slot.Item2 + "Envelope")).Exists()) + Alter.Table(N(slot.Item1)).AddColumn(N("Protected" + slot.Item2 + "Envelope")).AsString(int.MaxValue).Nullable(); + Alter.Column(N("Passed")).OnTable(N("ChecklistCompletions")).AsBoolean().Nullable(); + Alter.Column(N("IsFailure")).OnTable(N("ChecklistCompletionItems")).AsBoolean().Nullable(); + } + public override void Down() + { + // Never discard encrypted outcomes or invent a false result during rollback. + Execute.WithConnection((connection, transaction) => + { + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistCompletions")} WHERE {Q("ProtectedScoreEnvelope")} IS NOT NULL OR {Q("ProtectedPassedEnvelope")} IS NOT NULL OR {Q("Passed")} IS NULL) + (SELECT COUNT(*) FROM {Q("ChecklistCompletionItems")} WHERE {Q("ProtectedIsFailureEnvelope")} IS NOT NULL OR {Q("IsFailure")} IS NULL)"; + if (Convert.ToInt64(command.ExecuteScalar()) != 0) + throw new InvalidOperationException("Decrypt checklist outcomes through the authorized ADP process before rolling back migration 192."); + }); + Alter.Column(N("Passed")).OnTable(N("ChecklistCompletions")).AsBoolean().NotNullable(); + Alter.Column(N("IsFailure")).OnTable(N("ChecklistCompletionItems")).AsBoolean().NotNullable(); + Delete.Column(N("ProtectedScoreEnvelope")).FromTable(N("ChecklistCompletions")); + Delete.Column(N("ProtectedPassedEnvelope")).FromTable(N("ChecklistCompletions")); + Delete.Column(N("ProtectedIsFailureEnvelope")).FromTable(N("ChecklistCompletionItems")); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0193_ReadinessHistoryProtectionPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0193_ReadinessHistoryProtectionPg.cs new file mode 100644 index 000000000..a1235fef0 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0193_ReadinessHistoryProtectionPg.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Text.Json; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(193)] + public class M0193_ReadinessHistoryProtectionPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + private static string Q(string value) => "\"" + N(value) + "\""; + public override void Up() + { + if (!Schema.Table(N("DomainEventOutbox")).Column(N("ReadinessRoutingJson")).Exists()) + Alter.Table(N("DomainEventOutbox")).AddColumn(N("ReadinessRoutingJson")).AsString(int.MaxValue).Nullable(); + foreach (var field in new[] { ("WorkflowRuns", "ErrorMessage"), ("WorkflowRunLogs", "ErrorMessage"), ("WorkflowRunLogs", "ActionResult") }) + Alter.Column(N(field.Item2)).OnTable(N(field.Item1)).AsString(int.MaxValue).Nullable(); + Execute.WithConnection((connection, transaction) => + { + long cursor = 0; + while (true) + { + var rows = new List<(long Id, string Payload, string Aggregate)>(); + using (var select = connection.CreateCommand()) + { + select.Transaction = transaction; + select.CommandText = $"SELECT {Q("DomainEventOutboxId")}, {Q("PayloadJson")}, {Q("AggregateId")} FROM {Q("DomainEventOutbox")} WHERE {Q("ProducerSubsystem")} = 'Checklists' AND {Q("ReadinessRoutingJson")} IS NULL AND {Q("DomainEventOutboxId")} > @cursor ORDER BY {Q("DomainEventOutboxId")} LIMIT 200"; + var parameter = select.CreateParameter(); parameter.ParameterName = "@cursor"; parameter.Value = cursor; select.Parameters.Add(parameter); + using var reader = select.ExecuteReader(); + while (rows.Count < 200 && reader.Read()) rows.Add((Convert.ToInt64(reader[0]), Convert.ToString(reader[1]), Convert.ToString(reader[2]))); + } + if (rows.Count == 0) break; + foreach (var row in rows) + { + // The historical payload stays intact. Only reviewed structural facts enter the routing copy. + var safe = Routing(row.Payload, row.Aggregate); + using var update = connection.CreateCommand(); update.Transaction = transaction; + update.CommandText = $"UPDATE {Q("DomainEventOutbox")} SET {Q("ReadinessRoutingJson")} = @safe WHERE {Q("DomainEventOutboxId")} = @id"; + foreach (var value in new[] { ("@safe", (object)safe), ("@id", (object)row.Id) }) { var parameter = update.CreateParameter(); parameter.ParameterName = value.Item1; parameter.Value = value.Item2; update.Parameters.Add(parameter); } + update.ExecuteNonQuery(); cursor = row.Id; + } + } + }); + } + private static string Routing(string json, string aggregate) + { + try + { + using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json); var root = document.RootElement; + var safe = new Dictionary(); + foreach (var name in new[] { "CompletionId", "DefinitionId", "VersionId", "ItemId" }) + if (root.TryGetProperty(name, out var field) && field.ValueKind == JsonValueKind.String && Guid.TryParse(field.GetString(), out var id)) safe[name] = id.ToString("D"); + if (!safe.ContainsKey("CompletionId") && Guid.TryParse(aggregate, out var completion)) safe["CompletionId"] = completion.ToString("D"); + var type = root.TryGetProperty("TargetType", out var targetType) && targetType.TryGetInt32(out var parsed) ? parsed : -1; + if (type >= 0) safe["TargetType"] = type; + var redacted = new List { "Score", "Passed" }; + if (root.TryGetProperty("TargetId", out var target) && target.ValueKind == JsonValueKind.String) + { + var text = target.GetString(); + var structural = type >= 0 && type <= 2 && int.TryParse(text, out var numeric) && numeric > 0 || type == 5 && Guid.TryParse(text, out _); + safe["TargetId"] = structural ? text : "REDACTED"; + if (!structural) redacted.Add("TargetId"); + } + safe["Score"] = "REDACTED"; safe["Passed"] = "REDACTED"; safe["is_redacted"] = true; safe["redacted_fields"] = redacted; safe["catalog_version"] = 16; + return JsonSerializer.Serialize(safe); + } + catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException) + { throw new InvalidOperationException("A historical checklist event requires repair before its safe routing payload can be created."); } + } + public override void Down() + { + Execute.WithConnection((connection, transaction) => + { + foreach (var field in new[] { ("AuditLogs", "Data"), ("DomainEventOutbox", "PayloadJson"), ("DomainEventOutbox", "LastError"), ("WorkflowRuns", "InputPayload"), ("WorkflowRuns", "ErrorMessage"), ("WorkflowRunLogs", "RenderedOutput"), ("WorkflowRunLogs", "ErrorMessage"), ("WorkflowRunLogs", "ActionResult") }) + { + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = $"SELECT COUNT(*) FROM {Q(field.Item1)} WHERE {Q(field.Item2)} LIKE 'rgdp:%'"; + if (Convert.ToInt64(command.ExecuteScalar()) > 0) throw new InvalidOperationException("Complete ADP offboarding before rolling back readiness history protection."); + } + }); + // Keep widened columns: narrowing may truncate legitimate historical content. + Delete.Column(N("ReadinessRoutingJson")).FromTable(N("DomainEventOutbox")); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0194_AddChecklistSchedulingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0194_AddChecklistSchedulingPg.cs new file mode 100644 index 000000000..666679f16 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0194_AddChecklistSchedulingPg.cs @@ -0,0 +1,73 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(194)] + public class M0194_AddChecklistSchedulingPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + private static string Q(string value) => "\"" + N(value) + "\""; + public override void Up() + { + Create.Table(N("ChecklistSchedules")) + .WithColumn(N("Id")).AsString(36).PrimaryKey() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("ParentId")).AsString(36).NotNullable() + .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() + .WithColumn(N("Revision")).AsInt32().NotNullable() + .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable() + .WithColumn(N("VersionId")).AsString(36).NotNullable() + .WithColumn(N("TargetType")).AsInt32().NotNullable() + .WithColumn(N("TargetId")).AsString(128).NotNullable() + .WithColumn(N("TargetGroupId")).AsInt32().Nullable() + .WithColumn(N("Frequency")).AsInt32().NotNullable() + .WithColumn(N("TimeZoneId")).AsString(128).NotNullable() + .WithColumn(N("StartDate")).AsDate().NotNullable() + .WithColumn(N("EndDate")).AsDate().Nullable() + .WithColumn(N("ClockMinutes")).AsString(64).NotNullable() + .WithColumn(N("Weekdays")).AsInt32().NotNullable() + .WithColumn(N("DayOfMonth")).AsInt32().NotNullable() + .WithColumn(N("MonthOfYear")).AsInt32().NotNullable() + .WithColumn(N("WindowMinutes")).AsInt32().NotNullable() + .WithColumn(N("WorkshiftId")).AsString(36).Nullable() + .WithColumn(N("IsActive")).AsBoolean().NotNullable() + .WithColumn(N("IsSuspended")).AsBoolean().NotNullable() + .WithColumn(N("ActiveFromUtc")).AsDateTime2().NotNullable() + .WithColumn(N("GeneratedThroughUtc")).AsDateTime2().NotNullable() + .WithColumn(N("LastSweepUtc")).AsDateTime2().NotNullable(); + Create.Index(N("UX_ChecklistSchedules_TenantId")).OnTable(N("ChecklistSchedules")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("Id")).Ascending().WithOptions().Unique(); + Create.Index(N("IX_ChecklistSchedules_ParentDate")).OnTable(N("ChecklistSchedules")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("ParentId")).Ascending().OnColumn(N("CreatedOn")).Descending(); + Create.Index(N("IX_ChecklistSchedules_Active")).OnTable(N("ChecklistSchedules")).OnColumn(N("IsActive")).Ascending().OnColumn(N("DepartmentId")).Ascending(); + Foreign("ChecklistSchedules", "ParentId", "ChecklistDefinitions"); + Foreign("ChecklistSchedules", "VersionId", "ChecklistDefinitionVersions"); + Alter.Table(N("ChecklistOccurrences")) + .AddColumn(N("ScheduleId")).AsString(36).Nullable() + .AddColumn(N("ScheduleRevision")).AsInt32().Nullable() + .AddColumn(N("PeriodStartUtc")).AsDateTime2().Nullable() + .AddColumn(N("WindowEndUtc")).AsDateTime2().Nullable() + .AddColumn(N("MissedOn")).AsDateTime2().Nullable(); + Foreign("ChecklistOccurrences", "ScheduleId", "ChecklistSchedules"); + Execute.Sql($"CREATE UNIQUE INDEX {Q("UX_ChecklistOccurrences_Period")} ON {Q("ChecklistOccurrences")} ({Q("DepartmentId")},{Q("ScheduleId")},{Q("ScheduleRevision")},{Q("PeriodStartUtc")}) WHERE {Q("ScheduleId")} IS NOT NULL"); + Create.Index(N("IX_ChecklistOccurrences_Due")).OnTable(N("ChecklistOccurrences")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("State")).Ascending().OnColumn(N("WindowEndUtc")).Ascending(); + } + private void Foreign(string child, string column, string parent) => Create.ForeignKey(N("FK_" + child + "_" + column)).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N(column)).ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + public override void Down() + { + Execute.WithConnection((connection, transaction) => + { + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistSchedules")}) + (SELECT COUNT(*) FROM {Q("ChecklistOccurrences")} WHERE {Q("ScheduleId")} IS NOT NULL)"; + if (Convert.ToInt64(command.ExecuteScalar()) > 0) throw new InvalidOperationException("Scheduling history must be preserved; populated scheduling storage cannot be rolled back."); + }); + Delete.ForeignKey(N("FK_ChecklistOccurrences_ScheduleId")).OnTable(N("ChecklistOccurrences")); + Delete.Index(N("UX_ChecklistOccurrences_Period")).OnTable(N("ChecklistOccurrences")); + Delete.Index(N("IX_ChecklistOccurrences_Due")).OnTable(N("ChecklistOccurrences")); + foreach (var column in new[] { "ScheduleId", "ScheduleRevision", "PeriodStartUtc", "WindowEndUtc", "MissedOn" }) Delete.Column(N(column)).FromTable(N("ChecklistOccurrences")); + Delete.Table(N("ChecklistSchedules")); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0195_AddChecklistRemindersPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0195_AddChecklistRemindersPg.cs new file mode 100644 index 000000000..1dd907b4d --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0195_AddChecklistRemindersPg.cs @@ -0,0 +1,49 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(195)] + public class M0195_AddChecklistRemindersPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + private static string Q(string value) => "\"" + N(value) + "\""; + public override void Up() + { + Alter.Table(N("DepartmentChecklistSettings")) + .AddColumn(N("RemindersEnabled")).AsBoolean().NotNullable().WithDefaultValue(false) + .AddColumn(N("NotifyBeforeMinutes")).AsInt32().NotNullable().WithDefaultValue(60) + .AddColumn(N("NotifyMissed")).AsBoolean().NotNullable().WithDefaultValue(true) + .AddColumn(N("DigestMode")).AsBoolean().NotNullable().WithDefaultValue(true) + .AddColumn(N("EscalateAfterMinutes")).AsInt32().Nullable() + .AddColumn(N("RemindersActiveFromUtc")).AsDateTime2().Nullable(); + Create.Table(N("ChecklistReminders")) + .WithColumn(N("Id")).AsString(36).PrimaryKey() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("OccurrenceId")).AsString(36).NotNullable() + .WithColumn(N("RecipientUserId")).AsString(128).NotNullable() + .WithColumn(N("Kind")).AsInt32().NotNullable() + .WithColumn(N("Status")).AsInt32().NotNullable() + .WithColumn(N("CreatedOnUtc")).AsDateTime2().NotNullable() + .WithColumn(N("NextAttemptUtc")).AsDateTime2().NotNullable() + .WithColumn(N("Attempts")).AsInt32().NotNullable() + .WithColumn(N("ClaimToken")).AsString(36).Nullable() + .WithColumn(N("ClaimUntilUtc")).AsDateTime2().Nullable() + .WithColumn(N("CompletedOnUtc")).AsDateTime2().Nullable(); + Create.ForeignKey(N("FK_ChecklistReminders_Occurrence")).FromTable(N("ChecklistReminders")).ForeignColumns(N("DepartmentId"), N("OccurrenceId")).ToTable(N("ChecklistOccurrences")).PrimaryColumns(N("DepartmentId"), N("Id")); + Create.Index(N("UX_ChecklistReminders_Recipient")).OnTable(N("ChecklistReminders")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("OccurrenceId")).Ascending().OnColumn(N("RecipientUserId")).Ascending().OnColumn(N("Kind")).Ascending().WithOptions().Unique(); + Create.Index(N("IX_ChecklistReminders_Pending")).OnTable(N("ChecklistReminders")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("Status")).Ascending().OnColumn(N("NextAttemptUtc")).Ascending(); + } + public override void Down() + { + Execute.WithConnection((connection, transaction) => + { + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistReminders")}) + (SELECT COUNT(*) FROM {Q("DepartmentChecklistSettings")})"; + if (Convert.ToInt64(command.ExecuteScalar()) > 0) throw new InvalidOperationException("Reminder settings and delivery history must be preserved; populated reminder storage cannot be rolled back."); + }); + Delete.Table(N("ChecklistReminders")); + foreach (var column in new[] { "RemindersEnabled", "NotifyBeforeMinutes", "NotifyMissed", "DigestMode", "EscalateAfterMinutes", "RemindersActiveFromUtc" }) Delete.Column(N(column)).FromTable(N("DepartmentChecklistSettings")); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0196_CompleteChecklistSchedulingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0196_CompleteChecklistSchedulingPg.cs new file mode 100644 index 000000000..550037b40 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0196_CompleteChecklistSchedulingPg.cs @@ -0,0 +1,42 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(196)] + public class M0196_CompleteChecklistSchedulingPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + private static string Q(string value) => "\"" + N(value) + "\""; + public override void Up() + { + Create.Table(N("ChecklistAccessFence")).WithColumn(N("Id")).AsInt32().PrimaryKey(); + Insert.IntoTable(N("ChecklistAccessFence")).Row(new System.Collections.Generic.Dictionary { [N("Id")] = 1 }); + Alter.Table(N("ChecklistSchedules")).AddColumn(N("AssignmentType")).AsInt32().NotNullable().WithDefaultValue(0).AddColumn(N("AssignmentId")).AsString(128).Nullable(); + Alter.Table(N("DepartmentChecklistSettings")).AddColumn(N("NotifyAtShiftStart")).AsBoolean().NotNullable().WithDefaultValue(true) + .AddColumn(N("FixedDigestMinute")).AsInt32().Nullable().AddColumn(N("LastDigestSweepUtc")).AsDateTime2().Nullable().AddColumn(N("DigestActiveFromUtc")).AsDateTime2().Nullable(); + Alter.Table(N("ChecklistReminders")).AddColumn(N("PeriodKey")).AsString(80).NotNullable().WithDefaultValue(""); + Delete.Index(N("UX_ChecklistReminders_Recipient")).OnTable(N("ChecklistReminders")); + RecipientIndex(true); + } + private void RecipientIndex(bool period) + { + var index = Create.Index(N("UX_ChecklistReminders_Recipient")).OnTable(N("ChecklistReminders")).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("OccurrenceId")).Ascending().OnColumn(N("RecipientUserId")).Ascending().OnColumn(N("Kind")).Ascending(); + if (period) index = index.OnColumn(N("PeriodKey")).Ascending(); + index.WithOptions().Unique(); + } + public override void Down() + { + Execute.WithConnection((connection, transaction) => + { + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = $"SELECT (SELECT COUNT(*) FROM {Q("ChecklistSchedules")} WHERE {Q("AssignmentType")}<>0) + (SELECT COUNT(*) FROM {Q("ChecklistReminders")} WHERE {Q("PeriodKey")}<>'') + (SELECT COUNT(*) FROM {Q("DepartmentChecklistSettings")} WHERE {Q("DigestActiveFromUtc")} IS NOT NULL OR {Q("NotifyAtShiftStart")} = false OR {Q("LastDigestSweepUtc")} IS NOT NULL OR {Q("FixedDigestMinute")} IS NOT NULL)"; + if (Convert.ToInt64(command.ExecuteScalar()) > 0) throw new InvalidOperationException("Assignment and digest history must be preserved before rollback."); + }); + Delete.Index(N("UX_ChecklistReminders_Recipient")).OnTable(N("ChecklistReminders")); RecipientIndex(false); + Delete.Column(N("PeriodKey")).FromTable(N("ChecklistReminders")); + foreach (var column in new[] { "NotifyAtShiftStart", "FixedDigestMinute", "LastDigestSweepUtc", "DigestActiveFromUtc" }) Delete.Column(N(column)).FromTable(N("DepartmentChecklistSettings")); + Delete.Table(N("ChecklistAccessFence")); Delete.Column(N("AssignmentType")).FromTable(N("ChecklistSchedules")); Delete.Column(N("AssignmentId")).FromTable(N("ChecklistSchedules")); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs new file mode 100644 index 000000000..4f9a2f331 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs @@ -0,0 +1,50 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using CommandDefinition = Dapper.CommandDefinition; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Checklists; + +namespace Resgrid.Repositories.DataRepository +{ + /// Readiness subtree of an authorized department deletion, in the caller's transaction. + /// This is not an independent purge API or a substitute for department retention authorization. + public static class ChecklistDepartmentCleanup + { + public static async Task DeleteWithinTransactionAsync(DbConnection connection, DbTransaction transaction, int departmentId, DatabaseTypes type, CancellationToken ct = default) + { + if (transaction == null || transaction.Connection != connection || departmentId <= 0) throw new InvalidOperationException("Department cleanup requires a scoped transaction."); + var pg = type == DatabaseTypes.Postgres; + string Q(string value) => pg ? value.ToLowerInvariant() : "[" + value + "]"; + async Task Exists(string table) + { + var result = await connection.ExecuteScalarAsync(new CommandDefinition(pg ? "SELECT CASE WHEN to_regclass(@Name) IS NULL THEN 0 ELSE 1 END" : "SELECT CASE WHEN OBJECT_ID(@Name,'U') IS NOT NULL THEN 1 WHEN HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','VIEW DEFINITION')=1 THEN 0 ELSE -1 END", new { Name = (pg ? "public." + table.ToLowerInvariant() : "dbo." + table) }, transaction, cancellationToken: ct)); + if (result < 0) throw new InvalidOperationException("Cannot verify the department cleanup schema."); return result == 1; + } + if (!await Exists("ChecklistDefinitions")) return; + if (await Exists("ChecklistAccessFence")) await connection.ExecuteScalarAsync(new CommandDefinition(pg ? "SELECT id FROM checklistaccessfence WHERE id=1 FOR UPDATE" : "SELECT Id FROM ChecklistAccessFence WITH (UPDLOCK,HOLDLOCK) WHERE Id=1", transaction: transaction, cancellationToken: ct)); + await connection.ExecuteScalarAsync(new CommandDefinition(pg ? "SELECT departmentid FROM departments WHERE departmentid=@DepartmentId FOR UPDATE" : "SELECT DepartmentId FROM Departments WITH (UPDLOCK,HOLDLOCK) WHERE DepartmentId=@DepartmentId", new { DepartmentId = departmentId }, transaction, cancellationToken: ct)); + // RMS uses the same department lock for legal holds. Preserve source readiness evidence + // conservatively when any active hold exists, even if its content is encrypted. + if (await Exists("RmsRecordLegalHolds") && await connection.ExecuteScalarAsync(new CommandDefinition($"SELECT COUNT(*) FROM {Q("RmsRecordLegalHolds")} WHERE {Q("DepartmentId")}=@DepartmentId AND {Q("ReleasedOn")} IS NULL", new { DepartmentId = departmentId }, transaction, cancellationToken: ct)) > 0) + throw new InvalidOperationException("Department readiness evidence is retained under an active legal hold."); + var triggers = new[] { WorkflowTriggerEventType.ChecklistCompleted, WorkflowTriggerEventType.ChecklistFailed, WorkflowTriggerEventType.ChecklistMissed, WorkflowTriggerEventType.ChecklistScheduleChanged, WorkflowTriggerEventType.ChecklistOccurrenceSkipped }.Select(t => (int)t).ToArray(); + var triggerPredicate = Q("TriggerEventType") + (pg ? "=ANY(@Triggers)" : " IN @Triggers"); + var auditPredicate = Q("LogType") + (pg ? "=ANY(@AuditTypes)" : " IN @AuditTypes"); + var parameters = new { DepartmentId = departmentId, Triggers = triggers, AuditTypes = ReadinessHistoryFields.AuditTypes }; + if (await Exists("WorkflowRuns")) + { + if (await Exists("WorkflowRunLogs")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("WorkflowRunLogs")} WHERE {Q("WorkflowRunId")} IN (SELECT {Q("WorkflowRunId")} FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=@DepartmentId AND {triggerPredicate})", parameters, transaction, cancellationToken: ct)); + await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=@DepartmentId AND {triggerPredicate}", parameters, transaction, cancellationToken: ct)); + } + if (await Exists("DomainEventOutbox")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("DomainEventOutbox")} WHERE {Q("DepartmentId")}=@DepartmentId AND {Q("ProducerSubsystem")}='Checklists'", parameters, transaction, cancellationToken: ct)); + if (await Exists("AuditLogs")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("AuditLogs")} WHERE {Q("DepartmentId")}=@DepartmentId AND {auditPredicate}", parameters, transaction, cancellationToken: ct)); + foreach (var table in new[] { "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) + if (await Exists(table)) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q(table)} WHERE {Q("DepartmentId")}=@DepartmentId", parameters, transaction, cancellationToken: ct)); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistReminderRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistReminderRepository.cs new file mode 100644 index 000000000..10090c999 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistReminderRepository.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed class ChecklistReminderRepository : RmsRepositoryBase, IChecklistReminderRepository + { + public ChecklistReminderRepository(IConnectionProvider connection, SqlConfiguration config, IUnitOfWork uow, IQueryFactory queries) : base(connection, config, uow, queries) { } + private static readonly string[] Columns = typeof(ChecklistReminder).GetProperties().Select(p => p.Name).ToArray(); + public async Task> ForRecipientAsync(int departmentId, string userId, int skip, CancellationToken ct = default) + { + if (skip < 0) throw new ArgumentOutOfRangeException(nameof(skip)); + return (await QueryAsync($"SELECT {Cols(Columns.Where(c => c != "ClaimToken").ToArray())} FROM {Tbl("ChecklistReminders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("RecipientUserId")}={P}UserId ORDER BY {Col("CreatedOnUtc")},{Col("Id")} {Paging()}", new { DepartmentId = departmentId, UserId = userId, Skip = skip, Take = 100 }, ct)).ToList(); + } + public async Task> DepartmentsAsync(int afterDepartmentId, CancellationToken ct = default) => (await QueryAsync( + $"SELECT {Col("DepartmentId")} FROM (SELECT {Col("DepartmentId")} FROM {Tbl("DepartmentChecklistSettings")} WHERE {Col("RemindersEnabled")}={P}Enabled UNION SELECT {Col("DepartmentId")} FROM {Tbl("ChecklistReminders")} WHERE {Col("Status")}=0) d WHERE {Col("DepartmentId")}>{P}After ORDER BY {Col("DepartmentId")} {Paging()}", new { Enabled = true, After = afterDepartmentId, Skip = 0, Take = 100 }, ct)).ToList(); + public async Task EnqueueAsync(ChecklistReminder reminder, CancellationToken ct = default) + { + RequireTransaction(); + if (await ScalarAsync($"SELECT COUNT(*) FROM {Tbl("ChecklistReminders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("OccurrenceId")}={P}OccurrenceId AND {Col("RecipientUserId")}={P}RecipientUserId AND {Col("Kind")}={P}Kind AND {Col("PeriodKey")}={P}PeriodKey", reminder, ct) > 0) return; + await ExecuteAsync($"INSERT INTO {Tbl("ChecklistReminders")} ({Cols(Columns)}) VALUES ({string.Join(",", Columns.Select(c => P + c))})", reminder, ct); + } + public async Task> ClaimAsync(int departmentId, DateTime now, bool digest, CancellationToken ct = default) + { + RequireTransaction(); + var predicate = $"{Col("DepartmentId")}={P}DepartmentId AND {Col("Status")}=0 AND {Col("NextAttemptUtc")}<={P}Now AND ({Col("ClaimUntilUtc")} IS NULL OR {Col("ClaimUntilUtc")}<={P}Now)"; + var first = await QueryFirstOrDefaultAsync($"SELECT {Cols(Columns)} FROM {Tbl("ChecklistReminders")} WHERE {predicate} ORDER BY {Col("NextAttemptUtc")},{Col("Id")} {Paging()}", new { DepartmentId = departmentId, Now = now, Skip = 0, Take = 1 }, ct); + if (first == null) return new List(); + var rows = digest ? (await QueryAsync($"SELECT {Cols(Columns)} FROM {Tbl("ChecklistReminders")} WHERE {predicate} AND {Col("RecipientUserId")}={P}Recipient ORDER BY {Col("NextAttemptUtc")},{Col("Id")} {Paging()}", new { DepartmentId = departmentId, Now = now, Recipient = first.RecipientUserId, Skip = 0, Take = 500 }, ct)).ToList() : new List { first }; + var token = Guid.NewGuid().ToString("D"); + foreach (var row in rows) + { + row.ClaimToken = token; row.ClaimUntilUtc = now.AddMinutes(30); row.Attempts++; + await ExecuteAsync($"UPDATE {Tbl("ChecklistReminders")} SET {Col("ClaimToken")}={P}ClaimToken,{Col("ClaimUntilUtc")}={P}ClaimUntilUtc,{Col("Attempts")}={P}Attempts WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", row, ct); + } + return rows; + } + public async Task FinishAsync(ChecklistReminder reminder, ChecklistReminderStatus status, DateTime now, CancellationToken ct = default) + { + RequireTransaction(); + var changed = await ExecuteAsync($"UPDATE {Tbl("ChecklistReminders")} SET {Col("Status")}={P}Status,{Col("NextAttemptUtc")}={P}Next,{Col("CompletedOnUtc")}={P}Completed,{Col("ClaimToken")}=NULL,{Col("ClaimUntilUtc")}=NULL WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id AND {Col("ClaimToken")}={P}ClaimToken AND {Col("Status")}=0", + new { reminder.DepartmentId, reminder.Id, reminder.ClaimToken, Status = (int)status, Next = now.AddMinutes(5), Completed = status == ChecklistReminderStatus.Pending ? (DateTime?)null : now }, ct); + if (changed != 1) throw new InvalidOperationException("Checklist reminder lease was lost."); + } + private void RequireTransaction() { if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist reminder writes require a department transaction."); } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs index 6d12c643c..6aa97fef0 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Dapper; using Resgrid.Model.Checklists; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; @@ -13,21 +14,37 @@ namespace Resgrid.Repositories.DataRepository { /// Only the closed checklist table map can form identifiers. Every read/write is tenant scoped. - public class ChecklistRepository : RmsRepositoryBase, IChecklistRepository + public partial class ChecklistRepository : RmsRepositoryBase, IChecklistRepository { public ChecklistRepository(IConnectionProvider connection, SqlConfiguration config, IUnitOfWork uow, IQueryFactory queries) : base(connection, config, uow, queries) { } public Task LockDepartmentAsync(int departmentId, CancellationToken ct = default) => LockRecordsDepartmentAsync(departmentId, ct); private static string Table() where T : ChecklistRow => ChecklistTables.All[typeof(T)]; - private static string[] Columns(bool includeData = true) where T : ChecklistRow => typeof(T).GetProperties() - .Where(p => p.CanWrite && !Attribute.IsDefined(p, typeof(NotMappedAttribute)) && (includeData || p.Name != "Data")).Select(p => p.Name).ToArray(); + private static class ColumnCache where T : ChecklistRow + { + public static readonly System.Reflection.PropertyInfo[] Properties = typeof(T).GetProperties() + .Where(p => p.CanWrite && !Attribute.IsDefined(p, typeof(NotMappedAttribute))).ToArray(); + public static readonly string[] WithData = Properties.Select(p => p.Name).ToArray(); + public static readonly string[] WithoutData = WithData.Where(name => name != "Data").ToArray(); + } + private static string[] Columns(bool includeData = true) where T : ChecklistRow => includeData ? ColumnCache.WithData : ColumnCache.WithoutData; public Task GetAsync(int departmentId, string id, CancellationToken ct = default) where T : ChecklistRow => QueryFirstOrDefaultAsync($"SELECT {Cols(Columns())} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }, ct); public async Task> ListAsync(int departmentId, string parentId = null, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow { - if (skip < 0 || take < 1 || take > 500) throw new ArgumentOutOfRangeException(nameof(take)); + if (skip < 0) throw new ArgumentOutOfRangeException(nameof(skip)); + if (take < 1 || take > 500) throw new ArgumentOutOfRangeException(nameof(take)); var parent = parentId == null ? "" : $" AND {Col("ParentId")}={P}ParentId"; return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId{parent} ORDER BY {Col("CreatedOn")} DESC, {Col("Id")} {Paging()}", new { DepartmentId = departmentId, ParentId = parentId, Skip = skip, Take = take }, ct)).ToList(); } + public async Task> ListChildrenAsync(int departmentId, IReadOnlyCollection parentIds, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow + { + if (parentIds == null || parentIds.Count > 100) throw new ArgumentOutOfRangeException(nameof(parentIds)); + if (skip < 0) throw new ArgumentOutOfRangeException(nameof(skip)); + if (take < 1 || take > 500) throw new ArgumentOutOfRangeException(nameof(take)); + if (parentIds.Count == 0) return new List(); + return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {InList("ParentId", "ParentIds")} ORDER BY {Col("CreatedOn")} DESC, {Col("Id")} {Paging()}", + new { DepartmentId = departmentId, ParentIds = InListValue(parentIds), Skip = skip, Take = take }, ct)).ToList(); + } public async Task WriteAsync(T row, bool insert, CancellationToken ct = default) where T : ChecklistRow { if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist writes require a transaction."); @@ -40,15 +57,51 @@ public async Task WriteAsync(T row, bool insert, CancellationToken ct = defau public async Task ReplaceAnswersAsync(int departmentId, string completionId, IEnumerable items, CancellationToken ct = default) { if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist writes require a transaction."); + var answers = items.ToList(); + if (answers.Any(item => item == null || item.DepartmentId != departmentId || item.ParentId != completionId)) + throw new InvalidOperationException("Answer ownership mismatch."); await ExecuteAsync($"DELETE FROM {Tbl("ChecklistCompletionItems")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ParentId")}={P}Id", new { DepartmentId = departmentId, Id = completionId }, ct); - foreach (var item in items) + // Stay below SQL Server's parameter limit; each VALUES batch is a single round trip. + var properties = ColumnCache.Properties; + foreach (var batch in answers.Chunk(Math.Min(100, 2000 / properties.Length))) { - if (item.DepartmentId != departmentId || item.ParentId != completionId) throw new InvalidOperationException("Answer ownership mismatch."); - await WriteAsync(item, true, ct); + var parameters = new DynamicParameters(); + var values = new List(); + for (var i = 0; i < batch.Length; i++) + { + var names = properties.Select(property => + { + var name = property.Name + i; + parameters.Add(name, property.GetValue(batch[i]), property.PropertyType == typeof(bool?) ? System.Data.DbType.Boolean : null); + return P + name; + }); + values.Add("(" + string.Join(",", names) + ")"); + } + var count = await ExecuteAsync($"INSERT INTO {Tbl("ChecklistCompletionItems")} ({Cols(Columns())}) VALUES {string.Join(",", values)}", parameters, ct); + if (count != batch.Length) throw new InvalidOperationException("Checklist answers could not be saved."); } } public Task DeleteFileAsync(int departmentId, string id, CancellationToken ct = default) => ExecuteAsync($"DELETE FROM {Tbl("ChecklistCompletionFiles")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }, ct); public Task GetFileMetadataAsync(int departmentId, string id) => QueryFirstOrDefaultAsync( $"SELECT {Cols(Columns(false))} FROM {Tbl("ChecklistCompletionFiles")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }); + public async Task> SchedulingDepartmentsAsync(int afterDepartmentId, CancellationToken ct = default) => (await QueryAsync( + $"SELECT DISTINCT {Col("DepartmentId")} FROM {Tbl("ChecklistSchedules")} WHERE {Col("IsActive")}={P}Active AND {Col("DepartmentId")}>{P}After ORDER BY {Col("DepartmentId")} {Paging()}", new { Active = true, After = afterDepartmentId, Skip = 0, Take = 100 }, ct)).ToList(); + public async Task> ActiveSchedulesAsync(int departmentId, string afterId, CancellationToken ct = default) => (await QueryAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("ChecklistSchedules")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("IsActive")}={P}Active AND {Col("Id")}>{P}After ORDER BY {Col("Id")} {Paging()}", new { DepartmentId = departmentId, Active = true, After = afterId ?? "", Skip = 0, Take = 100 }, ct)).ToList(); + public async Task> ScheduledOccurrencesAsync(int departmentId, string scheduleId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default) => (await QueryAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("ChecklistOccurrences")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ScheduleId")}={P}ScheduleId AND {Col("State")} IN (0,1,3) AND {Col("WindowEndUtc")}>={P}From AND {Col("WindowEndUtc")}<{P}Until ORDER BY {Col("PeriodStartUtc")},{Col("Id")} {Paging()}", new { DepartmentId = departmentId, ScheduleId = scheduleId, From = fromUtc, Until = untilUtc, Skip = 0, Take = 500 }, ct)).ToList(); + public async Task> DueOccurrencesAsync(int departmentId, DateTime untilUtc, int skip, CancellationToken ct = default) => (await QueryAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("ChecklistOccurrences")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ScheduleId")} IS NOT NULL AND {Col("State")} IN (0,1,3,4) AND {Col("PeriodStartUtc")}<={P}Until ORDER BY {Col("PeriodStartUtc")},{Col("Id")} {Paging()}", new { DepartmentId = departmentId, Until = untilUtc, Skip = skip, Take = 50 }, ct)).ToList(); + public async Task> OccurrencesInWindowAsync(int departmentId, string scheduleId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default) => (await QueryAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("ChecklistOccurrences")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ScheduleId")}={P}ScheduleId AND {Col("PeriodStartUtc")}>={P}From AND {Col("PeriodStartUtc")}<{P}Until", new { DepartmentId = departmentId, ScheduleId = scheduleId, From = fromUtc, Until = untilUtc }, ct)).ToList(); + public Task CancelUnstartedOccurrencesAsync(int departmentId, string scheduleId, DateTime? fromUtc, DateTime nowUtc, CancellationToken ct = default) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist writes require a transaction."); + return ExecuteAsync($"UPDATE {Tbl("ChecklistOccurrences")} SET {Col("State")}=6,{Col("Revision")}={Col("Revision")}+1,{Col("UpdatedOn")}={P}Now WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ScheduleId")}={P}ScheduleId AND {Col("State")}=3{(fromUtc.HasValue ? $" AND {Col("PeriodStartUtc")}>={P}From" : "")}", new { DepartmentId = departmentId, ScheduleId = scheduleId, From = fromUtc, Now = nowUtc }, ct); + } + public async Task WorkshiftExistsAsync(int departmentId, string workshiftId, CancellationToken ct = default) => await ScalarAsync( + $"SELECT COUNT(*) FROM {Tbl("Workshifts")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("WorkshiftId")}={P}Id AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = workshiftId }, ct) == 1; + public async Task> WorkshiftStartsAsync(int departmentId, string workshiftId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default) => (await QueryAsync( + $"SELECT {Col("Day")} FROM {Tbl("WorkshiftDays")} WHERE {Col("WorkshiftId")} IN (SELECT {Col("WorkshiftId")} FROM {Tbl("Workshifts")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("WorkshiftId")}={P}Id AND {Col("DeletedOn")} IS NULL) AND {Col("Day")}>={P}From AND {Col("Day")}<{P}Until ORDER BY {Col("Day")}", new { DepartmentId = departmentId, Id = workshiftId, From = fromUtc, Until = untilUtc }, ct)).ToList(); } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistSchedulingRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistSchedulingRepository.cs new file mode 100644 index 000000000..6bad7e1c2 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistSchedulingRepository.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; + +namespace Resgrid.Repositories.DataRepository +{ + public partial class ChecklistRepository + { + public async Task LockAccessFenceAsync(CancellationToken ct = default) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist access changes require a transaction."); + var sql = Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres + ? "SELECT id FROM checklistaccessfence WHERE id=1 FOR UPDATE" : "SELECT [Id] FROM [ChecklistAccessFence] WITH (UPDLOCK,HOLDLOCK) WHERE [Id]=1"; + if (await ScalarAsync(sql, null, ct) != 1) throw new InvalidOperationException("Checklist access fence is unavailable."); + } + public async Task ApplyAccessStateAsync(int departmentId, bool enabled, DateTime nowUtc, CancellationToken ct = default, bool inventoryEnabled = true) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist access changes require a transaction."); + foreach (var schedule in await QueryAsync($"SELECT {Col("Id")},{Col("IsSuspended")},{Col("TargetType")} FROM {Tbl("ChecklistSchedules")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("IsActive")}={P}Active", new { DepartmentId = departmentId, Active = true }, ct)) + { + var available = enabled && (schedule.TargetType != 5 || inventoryEnabled); + if (schedule.IsSuspended == !available) continue; + await CancelUnstartedOccurrencesAsync(departmentId, schedule.Id, null, nowUtc, ct); + var resume = available ? $",{Col("Revision")}={Col("Revision")}+1,{Col("ActiveFromUtc")}={P}Now,{Col("GeneratedThroughUtc")}={P}Now,{Col("LastSweepUtc")}={P}Now" : ""; + // Operational metadata only. Existing protected Content is never read or rewritten. + await ExecuteAsync($"UPDATE {Tbl("ChecklistSchedules")} SET {Col("IsSuspended")}={P}Suspended,{Col("UpdatedOn")}={P}Now{resume} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, schedule.Id, Suspended = !available, Now = nowUtc }, ct); + } + } + public async Task> CalendarOccurrencesAsync(int departmentId, DateTime fromUtc, DateTime untilUtc, int skip, CancellationToken ct = default) => (await QueryAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("ChecklistOccurrences")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ScheduleId")} IS NOT NULL AND {Col("PeriodStartUtc")}<{P}Until AND {Col("WindowEndUtc")}>{P}From AND {Col("State")}<>6 ORDER BY {Col("PeriodStartUtc")},{Col("Id")} {Paging()}", new { DepartmentId = departmentId, From = fromUtc, Until = untilUtc, Skip = skip, Take = 500 }, ct)).ToList(); + public async Task> ShiftStartsAsync(int departmentId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default) => (await QueryAsync( + $"SELECT d.{Col("WorkshiftDayId")},d.{Col("Day")} AS {Col("StartUtc")},u.{Col("UnitId")} FROM {Tbl("WorkshiftDays")} d INNER JOIN {Tbl("Workshifts")} s ON s.{Col("WorkshiftId")}=d.{Col("WorkshiftId")} INNER JOIN {Tbl("WorkshiftEntities")} e ON e.{Col("WorkshiftId")}=s.{Col("WorkshiftId")} INNER JOIN {Tbl("Units")} u ON e.{Col("BackingId")}=CAST(u.{Col("UnitId")} AS VARCHAR(20)) AND u.{Col("DepartmentId")}=s.{Col("DepartmentId")} WHERE s.{Col("DepartmentId")}={P}DepartmentId AND s.{Col("DeletedOn")} IS NULL AND s.{Col("Type")}=1 AND d.{Col("Day")}>{P}From AND d.{Col("Day")}<={P}Until ORDER BY d.{Col("Day")}", new { DepartmentId = departmentId, From = fromUtc, Until = untilUtc }, ct)).ToList(); + public Task AdvanceDigestSweepAsync(int departmentId, DateTime nowUtc, CancellationToken ct = default) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist digest checkpoints require a transaction."); + return ExecuteAsync($"UPDATE {Tbl("DepartmentChecklistSettings")} SET {Col("LastDigestSweepUtc")}={P}Now WHERE {Col("DepartmentId")}={P}DepartmentId", new { DepartmentId = departmentId, Now = nowUtc }, ct); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs index 57c71e2bc..ad8cbb8e4 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs @@ -30,18 +30,9 @@ public async Task DeleteDepartmentAndUsersAsync(int departmentId) using (var transaction = db.BeginTransaction()) { + await ChecklistDepartmentCleanup.DeleteWithinTransactionAsync(db, transaction, departmentId, DatabaseTypes.SqlServer); var result = await db.ExecuteAsync(@" - IF OBJECT_ID('dbo.ChecklistDefinitions', 'U') IS NOT NULL - BEGIN - DELETE FROM [dbo].[ChecklistCompletionFiles] WHERE DepartmentId = @DepartmentId - DELETE FROM [dbo].[ChecklistCompletionItems] WHERE DepartmentId = @DepartmentId - DELETE FROM [dbo].[ChecklistCompletions] WHERE DepartmentId = @DepartmentId - DELETE FROM [dbo].[ChecklistOccurrences] WHERE DepartmentId = @DepartmentId - DELETE FROM [dbo].[ChecklistDefinitionVersions] WHERE DepartmentId = @DepartmentId - DELETE FROM [dbo].[ChecklistDefinitions] WHERE DepartmentId = @DepartmentId - DELETE FROM [dbo].[DepartmentChecklistSettings] WHERE DepartmentId = @DepartmentId - END - DECLARE @UserId NVARCHAR(128) + DECLARE @UserId NVARCHAR(128) DECLARE @UnitId INT DECLARE @ManagingUserId NVARCHAR(128) diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs index 6cae74d7b..d1f857682 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs @@ -301,9 +301,16 @@ private List SelectColumns(AdpTableBinding binding) private string Scope(AdpTableBinding binding) { + var filter = binding.Discriminator; + // Discriminators are code-owned constants. Integers and escaped string literals keep the same + // scope on reads, verification and counts without broadening the shared table to other features. + var discriminator = filter == null ? "" : filter.Text != null + ? $"{Ident(filter.Column)} = '{filter.Text.Replace("'", "''")}'" + : $"{Ident(filter.Column)} IN ({string.Join(",", filter.Integers ?? throw new InvalidOperationException("Missing ADP discriminator values."))})"; var scope = !string.IsNullOrEmpty(binding.DepartmentColumn) ? $"{Ident(binding.DepartmentColumn)} = @DepartmentId" - : $"{Ident(binding.ParentFkColumn)} IN (SELECT {Ident(binding.ParentPkColumn)} FROM {Table(binding.ParentTable)} WHERE {Ident("DepartmentId")} = @DepartmentId)"; + : $"{Ident(binding.ParentFkColumn)} IN (SELECT {Ident(binding.ParentPkColumn)} FROM {Table(binding.ParentTable)} WHERE {Ident("DepartmentId")} = @DepartmentId{(filter?.OnParent == true ? " AND " + discriminator : "")})"; + if (filter != null && !filter.OnParent) scope = $"({scope} AND {discriminator})"; // A boolean row filter narrows the sweep (RmsRecordValues.ProtectionRequired): rows outside it are never // read, counted or verified, so a Standard-classified value stays plaintext by construction. diff --git a/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs index 248d94db5..db1fe6804 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/FeatureFlagRepository.cs @@ -8,9 +8,24 @@ namespace Resgrid.Repositories.DataRepository { public class FeatureFlagRepository : RepositoryBase, IFeatureFlagRepository { + private readonly IConnectionProvider _connections; + private readonly IUnitOfWork _unit; public FeatureFlagRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { + _connections = connectionProvider; _unit = unitOfWork; + } + public async System.Threading.Tasks.Task TouchEvaluationAsync(int flagId, System.DateTime evaluatedOn, System.Threading.CancellationToken ct = default) + { + // Never re-save a cached definition for telemetry: that could restore an old flag state. + const string sql = "UPDATE FeatureFlags SET LastEvaluatedOn=@evaluatedOn WHERE FeatureFlagId=@flagId AND (LastEvaluatedOn IS NULL OR LastEvaluatedOn<@evaluatedOn)"; + if (_unit.Connection != null) + await Dapper.SqlMapper.ExecuteAsync(_unit.Connection, new Dapper.CommandDefinition(sql, new { flagId, evaluatedOn }, _unit.Transaction, cancellationToken: ct)); + else + { + using var connection = _connections.Create(); await connection.OpenAsync(ct); + await Dapper.SqlMapper.ExecuteAsync(connection, new Dapper.CommandDefinition(sql, new { flagId, evaluatedOn }, cancellationToken: ct)); + } } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index 99226f540..de9fb1ffa 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -16,6 +16,7 @@ public class ApiDataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 043fb84c3..fac1c5324 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -16,6 +16,7 @@ public class DataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); if (Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres) diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index b71d900e8..89065a094 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -16,6 +16,7 @@ public class NonWebDataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); if (Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres) diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 4fdcabe23..d0361c8a9 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -16,6 +16,7 @@ public class TestingDataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); if (Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres) diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index 6d9617b5e..65a4841ba 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -674,6 +674,17 @@ public Task> GetForRecordAsync(int departmentI public class DomainEventOutboxRepository : RmsRepositoryBase, IDomainEventOutboxRepository { + public async Task InitializeChecklistPayloadAsync(DomainEventOutboxEntry entry, CancellationToken cancellationToken = default) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist event initialization requires the producer transaction."); + return await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Payload WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("EventId")} = {P}EventId AND {Col("ProducerSubsystem")} = 'Checklists' AND {Col("State")} = {P}Pending AND {Col("LeaseOwner")} IS NULL AND {Col("PayloadJson")} = '{{}}'", + new { Id = entry.DomainEventOutboxId, entry.DepartmentId, entry.EventId, Payload = entry.PayloadJson, Pending = (int)DomainEventOutboxState.Pending }, cancellationToken) == 1; + } + public async Task ReplaceChecklistPayloadAsync(DomainEventOutboxEntry entry, string safePayload, CancellationToken cancellationToken = default) + { + return await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Payload, {Col("LastError")} = {P}LastError WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("ProducerSubsystem")} = 'Checklists' AND {Col("State")} = {P}Pending AND {Col("LeaseOwner")} = {P}Owner AND {Col("Attempts")} = {P}Attempts AND {Col("LeaseExpiresOn")} > {P}Now", + new { Id = entry.DomainEventOutboxId, entry.DepartmentId, Payload = safePayload, entry.LastError, Pending = (int)DomainEventOutboxState.Pending, Owner = entry.LeaseOwner, entry.Attempts, Now = DateTime.UtcNow }, cancellationToken) == 1; + } public DomainEventOutboxRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs index 09eb4e7db..74302c206 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data.Common; using System.Threading.Tasks; @@ -15,6 +15,11 @@ namespace Resgrid.Repositories.DataRepository { public class WorkflowRunRepository : RmsRepositoryBase, IWorkflowRunRepository { + public async Task TryStartChecklistRunAsync(string runId, string workflowId, int departmentId, int attemptNumber, string safePayload) + { + return await ExecuteAsync($"UPDATE {Tbl("WorkflowRuns")} SET {Col("Status")} = {P}Running, {Col("AttemptNumber")} = {P}Attempt, {Col("InputPayload")} = {P}Payload WHERE {Col("WorkflowRunId")} = {P}Id AND {Col("WorkflowId")} = {P}WorkflowId AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("TriggerEventType")} IN (67,68,69,164,165) AND (({P}Attempt = 1 AND {Col("Status")} = {P}Pending) OR ({P}Attempt > 1 AND {Col("Status")} = {P}Retrying AND {Col("AttemptNumber")} = {P}Previous))", + new { Id = runId, WorkflowId = workflowId, DepartmentId = departmentId, Attempt = attemptNumber, Previous = attemptNumber - 1, Payload = safePayload, Running = (int)WorkflowRunStatus.Running, Pending = (int)WorkflowRunStatus.Pending, Retrying = (int)WorkflowRunStatus.Retrying }) == 1; + } public override Task InsertAsync(WorkflowRun entity, System.Threading.CancellationToken cancellationToken, bool firstLevelOnly = false) => WriteRunAsync(entity, true, cancellationToken, firstLevelOnly); public override Task UpdateAsync(WorkflowRun entity, System.Threading.CancellationToken cancellationToken, bool firstLevelOnly = false) diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index c993d667f..cb16ad9f4 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -1,4 +1,4 @@ -{ +{ "CallAdded": 0, "CallUpdated": 1, "CallClosed": 2, @@ -78,5 +78,8 @@ "RecordExportScheduled": 160, "RecordInspectionCompleted": 161, "RecordViolationOverdue": 162, - "RecordPermitExpiring": 163 + "RecordPermitExpiring": 163, + "ChecklistMissed": 69, + "ChecklistScheduleChanged": 164, + "ChecklistOccurrenceSkipped": 165 } diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index aa187e7e2..6fcb0bda5 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -88,7 +88,9 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() ((int)WorkflowTriggerEventType.RecordDefinitionPublished).Should().Be(113); ((int)WorkflowTriggerEventType.RecordDefinitionRetired).Should().Be(114); - foreach (var value in Enumerable.Range(52, 48)) + ((int)WorkflowTriggerEventType.ChecklistCompleted).Should().Be(67); + ((int)WorkflowTriggerEventType.ChecklistFailed).Should().Be(68); + foreach (var value in Enumerable.Range(52, 48).Except(new[] { 67, 68, 69 })) Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for another plan"); } diff --git a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs index 34e13e424..2e3503ec5 100644 --- a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs @@ -55,9 +55,10 @@ public async Task Scan_counts_every_binding_and_derives_the_range_and_nights() "every catalog binding is counted"); result.TotalRows.Should().Be(10000L * AdpTableBindings.V1.Count); - // 620,000 rows / 100 rps = 6200s + 62×30s overhead = 8060s; ×1.25 = 10075s → 168 min P50 (catalog v13 binds 62 tables). - result.EstimatedP50Minutes.Should().Be(168); - result.EstimatedP90Minutes.Should().Be(336); + // Catalog v17 binds 74 tables: 740,000 rows / 100 rps + 74×30s overhead + // = 9620s; ×1.25 = 12025s → 201 min P50, ×2 → 401 min P90. + result.EstimatedP50Minutes.Should().Be(201); + result.EstimatedP90Minutes.Should().Be(401); result.ProjectedNights.Should().Be(1, "the P90 estimate still fits one 480-minute window"); result.BenchmarkRowsPerSecond.Should().Be(100); } diff --git a/Tests/Resgrid.Tests/Services/ChecklistAccessMutationTests.cs b/Tests/Resgrid.Tests/Services/ChecklistAccessMutationTests.cs new file mode 100644 index 000000000..41650f159 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistAccessMutationTests.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + [Test, NonParallelizable] + public async Task Managed_flag_and_module_commands_persist_pauses_and_resume_in_the_same_transaction() + { + var oldEnabled = FeatureFlagsConfig.FeatureFlagsEnabled; var oldCache = SystemBehaviorConfig.CacheEnabled; + FeatureFlagsConfig.FeatureFlagsEnabled = true; SystemBehaviorConfig.CacheEnabled = true; + try + { + var setup = await Scheduled(); await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + var flag = new FeatureFlag { FeatureFlagId = 19, FlagKey = FeatureFlagKeys.ChecklistsSystem, IsEnabledGlobally = true }; + var flags = new Mock(); + flags.Setup(r => r.GetAllAsync()).ReturnsAsync(() => new[] { JsonConvert.DeserializeObject(JsonConvert.SerializeObject(flag)) }); + flags.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), false)).ReturnsAsync((FeatureFlag row, CancellationToken _, bool first) => flag = row); + DepartmentSetting modules = null; var settings = new Mock(); + settings.Setup(r => r.GetDepartmentSettingByIdTypeAsync(77, DepartmentSettingTypes.ModuleSettings)).ReturnsAsync(() => modules); + settings.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), false)).ReturnsAsync((DepartmentSetting row, CancellationToken _, bool first) => modules = row); + settings.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(() => { modules = null; return true; }); + var observer = new ChecklistAccessMutationObserver(_store, settings.Object, setup.Clock); var cache = new Mock(); + var service = new FeatureToggleService(flags.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), cache.Object, Mock.Of(), Mock.Of(), Mock.Of(), _uow.Object, observer); + await service.SetGlobalEnabledAsync(flag.FlagKey, false, "author"); + (await _store.GetAsync(77, setup.Input.Id)).IsSuspended.Should().BeTrue(); + setup.Clock.Now = setup.Clock.Now.AddDays(3); await service.SetGlobalEnabledAsync(flag.FlagKey, true, "author"); + var resumed = await _store.GetAsync(77, setup.Input.Id); resumed.ActiveFromUtc.Should().Be(setup.Clock.Now.UtcDateTime); resumed.IsSuspended.Should().BeFalse(); + var moduleService = new DepartmentSettingsService(settings.Object, Mock.Of(), Mock.Of(), cache.Object, _uow.Object, observer, new Lazy(() => service)); + await moduleService.SaveOrUpdateSettingAsync(77, Resgrid.Framework.ObjectSerialization.Serialize(new DepartmentModuleSettings { ChecklistsDisabled = true }), DepartmentSettingTypes.ModuleSettings); + (await _store.GetAsync(77, setup.Input.Id)).IsSuspended.Should().BeTrue(); + setup.Clock.Now = setup.Clock.Now.AddHours(1); await moduleService.DeleteSettingAsync(77, DepartmentSettingTypes.ModuleSettings); + (await _store.GetAsync(77, setup.Input.Id)).ActiveFromUtc.Should().Be(setup.Clock.Now.UtcDateTime); + cache.Invocations.Should().NotContain(c => c.Method.Name.StartsWith("Retrieve"), "policy evaluation during a mutation must bypass every cached layer"); + _uow.Verify(u => u.CommitChanges(), Times.AtLeast(4)); + } + finally { FeatureFlagsConfig.FeatureFlagsEnabled = oldEnabled; SystemBehaviorConfig.CacheEnabled = oldCache; } + } + [Test] + public async Task Mutation_observer_failure_aborts_flag_write_without_cache_invalidation_or_audit_publication() + { + var observer = new Mock(); observer.Setup(o => o.AfterChangeAsync(It.IsAny>>(), It.IsAny())).ThrowsAsync(new InvalidOperationException("Storage unavailable")); + var flags = new Mock(); var cache = new Mock(); var events = new Mock(); + var service = new FeatureToggleService(flags.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), cache.Object, events.Object, Mock.Of(), Mock.Of(), _uow.Object, observer.Object); + Func command = () => service.SetGlobalEnabledAsync(FeatureFlagKeys.ChecklistsSystem, false, "author"); await command.Should().ThrowAsync(); + _uow.Verify(u => u.DiscardChanges(), Times.Once); _uow.Verify(u => u.CommitChanges(), Times.Never); cache.Invocations.Should().BeEmpty(); events.Invocations.Should().BeEmpty(); flags.Invocations.Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistAssignmentTests.cs b/Tests/Resgrid.Tests/Services/ChecklistAssignmentTests.cs new file mode 100644 index 000000000..37b8f0013 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistAssignmentTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ChecklistAssignmentTests + { + [Test] + public async Task Assignment_membership_requires_current_same_department_members_and_valid_targets() + { + var departments = new Mock(); var groups = new Mock(); var units = new Mock(); var roles = new Mock(); + var members = new List { new DepartmentMember { DepartmentId = 77, UserId = "active" }, new DepartmentMember { DepartmentId = 77, UserId = "disabled", IsDisabled = true }, new DepartmentMember { DepartmentId = 77, UserId = "deleted", IsDeleted = true }, new DepartmentMember { DepartmentId = 88, UserId = "foreign" } }; + departments.Setup(d => d.GetAllMembersForDepartmentUnlimitedAsync(77, true)).ReturnsAsync(members); + departments.Setup(d => d.GetDepartmentMemberAsync("active", 77, true)).ReturnsAsync(members[0]); + departments.Setup(d => d.GetDepartmentMemberAsync("foreign", 77, true)).ReturnsAsync(members[3]); + units.Setup(u => u.GetUnitByIdAsync(12)).ReturnsAsync(new Unit { UnitId = 12, DepartmentId = 77 }); + units.Setup(u => u.GetUnitByIdAsync(13)).ReturnsAsync(new Unit { UnitId = 13, DepartmentId = 88 }); + units.Setup(u => u.GetActiveRolesForUnitAsync(12)).ReturnsAsync(new List { new UnitActiveRole { UnitId = 12, DepartmentId = 77, UserId = "active" }, new UnitActiveRole { UnitId = 12, DepartmentId = 77, UserId = "disabled" }, new UnitActiveRole { UnitId = 12, DepartmentId = 77, UserId = "deleted" }, new UnitActiveRole { UnitId = 12, DepartmentId = 88, UserId = "foreign" } }); + groups.Setup(g => g.GetGroupByIdAsync(9, true)).ReturnsAsync(new DepartmentGroup { DepartmentId = 77, DepartmentGroupId = 9 }); + groups.Setup(g => g.GetAllMembersForGroupAsync(9)).ReturnsAsync(new List { new DepartmentGroupMember { DepartmentId = 77, UserId = "active" }, new DepartmentGroupMember { DepartmentId = 88, UserId = "foreign" } }); + roles.Setup(r => r.GetRoleByIdAsync(8)).ReturnsAsync(new PersonnelRole { DepartmentId = 77, PersonnelRoleId = 8 }); + roles.Setup(r => r.GetAllMembersOfRoleAsync(8)).ReturnsAsync(new List { new PersonnelRoleUser { UserId = "active" }, new PersonnelRoleUser { UserId = "deleted" }, new PersonnelRoleUser { UserId = "foreign" } }); + var service = new ChecklistAssignmentService(departments.Object, groups.Object, units.Object, roles.Object); + foreach (var choice in new[] { (1, "active"), (2, "8"), (3, "9"), (4, "12") }) (await service.MembersAsync(77, choice.Item1, choice.Item2)).Should().Equal("active"); + foreach (var choice in new[] { (1, "foreign"), (4, "13"), (7, "12"), (0, "unexpected"), (2, "-1") }) { Func bad = () => service.ValidateAsync(77, choice.Item1, choice.Item2); await bad.Should().ThrowAsync(); } + members[0].IsDisabled = true; (await service.MembersAsync(77, 4, "12")).Should().BeEmpty(); + } + [Test] + public async Task Asset_picker_revalidates_tenant_and_provider_authorization_and_hides_an_absent_module() + { + var actor = new ChecklistActor { DepartmentId = 77, UserId = "author" }; var departments = new Mock(); + departments.Setup(d => d.GetDepartmentMemberAsync("author", 77, true)).ReturnsAsync(new DepartmentMember { DepartmentId = 77, UserId = "author" }); + var assets = new Mock(); assets.Setup(a => a.IsAvailableAsync(77)).ReturnsAsync(true); + var id = Guid.NewGuid().ToString(); var forbidden = Guid.NewGuid().ToString(); + assets.Setup(a => a.ListAsync(actor)).ReturnsAsync(new List { new ChecklistAssetTarget { Id = id, DepartmentId = 77 }, new ChecklistAssetTarget { Id = forbidden, DepartmentId = 77 }, new ChecklistAssetTarget { Id = Guid.NewGuid().ToString(), DepartmentId = 88 } }); + assets.Setup(a => a.GetAsync(actor, id)).ReturnsAsync(new ChecklistAssetTarget { Id = id, DepartmentId = 77, Name = "Authorized asset", GroupId = 9 }); + assets.Setup(a => a.GetAsync(actor, forbidden)).ReturnsAsync(new ChecklistAssetTarget { Id = forbidden, DepartmentId = 88, Name = "Foreign asset" }); + var service = new ChecklistAuthorizationService(departments.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), assets.Object); + (await service.TargetsAsync(actor, ChecklistTargetType.InventoryAsset)).Should().ContainSingle(t => t.Id == id && t.GroupId == 9); + assets.Setup(a => a.IsAvailableAsync(77)).ReturnsAsync(false); (await service.TargetsAsync(actor, ChecklistTargetType.InventoryAsset)).Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistCalendarApiTests.cs b/Tests/Resgrid.Tests/Services/ChecklistCalendarApiTests.cs new file mode 100644 index 000000000..4ea8d7ad2 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistCalendarApiTests.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; +using ClaimsHelper = Resgrid.Web.ServicesCore.Helpers.ClaimsAuthorizationHelper; + +namespace Resgrid.Tests.Services +{ + [TestFixture, NonParallelizable] + public class ChecklistCalendarApiTests + { + [Test] + public async Task V4_calendar_inclusion_is_opt_in_preserves_ordinary_ids_and_uses_authenticated_protected_context() + { + var previous = ClaimsHelper._httpContextAccessor; var context = new DefaultHttpContext(); + context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.PrimarySid, "author"), new Claim(ClaimTypes.PrimaryGroupSid, "77") }, "test")); + context.Request.Headers[DataProtectionController.GrantHeader] = "synthetic-grant"; + ClaimsHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = context }; + using var activity = new Activity("checklist-calendar-test").Start(); + try + { + var calendar = new Mock(); var departments = new Mock(); var checklists = new Mock(); var now = new DateTime(2026, 9, 8, 8, 0, 0, DateTimeKind.Utc); + departments.Setup(d => d.GetDepartmentByIdAsync(77, false)).ReturnsAsync(new Department { DepartmentId = 77, ManagingUserId = "author", TimeZone = "UTC" }); + departments.Setup(d => d.GetAllPersonnelNamesForDepartmentAsync(77)).ReturnsAsync(new List()); + calendar.Setup(c => c.GetAllCalendarItemsForDepartmentInRangeAsync(77, It.IsAny(), It.IsAny())).ReturnsAsync(new List { new CalendarItem { DepartmentId = 77, CalendarItemId = 42, Start = now, End = now.AddHours(1), Title = "Ordinary event", CreatorUserId = "author" } }); + calendar.Setup(c => c.GetAllCalendarItemTypesForDepartmentAsync(77)).ReturnsAsync(new List()); + var id = Guid.NewGuid().ToString(); checklists.Setup(c => c.CalendarAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "author" && a.GrantToken == "synthetic-grant"), now.Date, now.Date.AddDays(1))).ReturnsAsync(new List { new ChecklistCalendarEntry { Id = "checklist:" + id, OccurrenceId = id, Title = "REDACTED", IsRedacted = true, State = 4, StartUtc = now, EndUtc = now.AddHours(2) } }); + var controller = new CalendarController(calendar.Object, departments.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), checklists.Object) { ControllerContext = new ControllerContext { HttpContext = context } }; + var ordinary = (await controller.GetDepartmentCalendarItemsInRange(now.Date, now.Date)).Value; ordinary.Data.Should().ContainSingle(i => i.CalendarItemId == "42" && !i.IsVirtual); checklists.Invocations.Should().BeEmpty(); + var combined = (await controller.GetDepartmentCalendarItemsInRange(now.Date, now.Date, true)).Value; combined.PageSize.Should().Be(2); + var readiness = combined.Data.Single(i => i.IsVirtual); readiness.CalendarItemId.Should().Be("checklist:" + id); readiness.SourceId.Should().Be(id); readiness.LockEditing.Should().BeTrue(); readiness.IsRedacted.Should().BeTrue(); readiness.ChecklistState.Should().Be(4); readiness.TypeColor.Should().Be("#c0392b"); readiness.DeepLinkUrl.Should().EndWith(id); readiness.StartUtc.Should().Be(now); + context.Response.Headers.CacheControl.ToString().Should().Be("no-store"); + (await controller.GetDepartmentCalendarItemsInRange(now, now.AddDays(94), true)).Result.Should().BeOfType(); + } + finally { ClaimsHelper._httpContextAccessor = previous; } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistDatabaseTests.cs b/Tests/Resgrid.Tests/Services/ChecklistDatabaseTests.cs index ba75b870f..f50d57e55 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistDatabaseTests.cs @@ -15,6 +15,7 @@ using NUnit.Framework; using Resgrid.Config; using Resgrid.Model.Checklists; +using Resgrid.Model; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; using Resgrid.Providers.Migrations.Migrations; @@ -23,16 +24,20 @@ using Resgrid.Repositories.DataRepository.Configs; using Resgrid.Repositories.DataRepository.Servers.SqlServer; using Resgrid.Repositories.DataRepository.Transactions; +using Resgrid.Services; +using Newtonsoft.Json.Linq; namespace Resgrid.Tests.Services { [TestFixture(DatabaseTypes.SqlServer), TestFixture(DatabaseTypes.Postgres), NonParallelizable] - public class ChecklistDatabaseTests + public partial class ChecklistDatabaseTests { private readonly DatabaseTypes _type; private DatabaseTypes _previous; private string _master, _connection, _database; private ServiceProvider _runner; + private string Q(string name) => _type == DatabaseTypes.Postgres ? name.ToLowerInvariant() : "[" + name + "]"; + private string TextType => _type == DatabaseTypes.Postgres ? "text" : "nvarchar(max)"; public ChecklistDatabaseTests(DatabaseTypes type) { _type = type; } private DbConnection Connect(string connection) => _type == DatabaseTypes.Postgres ? new NpgsqlConnection(connection) : new SqlConnection(connection); [OneTimeSetUp] @@ -47,7 +52,8 @@ public async Task CreateIsolatedDatabaseAndMigrate() else { var builder = new SqlConnectionStringBuilder(_master) { InitialCatalog = _database }; _connection = builder.ConnectionString; } await using var db = Connect(_connection); await db.ExecuteAsync(_type == DatabaseTypes.Postgres ? "CREATE TABLE departments(departmentid integer PRIMARY KEY); INSERT INTO departments VALUES(77),(88);" : "CREATE TABLE Departments(DepartmentId int PRIMARY KEY); INSERT INTO Departments VALUES(77),(88);"); - var source = new Mock(); source.Setup(s => s.GetMigrations()).Returns(new IMigration[] { _type == DatabaseTypes.Postgres ? new M0191_AddChecklistWorkflowPg() : new M0191_AddChecklistWorkflow() }); + await db.ExecuteAsync($"CREATE TABLE {Q("AuditLogs")} ({Q("AuditLogId")} int PRIMARY KEY, {Q("DepartmentId")} int, {Q("LogType")} int, {Q("Data")} {TextType}); CREATE TABLE {Q("DomainEventOutbox")} ({Q("DomainEventOutboxId")} bigint PRIMARY KEY, {Q("DepartmentId")} int, {Q("ProducerSubsystem")} varchar(100), {Q("PayloadJson")} {TextType}, {Q("LastError")} {TextType}, {Q("AggregateId")} varchar(36)); CREATE TABLE {Q("WorkflowRuns")} ({Q("WorkflowRunId")} varchar(36) PRIMARY KEY, {Q("WorkflowId")} varchar(36), {Q("DepartmentId")} int, {Q("TriggerEventType")} int, {Q("Status")} int, {Q("AttemptNumber")} int, {Q("InputPayload")} {TextType}, {Q("ErrorMessage")} varchar(4000)); CREATE TABLE {Q("WorkflowRunLogs")} ({Q("WorkflowRunLogId")} varchar(36) PRIMARY KEY, {Q("WorkflowRunId")} varchar(36), {Q("RenderedOutput")} {TextType}, {Q("ActionResult")} varchar(4000), {Q("ErrorMessage")} varchar(4000));"); + var source = new Mock(); source.Setup(s => s.GetMigrations()).Returns(new IMigration[] { _type == DatabaseTypes.Postgres ? new M0191_AddChecklistWorkflowPg() : new M0191_AddChecklistWorkflow(), _type == DatabaseTypes.Postgres ? new M0192_ProtectChecklistOutcomesPg() : new M0192_ProtectChecklistOutcomes(), _type == DatabaseTypes.Postgres ? new M0193_ReadinessHistoryProtectionPg() : new M0193_ReadinessHistoryProtection(), _type == DatabaseTypes.Postgres ? new M0194_AddChecklistSchedulingPg() : new M0194_AddChecklistScheduling(), _type == DatabaseTypes.Postgres ? new M0195_AddChecklistRemindersPg() : new M0195_AddChecklistReminders(), _type == DatabaseTypes.Postgres ? new M0196_CompleteChecklistSchedulingPg() : new M0196_CompleteChecklistScheduling() }); _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { if (_type == DatabaseTypes.Postgres) r.AddPostgres(); else r.AddSqlServer(); @@ -97,6 +103,86 @@ public async Task Round_trip_versions_occurrences_answers_and_files_and_enforce_ await uow.CreateOrGetConnectionAsync(); var foreign = Row(definition.Id); foreign.DepartmentId = 88; foreign.Version = 2; Func insert = () => store.WriteAsync(foreign, true); await insert.Should().ThrowAsync(); uow.DiscardChanges(); } + + [Test, Order(1)] + public async Task History_migration_backfills_multiple_batches_without_disclosing_or_replacing_original_payloads() + { + var runner = _runner.GetRequiredService(); runner.MigrateDown(192); + await using var db = Connect(_connection); + var completion = Guid.NewGuid().ToString(); + var payload = new JObject { ["CompletionId"] = completion, ["TargetType"] = 3, ["TargetId"] = "SYNTHETIC-PERSON", ["Score"] = 87.25, ["Passed"] = false, ["Note"] = "SYNTHETIC-PHI-CANARY" }.ToString(); + for (var i = 1; i <= 205; i++) + await db.ExecuteAsync($"INSERT INTO {Q("DomainEventOutbox")} VALUES(@id,77,'Checklists',@payload,NULL,@completion)", new { id = i, payload, completion }); + await db.ExecuteAsync($"INSERT INTO {Q("DomainEventOutbox")} VALUES(206,77,'Records',@payload,NULL,@completion)", new { payload, completion }); + runner.MigrateUp(); runner.MigrateUp(); + var rows = (await db.QueryAsync($"SELECT * FROM {Q("DomainEventOutbox")} ORDER BY {Q("DomainEventOutboxId")}")).ToList(); + foreach (var row in rows.Take(205)) + { + row.PayloadJson.Should().Be(payload); + row.ReadinessRoutingJson.Should().NotContain("SYNTHETIC").And.NotContain("87.25"); + var routing = JObject.Parse(row.ReadinessRoutingJson); routing["CompletionId"].Value().Should().Be(completion); + routing["TargetId"].Value().Should().Be("REDACTED"); routing["Passed"].Value().Should().Be("REDACTED"); + } + rows.Last().ReadinessRoutingJson.Should().BeNull(); + await db.ExecuteAsync($"DELETE FROM {Q("DomainEventOutbox")}"); + } + + [Test, Order(2)] + public async Task Historical_adp_scans_and_residue_checks_exclude_other_tenants_and_other_features() + { + await using var db = Connect(_connection); + var bulk = new DepartmentDataProtectionBulkRepository(Connections(), Configuration()); + var bindings = AdpTableBindings.ForVersionRange(new ProtectedFieldCatalog(), 15, 16); + var ids = Enumerable.Range(1, 3).Select(_ => Guid.NewGuid().ToString()).ToArray(); + for (var i = 0; i < 3; i++) + { + var parameters = new { numericId = i + 1, id = ids[i], department = i == 1 ? 88 : 77, trigger = i == 2 ? 1 : 67, logType = i == 2 ? (int)AuditLogTypes.GroupAdded : ReadinessHistoryFields.AuditTypes.First(), producer = i == 2 ? "Records" : "Checklists", data = "SYNTHETIC-PHI-CANARY" }; + await db.ExecuteAsync($"INSERT INTO {Q("AuditLogs")} VALUES(@numericId,@department,@logType,@data); INSERT INTO {Q("DomainEventOutbox")} ({Q("DomainEventOutboxId")},{Q("DepartmentId")},{Q("ProducerSubsystem")},{Q("PayloadJson")},{Q("LastError")}) VALUES(@numericId,@department,@producer,@data,@data); INSERT INTO {Q("WorkflowRuns")} ({Q("WorkflowRunId")},{Q("DepartmentId")},{Q("TriggerEventType")},{Q("InputPayload")},{Q("ErrorMessage")}) VALUES(@id,@department,@trigger,@data,@data); INSERT INTO {Q("WorkflowRunLogs")} VALUES(@id,@id,@data,@data,@data);", parameters); + } + foreach (var binding in bindings) + { + (await bulk.CountRowsAsync(binding, 77)).Should().Be(1, binding.TableName); + (await bulk.CountRowsAsync(binding, 88)).Should().Be(1, binding.TableName); + (await bulk.CountRowsAsync(binding, 99)).Should().Be(0, binding.TableName); + var batch = await bulk.GetBatchAsync(binding, 77, null, 10); batch.Should().ContainSingle(); + batch[0].RowKey.Should().Be(binding.PkIsNumeric ? "1" : ids[0]); + (await bulk.GetBatchAsync(binding, 77, batch[0].RowKey, 10)).Should().BeEmpty(); + (await bulk.CountTextResidueAsync(binding, 77, false)).Should().Be(1); + foreach (var column in binding.Columns) + { + // A long envelope also proves the widened Workflow error/result columns do not truncate ciphertext. + await db.ExecuteAsync($"UPDATE {Q(binding.TableName)} SET {Q(column.ColumnName)} = @value WHERE {Q(binding.PkColumn)} = @id", new { value = "rgdp:1:1:" + new string('x', 5000), id = binding.PkIsNumeric ? (object)1 : ids[0] }); + } + (await bulk.CountTextResidueAsync(binding, 77, false)).Should().Be(0); + (await bulk.CountTextResidueAsync(binding, 77, true)).Should().Be(1); + (await bulk.CountTextResidueAsync(binding, 88, false)).Should().Be(1); + (await bulk.CountSupersededKeyVersionResidueAsync(binding, 77, 2)).Should().Be(1); + } + Action rollback = () => _runner.GetRequiredService().MigrateDown(192); + rollback.Should().Throw(); + _runner.GetRequiredService().LoadVersionInfo(); _runner.GetRequiredService().MigrateUp(); + await db.ExecuteAsync($"DELETE FROM {Q("WorkflowRunLogs")}; DELETE FROM {Q("WorkflowRuns")}; DELETE FROM {Q("DomainEventOutbox")}; DELETE FROM {Q("AuditLogs")};"); + } + [Test, Order(3)] + public async Task Checklist_outbox_initialization_requires_matching_identity_and_rolls_back_with_its_producer() + { + await using var db = Connect(_connection); + await db.ExecuteAsync($"ALTER TABLE {Q("DomainEventOutbox")} ADD {Q("EventId")} varchar(36); ALTER TABLE {Q("DomainEventOutbox")} ADD {Q("State")} int; ALTER TABLE {Q("DomainEventOutbox")} ADD {Q("LeaseOwner")} varchar(100);"); + var connections = Connections(); using var uow = new UnitOfWork(connections); + var repository = new DomainEventOutboxRepository(connections, Configuration(), uow, new Mock().Object); + var entry = new DomainEventOutboxEntry { DomainEventOutboxId = 901, DepartmentId = 77, EventId = Guid.NewGuid().ToString(), PayloadJson = "rgdp:1:1:synthetic==" }; + Func noTransaction = () => repository.InitializeChecklistPayloadAsync(entry); + await noTransaction.Should().ThrowAsync(); + var connection = await uow.CreateOrGetConnectionAsync(); + await connection.ExecuteAsync($"INSERT INTO {Q("DomainEventOutbox")} ({Q("DomainEventOutboxId")},{Q("DepartmentId")},{Q("ProducerSubsystem")},{Q("PayloadJson")},{Q("EventId")},{Q("State")}) VALUES(901,77,'Checklists','{{}}',@EventId,@state)", new { entry.EventId, state = (int)DomainEventOutboxState.Pending }, uow.Transaction); + entry.DepartmentId = 88; (await repository.InitializeChecklistPayloadAsync(entry)).Should().BeFalse(); entry.DepartmentId = 77; + var eventId = entry.EventId; entry.EventId = Guid.NewGuid().ToString(); (await repository.InitializeChecklistPayloadAsync(entry)).Should().BeFalse(); entry.EventId = eventId; + (await repository.InitializeChecklistPayloadAsync(entry)).Should().BeTrue(); + (await repository.InitializeChecklistPayloadAsync(entry)).Should().BeFalse("a populated event cannot be reinitialized"); + uow.DiscardChanges(); + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("DomainEventOutbox")} WHERE {Q("DomainEventOutboxId")}=901")).Should().Be(0); + } + [Test] public async Task Rollback_removes_partial_data_and_duplicate_version_is_rejected() { @@ -118,5 +204,40 @@ public async Task Department_lock_serializes_two_writers_until_commit() (await Task.WhenAny(waiting, Task.Delay(200))).Should().NotBe(waiting); first.CommitChanges(); await waiting.WaitAsync(TimeSpan.FromSeconds(10)); second.CommitChanges(); } + [Test, Order(99)] + public async Task Protected_outcomes_round_trip_as_null_and_prevent_lossy_schema_rollback() + { + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + var definition = Row(); await store.WriteAsync(definition, true); + var version = Row(definition.Id); version.Version = 1; await store.WriteAsync(version, true); + var completion = Row(definition.Id); completion.VersionId = version.Id; completion.TargetId = "77"; + var occurrence = Row(definition.Id); occurrence.VersionId = version.Id; occurrence.CompletionId = completion.Id; occurrence.TargetId = "77"; await store.WriteAsync(occurrence, true); + completion.OccurrenceId = occurrence.Id; completion.Score = null; completion.Passed = null; completion.ProtectedScoreEnvelope = "rgdp:1:1:score=="; completion.ProtectedPassedEnvelope = "rgdp:1:1:passed=="; + await store.WriteAsync(completion, true); + var item = Row(completion.Id); item.ItemId = Guid.NewGuid().ToString(); item.IsFailure = null; item.ProtectedIsFailureEnvelope = "rgdp:1:1:failure=="; await store.ReplaceAnswersAsync(77, completion.Id, new[] { item }); uow.CommitChanges(); + var stored = await store.GetAsync(77, completion.Id); + stored.Score.Should().BeNull(); stored.Passed.Should().BeNull(); stored.ProtectedPassedEnvelope.Should().Be(completion.ProtectedPassedEnvelope); + (await store.ListAsync(77, completion.Id)).Single().IsFailure.Should().BeNull(); + Action rollback = () => _runner.GetRequiredService().MigrateDown(191); + rollback.Should().Throw(); + _runner.GetRequiredService().LoadVersionInfo(); _runner.GetRequiredService().MigrateUp(); + (await store.GetAsync(77, completion.Id)).ProtectedScoreEnvelope.Should().Be(completion.ProtectedScoreEnvelope); + } + [Test] + public async Task Duplicate_workflow_queue_deliveries_claim_only_one_attempt_per_department() + { + string Q(string name) => _type == DatabaseTypes.Postgres ? name.ToLowerInvariant() : "[" + name + "]"; + await using var db = Connect(_connection); + var id = Guid.NewGuid().ToString(); var workflow = Guid.NewGuid().ToString(); + await db.ExecuteAsync($"INSERT INTO {Q("WorkflowRuns")} ({Q("WorkflowRunId")},{Q("WorkflowId")},{Q("DepartmentId")},{Q("TriggerEventType")},{Q("Status")},{Q("AttemptNumber")},{Q("InputPayload")}) VALUES(@id,@workflow,77,67,@state,1,@payload)", new { id, workflow, state = (int)WorkflowRunStatus.Pending, payload = "SYNTHETIC-PHI-CANARY" }); + var connections = Connections(); using var first = new UnitOfWork(connections); using var second = new UnitOfWork(connections); + WorkflowRunRepository Repository(IUnitOfWork uow) => new WorkflowRunRepository(connections, Configuration(), uow, new Mock().Object); + (await Repository(first).TryStartChecklistRunAsync(id, workflow, 88, 1, "{}")).Should().BeFalse(); + var claims = await Task.WhenAll(Repository(first).TryStartChecklistRunAsync(id, workflow, 77, 1, "{}"), Repository(second).TryStartChecklistRunAsync(id, workflow, 77, 1, "{}")); + claims.Count(c => c).Should().Be(1); + (await db.QuerySingleAsync($"SELECT {Q("InputPayload")} FROM {Q("WorkflowRuns")} WHERE {Q("WorkflowRunId")}=@id", new { id })).Should().Be("{}"); + (await Repository(first).TryStartChecklistRunAsync(id, workflow, 77, 2, "{}")).Should().BeFalse(); + } } } diff --git a/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs b/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs new file mode 100644 index 000000000..c27af224e --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Queue; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Bus; +using Resgrid.Services; +using Resgrid.Services.Records; +using Resgrid.Tests.Rms; +using Scriban; +using Scriban.Runtime; + +namespace Resgrid.Tests.Services +{ + [TestFixture, NonParallelizable] + public class ChecklistEventDeliveryTests + { + private FakeRmsStore _store; + private EventAggregator _bus; + private Mock _policy; + private ProtectedProjectionService _projection; + private DomainEventOutboxService _outbox; + private ReadinessHistoryTestProtection _history; + [TearDown] public void TearDown() => _history?.Dispose(); + [SetUp] + public void SetUp() + { + _store = new FakeRmsStore(); _bus = new EventAggregator(); _policy = new Mock(); + _projection = new ProtectedProjectionService(_policy.Object, new ProtectedFieldCatalog()); + _history = new ReadinessHistoryTestProtection(_policy); + _outbox = new DomainEventOutboxService(_store.OutboxRepo.Object, _bus, new Lazy(() => _projection), _history.Lazy); + _store.OutboxRepo.Setup(s => s.InitializeChecklistPayloadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _store.OutboxRepo.Setup(s => s.ReplaceChecklistPayloadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DomainEventOutboxEntry e, string payload, CancellationToken ct) => { e.PayloadJson = payload; return true; }); + } + private static DomainEventEnvelope Event() => new DomainEventEnvelope + { + EventName = "ChecklistCompleted", AggregateType = "ChecklistCompletion", AggregateId = Guid.NewGuid().ToString(), Trigger = WorkflowTriggerEventType.ChecklistCompleted, + Payload = new { CompletionId = Guid.NewGuid().ToString(), DefinitionId = Guid.NewGuid().ToString(), VersionId = Guid.NewGuid().ToString(), TargetType = 3, TargetId = "person-42", Score = 87.25m, Passed = false, Note = "SYNTHETIC-PHI-CANARY", Data = new byte[] { 9, 8, 7 } } + }; + [Test] + public async Task Projection_precedes_persistence_and_is_rechecked_after_enrollment() + { + var entry = await _outbox.EnqueueAsync(42, "Checklists", Event()); + entry.PayloadJson.Should().NotContain("CANARY").And.NotContain("Data"); + JObject.Parse(entry.PayloadJson)["Score"].Value().Should().Be(87.25m); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + DomainEventDispatchedEvent delivered = null; + _bus.AddAsyncListener(e => { delivered = e; return Task.CompletedTask; }); + (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(1); + var payload = JObject.Parse(delivered.PayloadJson); + payload["Score"].Value().Should().Be("REDACTED"); payload["Passed"].Value().Should().Be("REDACTED"); payload["TargetId"].Value().Should().Be("REDACTED"); + payload["is_redacted"].Value().Should().BeTrue(); payload["catalog_version"].Value().Should().Be(17); + entry.PayloadJson.Should().StartWith("rgdp:").And.NotContain("87.25").And.NotContain("person-42"); + _history.Decrypt(42, "domaineventoutbox.payloadjson", entry.DomainEventOutboxId.ToString(), entry.PayloadJson).Should().Contain("87.25").And.Contain("person-42"); + } + [Test] + public async Task Protected_or_unknown_policy_never_persists_plaintext_outcomes() + { + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ThrowsAsync(new InvalidOperationException("policy unavailable")); + Func enqueue = () => _outbox.EnqueueAsync(42, "Checklists", Event()); + await enqueue.Should().ThrowAsync(); + _store.OutboxRepo.Verify(s => s.InitializeChecklistPayloadAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Test] + public async Task Async_subscriber_is_awaited_and_failure_remains_retryable_without_logging_its_message() + { + var entry = await _outbox.EnqueueAsync(42, "Checklists", Event()); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var listener = _bus.AddAsyncListener(e => completion.Task); + var dispatch = _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId }); + dispatch.IsCompleted.Should().BeFalse(); entry.DispatchedOn.Should().BeNull(); + completion.SetException(new InvalidOperationException("SYNTHETIC-PHI-CANARY")); + (await dispatch).Should().Be(0); entry.LastError.Should().NotContain("CANARY"); entry.State.Should().Be((int)DomainEventOutboxState.Pending); + _bus.RemoveListener(listener); string deliveredId = null; + _bus.AddAsyncListener(e => { deliveredId = e.EventId; e.IsReplay.Should().BeTrue(); return Task.CompletedTask; }); + (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(1); deliveredId.Should().Be(entry.EventId); + } + + [Test] + public async Task Already_encrypted_historical_events_replay_from_safe_routing_without_decrypting_history() + { + var entry = await _outbox.EnqueueAsync(42, "Checklists", Event()); + entry.PayloadJson = JObject.FromObject(Event().Payload).ToString(); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + await _history.Service.ProtectAsync(42, entry.DomainEventOutboxId.ToString(), entry, ReadinessHistoryFields.Outbox); + var encrypted = entry.PayloadJson; + string delivered = null; + _bus.AddAsyncListener(e => { delivered = e.PayloadJson; return Task.CompletedTask; }); + (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(1); + delivered.Should().NotContain("CANARY").And.NotContain("87.25").And.NotContain("person-42").And.NotContain("rgdp:"); + entry.PayloadJson.Should().Be(encrypted); + _history.Decrypt(42, "domaineventoutbox.payloadjson", entry.DomainEventOutboxId.ToString(), entry.PayloadJson).Should().Contain("SYNTHETIC-PHI-CANARY"); + _history.Broker.Invocations.Should().OnlyContain(i => i.Method.Name == "EncryptAsync"); + } + [TestCase(67), TestCase(69), TestCase(164), TestCase(165)] + public async Task Queue_rejection_after_run_insert_retries_the_same_workflow_run(int trigger) + { + var workflows = new Mock(); var runs = new Mock(); var queue = new Mock(); + var subscriptions = new Mock(); var departments = new Mock(); + subscriptions.Setup(s => s.GetCurrentPlanForDepartmentAsync(42, It.IsAny())).ReturnsAsync(new Plan { PlanId = 999999 }); + var workflow = new Workflow { WorkflowId = Guid.NewGuid().ToString(), DepartmentId = 42, TriggerEventType = trigger }; + workflows.Setup(s => s.GetAllActiveByDepartmentAndEventTypeAsync(42, trigger)).ReturnsAsync(new[] { workflow }); + WorkflowRun stored = null; + runs.Setup(s => s.GetByWorkflowAndEventAsync(workflow.WorkflowId, It.IsAny())).ReturnsAsync(() => stored); + runs.Setup(s => s.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((WorkflowRun r, CancellationToken ct, bool first) => stored = r); + var attempts = new List(); + queue.Setup(s => s.EnqueueWorkflow(It.IsAny())).ReturnsAsync((WorkflowQueueItem item) => { attempts.Add(item); return attempts.Count > 1; }); + _ = new WorkflowEventProvider(_bus, queue.Object, workflows.Object, runs.Object, departments.Object, subscriptions.Object, _projection, _history.Lazy); + var envelope = Event(); envelope.Trigger = (WorkflowTriggerEventType)trigger; envelope.EventName = envelope.Trigger.ToString(); + var entry = await _outbox.EnqueueAsync(42, "Checklists", envelope); + (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(0); stored.Should().NotBeNull(); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(1); + attempts.Should().HaveCount(2); attempts[1].WorkflowRunId.Should().Be(attempts[0].WorkflowRunId); + attempts[1].EventPayloadJson.Should().NotContain("87.25").And.NotContain("CANARY"); + runs.Verify(s => s.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + [TestCase(69), TestCase(164), TestCase(165)] + public async Task Scheduling_event_replay_preserves_safe_timing_and_designer_context_without_decrypting(int trigger) + { + var schedule = Guid.NewGuid().ToString(); var occurrence = Guid.NewGuid().ToString(); var time = new DateTimeOffset(2026, 9, 8, 8, 0, 0, TimeSpan.FromHours(2)); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + var entry = await _outbox.EnqueueAsync(42, "Checklists", new DomainEventEnvelope { Trigger = (WorkflowTriggerEventType)trigger, EventName = ((WorkflowTriggerEventType)trigger).ToString(), AggregateType = "ChecklistOccurrence", AggregateId = occurrence, + Payload = new { ScheduleId = schedule, OccurrenceId = occurrence, PeriodStartUtc = time, WindowEndUtc = time.AddHours(1), Revision = 4, State = 5, IsActive = true, TargetType = 3, TargetId = "SYNTHETIC-PII-CANARY", Reason = "SYNTHETIC-PHI-CANARY" } }); + entry.PayloadJson.Should().StartWith("rgdp:"); + string delivered = null; _bus.AddAsyncListener(e => { delivered = e.PayloadJson; return Task.CompletedTask; }); + (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(1); + delivered.Should().NotContain("CANARY").And.NotContain("rgdp:"); + var payload = JObject.Parse(delivered); payload["ScheduleId"].Value().Should().Be(schedule); payload["PeriodStartUtc"].Value().Should().Be(time.UtcDateTime); + var builder = new WorkflowTemplateContextBuilder(Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); + var context = (ScriptObject)await builder.BuildContextAsync(42, (WorkflowTriggerEventType)trigger, new JObject { ["Payload"] = payload }.ToString(), CancellationToken.None); + Template.Parse("{{ checklist.schedule_id }}|{{ checklist.revision }}|{{ checklist.is_active }}").Render(context).Should().Be(schedule + "|4|true"); + ((ScriptObject)context["checklist"])["url"].ToString().Should().EndWith("/User/Checklists/Due"); + var sample = (ScriptObject)((ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData((WorkflowTriggerEventType)trigger))["checklist"]; + foreach (var key in new[] { "schedule_id", "occurrence_id", "period_start_utc", "window_end_utc", "revision", "state", "is_active" }) sample.Should().ContainKey(key); + _history.Broker.Invocations.Should().OnlyContain(i => i.Method.Name == "EncryptAsync"); + } + [Test] + public async Task Queued_payloads_and_templates_preserve_redaction_after_policy_changes() + { + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + var queued = new JObject { ["Payload"] = JObject.FromObject(Event().Payload), ["LegacyNote"] = "SYNTHETIC-PHI-CANARY" }; + var safe = await ChecklistWorkflowPayload.ProjectAsync(42, queued, _projection, wrapped: true); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(false); + safe = await ChecklistWorkflowPayload.ProjectAsync(42, JObject.Parse(safe), _projection, wrapped: true); + safe.Should().NotContain("CANARY").And.NotContain("87.25"); + var builder = new WorkflowTemplateContextBuilder(Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); + var context = (ScriptObject)await builder.BuildContextAsync(42, WorkflowTriggerEventType.ChecklistCompleted, safe, CancellationToken.None); + var text = Template.Parse("{{ checklist.score }}|{{ checklist.passed }}|{{ protection.is_redacted }}").Render(context); + text.Should().Be("REDACTED|REDACTED|true"); + } + [Test] + public async Task Execution_rechecks_a_legacy_queue_payload_and_does_not_repeat_a_completed_run() + { + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + var workflows = new Mock(); var runs = new Mock(); var context = new Mock(); + var workflow = new Workflow { WorkflowId = Guid.NewGuid().ToString(), DepartmentId = 42, TriggerEventType = 67 }; + var run = new WorkflowRun { WorkflowRunId = Guid.NewGuid().ToString(), WorkflowId = workflow.WorkflowId, DepartmentId = 42, Status = (int)WorkflowRunStatus.Completed }; + workflows.Setup(s => s.GetByIdAsync(workflow.WorkflowId)).ReturnsAsync(workflow); + runs.Setup(s => s.GetByIdAsync(run.WorkflowRunId)).ReturnsAsync(run); + string claimedPayload = null; + runs.Setup(s => s.TryStartChecklistRunAsync(run.WorkflowRunId, workflow.WorkflowId, 42, 1, It.IsAny())).ReturnsAsync((string r, string w, int d, int a, string p) => { claimedPayload = p; return false; }); + var service = new WorkflowService(workflows.Object, Mock.Of(), Mock.Of(), runs.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), context.Object, Mock.Of(), Mock.Of(), new Lazy(() => _projection), _history.Lazy); + (await service.ExecuteWorkflowAsync(workflow.WorkflowId, JsonConvert.SerializeObject(new { Payload = Event().Payload }), 42, "", existingRunId: run.WorkflowRunId)).Should().BeSameAs(run); + claimedPayload.Should().StartWith("rgdp:"); + _history.Decrypt(42, "workflowruns.inputpayload", run.WorkflowRunId, claimedPayload).Should().NotContain("CANARY").And.NotContain("87.25").And.Contain("REDACTED"); + context.Verify(s => s.BuildContextAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Execution_seals_rendered_output_and_provider_errors_and_redacts_workflow_history() + { + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + var workflows = new Mock(); var runs = new Mock(); var logs = new Mock(); var steps = new Mock(); var context = new Mock(); + var workflow = new Workflow { WorkflowId = Guid.NewGuid().ToString(), DepartmentId = 42, TriggerEventType = 67, MaxRetryCount = 1 }; + var run = new WorkflowRun { WorkflowRunId = Guid.NewGuid().ToString(), WorkflowId = workflow.WorkflowId, DepartmentId = 42, TriggerEventType = 67, Status = (int)WorkflowRunStatus.Pending }; + var step = new WorkflowStep { WorkflowStepId = Guid.NewGuid().ToString(), WorkflowId = workflow.WorkflowId, IsEnabled = true, ActionType = (int)WorkflowActionType.SendEmail, OutputTemplate = "SYNTHETIC-TEMPLATE-CANARY" }; + workflows.Setup(s => s.GetByIdAsync(workflow.WorkflowId)).ReturnsAsync(workflow); + runs.Setup(s => s.GetByIdAsync(run.WorkflowRunId)).ReturnsAsync(run); + runs.Setup(s => s.GetRunsByWorkflowIdAsync(workflow.WorkflowId, It.IsAny(), It.IsAny())).ReturnsAsync(new[] { run }); + runs.Setup(s => s.TryStartChecklistRunAsync(run.WorkflowRunId, workflow.WorkflowId, 42, 1, It.IsAny())).ReturnsAsync(true); + steps.Setup(s => s.GetAllByWorkflowIdAsync(workflow.WorkflowId)).ReturnsAsync(new[] { step }); + context.Setup(s => s.BuildContextAsync(42, WorkflowTriggerEventType.ChecklistCompleted, It.IsAny(), It.IsAny())).ReturnsAsync(new ScriptObject()); + var executor = new Mock(); var factory = new Mock(); + factory.Setup(s => s.GetExecutor(WorkflowActionType.SendEmail)).Returns(executor.Object); + executor.Setup(s => s.ExecuteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new WorkflowActionResult { Success = false, ResultMessage = "SYNTHETIC-RESULT-CANARY", ErrorDetail = "SYNTHETIC-ERROR-CANARY" }); + WorkflowRunLog storedLog = null; + logs.Setup(s => s.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((WorkflowRunLog l, CancellationToken ct, bool first) => storedLog = l); + logs.Setup(s => s.GetByWorkflowRunIdAsync(run.WorkflowRunId)).ReturnsAsync(() => new[] { storedLog }); + var service = new WorkflowService(workflows.Object, steps.Object, Mock.Of(), runs.Object, logs.Object, Mock.Of(), Mock.Of(), factory.Object, context.Object, Mock.Of(), Mock.Of(), new Lazy(() => _projection), _history.Lazy); + await service.ExecuteWorkflowAsync(workflow.WorkflowId, JsonConvert.SerializeObject(new { Payload = Event().Payload }), 42, "", existingRunId: run.WorkflowRunId); + storedLog.Should().NotBeNull(); storedLog.RenderedOutput.Should().StartWith("rgdp:"); storedLog.ErrorMessage.Should().StartWith("rgdp:"); storedLog.ActionResult.Should().StartWith("rgdp:"); + _history.Decrypt(42, "workflowrunlogs.renderedoutput", storedLog.WorkflowRunLogId, storedLog.RenderedOutput).Should().Be("SYNTHETIC-TEMPLATE-CANARY"); + _history.Decrypt(42, "workflowrunlogs.errormessage", storedLog.WorkflowRunLogId, storedLog.ErrorMessage).Should().Be("SYNTHETIC-ERROR-CANARY"); + (await service.GetWorkflowRunByIdAsync(run.WorkflowRunId)).InputPayload.Should().Be("REDACTED"); + (await service.GetLogsForRunAsync(run.WorkflowRunId)).Single().RenderedOutput.Should().Be("REDACTED"); + (await service.GetWorkflowHealthAsync(workflow.WorkflowId)).LastErrorMessage.Should().Be("REDACTED"); + run.InputPayload.Should().StartWith("rgdp:"); storedLog.RenderedOutput.Should().StartWith("rgdp:"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs b/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs new file mode 100644 index 000000000..0150cb711 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Model.Repositories; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class GdprExportProtectedDataTests + { + [TestCase(false), TestCase(true)] + public async Task Checklist_export_pages_all_owned_data_and_masks_plaintext_during_enrollment_or_policy_failure(bool policyFailure) + { + var store = new ChecklistWorkflowTests.MemoryStore(); + var completion = new ChecklistCompletion { DepartmentId = DeptId, CreatedBy = UserId, Content = "SYNTHETIC-PHI-CANARY", Score = 23.5m, Passed = false, OccurrenceId = Guid.NewGuid().ToString() }; + await store.WriteAsync(completion, true); + for (var i = 0; i < 251; i++) await store.WriteAsync(new ChecklistCompletionItem { DepartmentId = DeptId, ParentId = completion.Id, Content = "SYNTHETIC-PHI-CANARY", IsFailure = true }, true); + for (var i = 0; i < 501; i++) await store.WriteAsync(new ChecklistCompletionFile { DepartmentId = DeptId, ParentId = completion.Id, Content = "SYNTHETIC-PHI-CANARY" }, true); + for (var i = 0; i < 25; i++) + { + var another = new ChecklistCompletion { DepartmentId = DeptId, CreatedBy = UserId, Content = "SYNTHETIC-PHI-CANARY" }; + await store.WriteAsync(another, true); + await store.WriteAsync(new ChecklistCompletionItem { DepartmentId = DeptId, ParentId = another.Id, Content = "SYNTHETIC-PHI-CANARY" }, true); + await store.WriteAsync(new ChecklistCompletionFile { DepartmentId = DeptId, ParentId = another.Id, Content = "SYNTHETIC-PHI-CANARY", Data = new byte[] { 1, 2, 3 } }, true); + } + var schedule = new ChecklistSchedule { DepartmentId = DeptId, CreatedBy = UserId, Content = "SYNTHETIC-PHI-CANARY" }; await store.WriteAsync(schedule, true); + await store.WriteAsync(new ChecklistSchedule { DepartmentId = 999, CreatedBy = UserId, Content = "FOREIGN-TENANT-CANARY" }, true); + await store.WriteAsync(new ChecklistSchedule { DepartmentId = DeptId, CreatedBy = "another-user", Content = "UNRELATED-USER-CANARY" }, true); + await store.WriteAsync(new ChecklistOccurrence { Id = completion.OccurrenceId, DepartmentId = DeptId, ScheduleId = schedule.Id, Content = "SYNTHETIC-PHI-CANARY" }, true); + var policy = new Mock(); + if (policyFailure) policy.Setup(p => p.IsProtectionEnforcedAsync(DeptId)).ThrowsAsync(new InvalidOperationException("Policy unavailable")); + else policy.Setup(p => p.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(true); + var protection = new ReadinessHistoryProtectionService(new Mock().Object, policy.Object); + var reminders = new Mock(); + reminders.Setup(r => r.ForRecipientAsync(DeptId, UserId, It.IsAny(), It.IsAny())) + .ReturnsAsync((int department, string user, int skip, CancellationToken ct) => Enumerable.Range(0, 205).Skip(skip).Take(100) + .Select(i => new ChecklistReminder { DepartmentId = department, RecipientUserId = user, ClaimToken = "SYNTHETIC-CLAIM-SECRET", OccurrenceId = completion.OccurrenceId, CreatedOnUtc = DateTime.UtcNow }).ToList()); + _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, _emergencyContactService.Object, + _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, + _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, store, + new Lazy(() => protection), reminders.Object); + var files = await RunExportAsync(); var json = files["checklists.json"]; + json.Should().NotContain("CANARY").And.NotContain("23.5").And.NotContain("rgdp:").And.Contain("REDACTED"); + var exported = JObject.Parse(json); + exported["Completions"].Should().HaveCount(26); + store.ChildQueries.Should().Be(9, "children are paged across all selected parents, not queried per completion"); + json.Should().NotContain("AQID", "evidence blobs are excluded from JSON exports"); + exported["Schedules"].Should().HaveCount(1); exported["Occurrences"].Should().HaveCount(1); + exported["Reminders"].Should().HaveCount(205); json.Should().NotContain("CLAIM-SECRET").And.NotContain("ClaimToken"); + exported["Completions"][0]["Answers"].Should().HaveCount(251); exported["Completions"][0]["Files"].Should().HaveCount(501); + (await store.GetAsync(DeptId, completion.Id)).Score.Should().Be(23.5m); + (await store.GetAsync(DeptId, schedule.Id)).Content.Should().Be("SYNTHETIC-PHI-CANARY"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M2DatabaseTests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M2DatabaseTests.cs new file mode 100644 index 000000000..dd6983396 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M2DatabaseTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Checklists; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Transactions; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistDatabaseTests + { + [Test, Order(120)] + public async Task Assignment_digest_and_access_metadata_survive_transactions_without_rewriting_protected_content() + { + _runner.GetRequiredService().LoadVersionInfo(); var runner = _runner.GetRequiredService(); runner.MigrateUp(); + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); var now = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); + var schedule = (await store.ActiveSchedulesAsync(77, "")).First(); + await uow.CreateOrGetConnectionAsync(); await store.LockAccessFenceAsync(); await store.LockDepartmentAsync(77); + schedule.AssignmentType = 1; schedule.AssignmentId = "author"; schedule.Content = "SYNTHETIC-BOUND-CIPHERTEXT"; await store.WriteAsync(schedule, false); + var settings = (await store.ListAsync(77)).Single(); settings.FixedDigestMinute = 90; settings.DigestActiveFromUtc = now; await store.WriteAsync(settings, false); + await store.AdvanceDigestSweepAsync(77, now); await store.ApplyAccessStateAsync(77, false, now); uow.CommitChanges(); + (await store.GetAsync(77, schedule.Id)).IsSuspended.Should().BeTrue(); + await uow.CreateOrGetConnectionAsync(); await store.LockAccessFenceAsync(); await store.LockDepartmentAsync(77); await store.ApplyAccessStateAsync(77, true, now.AddDays(1)); uow.DiscardChanges(); + (await store.GetAsync(77, schedule.Id)).IsSuspended.Should().BeTrue("a failed policy transaction rolls back resumption"); + await uow.CreateOrGetConnectionAsync(); await store.LockAccessFenceAsync(); await store.LockDepartmentAsync(77); await store.ApplyAccessStateAsync(77, true, now.AddDays(1)); uow.CommitChanges(); + var resumed = await store.GetAsync(77, schedule.Id); resumed.Content.Should().Be(schedule.Content); resumed.AssignmentId.Should().Be("author"); resumed.ActiveFromUtc.Should().Be(now.AddDays(1)); + (await store.ListAsync(77)).Single().LastDigestSweepUtc.Should().Be(now); + var occurrence = (await store.ListAsync(77)).First(); + var queue = new ChecklistReminderRepository(connections, Configuration(), uow, Mock.Of()); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + foreach (var key in new[] { "fixed:20260908", "fixed:20260908", "fixed:20260909" }) await queue.EnqueueAsync(new ChecklistReminder { DepartmentId = 77, OccurrenceId = occurrence.Id, RecipientUserId = "timed-recipient", Kind = 4, PeriodKey = key, CreatedOnUtc = now, NextAttemptUtc = now }); + uow.CommitChanges(); (await queue.ForRecipientAsync(77, "timed-recipient", 0)).Should().HaveCount(2); + Action down = () => runner.MigrateDown(195); down.Should().Throw().WithMessage("*preserved*"); + } + [Test, Order(121)] + public async Task Shift_digest_query_enforces_department_unit_ownership_deleted_shifts_and_trigger_bounds() + { + await using var db = Connect(_connection); var dateType = _type == Resgrid.Config.DatabaseTypes.Postgres ? "timestamp" : "datetime2"; + await db.ExecuteAsync($"ALTER TABLE {Q("Workshifts")} ADD {Q("Type")} int; ALTER TABLE {Q("WorkshiftDays")} ADD {Q("WorkshiftDayId")} varchar(36); CREATE TABLE {Q("WorkshiftEntities")} ({Q("WorkshiftId")} varchar(36),{Q("BackingId")} varchar(128)); CREATE TABLE {Q("Units")} ({Q("UnitId")} int PRIMARY KEY,{Q("DepartmentId")} int);"); + var id = Guid.NewGuid().ToString(); var day = Guid.NewGuid().ToString(); var from = new DateTime(2026, 9, 8, 8, 0, 0, DateTimeKind.Utc); var until = from.AddHours(1); + await db.ExecuteAsync($"INSERT INTO {Q("Workshifts")} ({Q("WorkshiftId")},{Q("DepartmentId")},{Q("Type")}) VALUES(@id,77,1); INSERT INTO {Q("Units")} VALUES(12,77),(13,88); INSERT INTO {Q("WorkshiftEntities")} VALUES(@id,'12'),(@id,'13'); INSERT INTO {Q("WorkshiftDays")} ({Q("WorkshiftId")},{Q("Day")},{Q("WorkshiftDayId")}) VALUES(@id,@from,@other),(@id,@until,@day);", new { id, day, from, until, other = Guid.NewGuid().ToString() }); + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + (await store.ShiftStartsAsync(77, from, until)).Should().ContainSingle(s => s.UnitId == 12 && s.WorkshiftDayId == day && s.StartUtc == until); + (await store.ShiftStartsAsync(88, from, until)).Should().BeEmpty(); + await db.ExecuteAsync($"UPDATE {Q("Workshifts")} SET {Q("DeletedOn")}=@until WHERE {Q("WorkshiftId")}=@id", new { until, id }); + (await store.ShiftStartsAsync(77, from, until)).Should().BeEmpty(); + } + [Test, Order(125)] + public async Task Restored_schedule_and_evidence_keep_their_authenticated_row_and_department_binding() + { + var crypto = new Resgrid.Services.ProtectedFieldCryptoService(); var key = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32); + try + { + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + var schedule = (await store.ActiveSchedulesAsync(77, "")).First(); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + schedule.Content = crypto.EncryptText(key, 1, "SYNTHETIC-PRIVATE-SCHEDULE", 77, "checklistschedules.content", schedule.Id); schedule.IsProtected = true; await store.WriteAsync(schedule, false); + var occurrence = Row(schedule.ParentId); occurrence.VersionId = schedule.VersionId; occurrence.TargetId = "77"; occurrence.CompletionId = Guid.NewGuid().ToString(); await store.WriteAsync(occurrence, true); + var completion = Row(schedule.ParentId); completion.Id = occurrence.CompletionId; completion.OccurrenceId = occurrence.Id; completion.VersionId = schedule.VersionId; completion.TargetId = "77"; await store.WriteAsync(completion, true); + var evidence = Row(completion.Id); evidence.ItemId = Guid.NewGuid().ToString(); evidence.ContentType = "image/png"; evidence.Size = 4; evidence.ScanState = 1; evidence.IsProtected = true; + evidence.Content = crypto.EncryptText(key, 1, "SYNTHETIC-PRIVATE-FILENAME", 77, "checklistcompletionfiles.content", evidence.Id); evidence.Data = crypto.EncryptBinary(key, 1, new byte[] { 1, 2, 3, 4 }, 77, "checklistcompletionfiles.data", evidence.Id); evidence.Sha256 = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(new byte[] { 1, 2, 3, 4 })); await store.WriteAsync(evidence, true); uow.CommitChanges(); + var restored = await store.GetAsync(77, evidence.Id); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); await store.DeleteFileAsync(77, evidence.Id); await store.WriteAsync(restored, true); uow.CommitChanges(); + var after = await store.GetAsync(77, evidence.Id); after.Data.Should().Equal(evidence.Data); after.Content.Should().Be(evidence.Content); + crypto.DecryptBinary(key, after.Data, 77, "checklistcompletionfiles.data", after.Id).Should().Equal(1, 2, 3, 4); + crypto.DecryptText(key, (await store.GetAsync(77, schedule.Id)).Content, 77, "checklistschedules.content", schedule.Id).Should().Be("SYNTHETIC-PRIVATE-SCHEDULE"); + Action foreign = () => crypto.DecryptBinary(key, after.Data, 88, "checklistcompletionfiles.data", after.Id); foreign.Should().Throw(); + Action moved = () => crypto.DecryptText(key, schedule.Content, 77, "checklistschedules.content", Guid.NewGuid().ToString()); moved.Should().Throw(); + } + finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(key); } + } + [Test, Order(130)] + public async Task Department_cleanup_preserves_holds_rolls_back_with_its_caller_and_erases_the_readiness_subtree_only() + { + await using var db = Connect(_connection); await db.OpenAsync(); + var date = _type == Resgrid.Config.DatabaseTypes.Postgres ? "timestamp" : "datetime2"; + await db.ExecuteAsync($"CREATE TABLE {Q("RmsRecordLegalHolds")} ({Q("DepartmentId")} int,{Q("ReleasedOn")} {date}); INSERT INTO {Q("RmsRecordLegalHolds")} VALUES(77,NULL);"); + var foreign = Row(); foreign.DepartmentId = 88; + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + await uow.CreateOrGetConnectionAsync(); await store.WriteAsync(foreign, true); uow.CommitChanges(); + var run = Guid.NewGuid().ToString(); var foreignRun = Guid.NewGuid().ToString(); var log = Guid.NewGuid().ToString(); var foreignLog = Guid.NewGuid().ToString(); + await db.ExecuteAsync($"INSERT INTO {Q("WorkflowRuns")} ({Q("WorkflowRunId")},{Q("DepartmentId")},{Q("TriggerEventType")},{Q("InputPayload")}) VALUES(@run,77,67,'SYNTHETIC-PROTECTED-HISTORY'),(@foreignRun,88,67,'SYNTHETIC-FOREIGN-HISTORY'); INSERT INTO {Q("WorkflowRunLogs")} ({Q("WorkflowRunLogId")},{Q("WorkflowRunId")},{Q("RenderedOutput")}) VALUES(@log,@run,'SYNTHETIC-LOG'),(@foreignLog,@foreignRun,'SYNTHETIC-FOREIGN-LOG');", new { run, foreignRun, log, foreignLog }); + var count = await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("ChecklistDefinitions")} WHERE {Q("DepartmentId")}=77"); count.Should().BePositive(); + await using (var held = await db.BeginTransactionAsync()) { Func erase = () => ChecklistDepartmentCleanup.DeleteWithinTransactionAsync(db, held, 77, _type); await erase.Should().ThrowAsync().WithMessage("*legal hold*"); await held.RollbackAsync(); } + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("ChecklistDefinitions")} WHERE {Q("DepartmentId")}=77")).Should().Be(count); + await db.ExecuteAsync($"UPDATE {Q("RmsRecordLegalHolds")} SET {Q("ReleasedOn")}=@now WHERE {Q("DepartmentId")}=77", new { now = DateTime.UtcNow }); + await using (var rollback = await db.BeginTransactionAsync()) { await ChecklistDepartmentCleanup.DeleteWithinTransactionAsync(db, rollback, 77, _type); await rollback.RollbackAsync(); } + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("ChecklistDefinitions")} WHERE {Q("DepartmentId")}=77")).Should().Be(count); + await using (var commit = await db.BeginTransactionAsync()) { await ChecklistDepartmentCleanup.DeleteWithinTransactionAsync(db, commit, 77, _type); await commit.CommitAsync(); } + foreach (var table in new[] { "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q(table)} WHERE {Q("DepartmentId")}=77")).Should().Be(0, table); + (await store.GetAsync(88, foreign.Id)).Should().NotBeNull(); + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("WorkflowRunLogs")} WHERE {Q("WorkflowRunId")}=@run", new { run })).Should().Be(0); + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("WorkflowRunLogs")} WHERE {Q("WorkflowRunId")}=@foreignRun", new { foreignRun })).Should().Be(1); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M2Tests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M2Tests.cs new file mode 100644 index 000000000..18175361a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M2Tests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + private void UseAssignmentService(ScheduleClock clock, IChecklistAssignmentService assignments, IChecklistAssetSource assets = null) + { + _service = new ChecklistsService(_store, _authorization.Object, _access.Object, _uow.Object, _audits.Object, _outbox.Object, + new Lazy(() => _read.Object), new Lazy(() => _write.Object), _scanner.Object, clock, assignments, assets); + } + [Test] + public async Task Assignments_gate_start_and_active_run_edits_without_changing_witness_ownership() + { + var setup = await Scheduled(); var assignments = new Mock(); UseAssignmentService(setup.Clock, assignments.Object); + setup.Input.AssignmentType = 1; setup.Input.AssignmentId = "author"; + await _service.SaveScheduleAsync(_actor, setup.Input); + await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); setup.Clock.Now = setup.Clock.Now.AddHours(1); + var occurrence = (await _store.ListAsync(77)).OrderBy(o => o.PeriodStartUtc).First(); + Func denied = () => _service.StartOccurrenceAsync(_actor, occurrence.Id); (await denied.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + assignments.Setup(a => a.CanPerformAsync(_actor, It.IsAny())).ReturnsAsync(true); + var run = await _service.StartOccurrenceAsync(_actor, occurrence.Id); var view = await _service.GetRunAsync(_actor, run); + assignments.Setup(a => a.CanPerformAsync(_actor, It.IsAny())).ReturnsAsync(false); + Func revoked = () => _service.SaveRunAsync(_actor, run, Answers(view.Form), true); + (await revoked.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + (await _store.GetAsync(77, run)).State.Should().Be(0); + _events.Should().Contain(e => e.Trigger == WorkflowTriggerEventType.ChecklistScheduleChanged); + JsonConvert.SerializeObject(_events).Should().NotContain("SYNTHETIC-SENSITIVE"); + } + [Test] + public async Task Calendar_uses_stable_virtual_ids_tenant_scope_and_redacted_titles_without_copying_notes() + { + var setup = await Scheduled(); await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + var from = setup.Clock.Now.UtcDateTime; var entries = await _service.CalendarAsync(_actor, from, from.AddDays(8)); + entries.Should().HaveCount(7).And.OnlyContain(e => e.Id == "checklist:" + e.OccurrenceId && !e.IsRedacted); + JsonConvert.SerializeObject(entries).Should().NotContain("SYNTHETIC-SENSITIVE-NOTES"); + (await _service.CalendarAsync(new ChecklistActor { DepartmentId = 88, UserId = "foreign" }, from, from.AddDays(8))).Should().BeEmpty(); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { RedactedFields = { "checklistschedules.content" } })); + (await _service.CalendarAsync(_actor, from, from.AddDays(8))).Should().OnlyContain(e => e.IsRedacted && e.Title == "REDACTED"); + _authorization.Setup(a => a.TargetAsync(_actor, It.IsAny(), It.IsAny())).ThrowsAsync(new ChecklistException(404, "Target is unavailable.")); + (await _service.CalendarAsync(_actor, from, from.AddDays(8))).Should().BeEmpty(); + Func excessive = () => _service.CalendarAsync(_actor, from, from.AddDays(94)); (await excessive.Should().ThrowAsync()).Which.StatusCode.Should().Be(400); + } + [Test] + public async Task Calendar_pages_past_500_occurrences_and_excludes_cancelled_and_out_of_range_rows() + { + var setup = await Scheduled(); await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + var first = (await _store.ListAsync(77)).First(); + for (var i = 0; i < 503; i++) { var row = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(first)); row.Id = Guid.NewGuid().ToString(); await _store.WriteAsync(row, true); } + var from = setup.Clock.Now.UtcDateTime; + (await _service.CalendarAsync(_actor, from, from.AddDays(8))).Should().HaveCount(510); + first.State = 6; await _store.WriteAsync(first, false); + (await _service.CalendarAsync(_actor, from, from.AddDays(8))).Should().HaveCount(509); + (await _service.CalendarAsync(_actor, from.AddDays(20), from.AddDays(21))).Should().BeEmpty(); + } + [Test] + public async Task Asset_templates_and_existing_schedules_degrade_when_the_optional_source_is_absent() + { + var template = ChecklistTemplateCatalog.All.First(t => t.SuggestedTargetType == ChecklistTargetType.InventoryAsset); + ChecklistForm.FromTemplate(template).TargetType.Should().Be(ChecklistTargetType.Department); + ChecklistForm.FromTemplate(template, true).TargetType.Should().Be(ChecklistTargetType.InventoryAsset); + (await _service.AssetTargetsAvailableAsync(_actor)).Should().BeFalse(); + Func unavailable = () => _service.SaveDefinitionAsync(_actor, null, 0, ChecklistForm.FromTemplate(template, true)); + await unavailable.Should().ThrowAsync(); + var setup = await Scheduled(); var schedule = await _store.GetAsync(77, setup.Input.Id); + schedule.TargetType = 5; schedule.TargetId = Guid.NewGuid().ToString(); await _store.WriteAsync(schedule, false); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Generated.Should().Be(0); + (await _store.GetAsync(77, schedule.Id)).IsSuspended.Should().BeTrue(); + } + [TestCase("08:00,09:00"), TestCase("25:00"), TestCase("8:00"), TestCase("12:30:00")] + public async Task Digest_time_requires_one_valid_24_hour_time(string value) + { + Func save = () => _service.SaveReminderSettingsAsync(_actor, new ChecklistReminderSettingsInput { FixedDigestTime = value }); + (await save.Should().ThrowAsync()).Which.StatusCode.Should().Be(400); + } + private async Task TimedOccurrence(DateTime now, int minute, string zone = "UTC") + { + var row = await ReminderOccurrence(); row.PeriodStartUtc = now.AddHours(-1); row.WindowEndUtc = now.AddHours(8); await _store.WriteAsync(row, false); + var schedule = await _store.GetAsync(77, row.ScheduleId); schedule.ActiveFromUtc = now.AddDays(-1); await _store.WriteAsync(schedule, false); + var settings = (await _store.ListAsync(77)).Single(); settings.RemindersActiveFromUtc = settings.DigestActiveFromUtc = now.AddHours(-2); + settings.LastDigestSweepUtc = now.AddMinutes(-5); settings.NotifyBeforeMinutes = 0; settings.FixedDigestMinute = minute; await _store.WriteAsync(settings, false); return row; + } + [TestCase(2026, 3, 8, 7, 0, 150), TestCase(2026, 11, 1, 5, 30, 90)] + public async Task Fixed_digest_handles_DST_gaps_folds_and_replays_once_per_local_day(int year, int month, int day, int hour, int minute, int localMinute) + { + var now = new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Utc); await TimedOccurrence(now, localMinute); + var harness = Reminders(); harness.Departments.Setup(s => s.GetDepartmentByIdAsync(77, true)).ReturnsAsync(new Department { DepartmentId = 77, ManagingUserId = "author", TimeZone = "America/New_York" }); + (await harness.Service.SweepAsync(now)).HandedOff.Should().Be(1); + (await harness.Service.SweepAsync(now.AddHours(1))).HandedOff.Should().Be(0); + harness.Notices.Should().ContainSingle(n => n.Kind == (int)ChecklistReminderKind.FixedTime && n.PeriodKey == "fixed:" + now.ToString("yyyyMMdd")); + _read.Invocations.Clear(); await harness.Service.SweepAsync(now.AddMinutes(5)); _read.Invocations.Should().BeEmpty(); + } + [Test] + public async Task Fixed_digest_reconciles_an_asset_move_before_delivery_to_current_unit_crew() + { + var now = new DateTime(2026, 9, 8, 9, 0, 0, DateTimeKind.Utc); var row = await TimedOccurrence(now, 540); + var assets = new Mock(); assets.Setup(a => a.IsAvailableAsync(77)).ReturnsAsync(true); var unitId = 12; + row.TargetType = 5; row.TargetId = Guid.NewGuid().ToString(); await _store.WriteAsync(row, false); + assets.Setup(a => a.RoutingAsync(77, row.TargetId)).ReturnsAsync(() => new ChecklistAssetTarget { DepartmentId = 77, Id = row.TargetId, UnitId = unitId }); + assets.Setup(a => a.CanReceiveReminderAsync(77, It.IsAny(), row.TargetId)).ReturnsAsync(true); + var harness = Reminders(assets: assets.Object); + foreach (var id in new[] { 12, 13 }) { var user = id == 12 ? "crew" : "author"; harness.Units.Setup(u => u.GetUnitByIdAsync(id)).ReturnsAsync(new Unit { UnitId = id, DepartmentId = 77 }); harness.Units.Setup(u => u.GetActiveRolesForUnitAsync(id)).ReturnsAsync(new List { new UnitActiveRole { UnitId = id, DepartmentId = 77, UserId = user } }); } + harness.BeforeClaim = () => unitId = 13; + (await harness.Service.SweepAsync(now)).Suppressed.Should().Be(1); + (await harness.Service.SweepAsync(now.AddMinutes(5))).HandedOff.Should().Be(1); + ((string)harness.Communication.Invocations.Single().Arguments[0]).Should().Be("author"); + (await harness.Service.SweepAsync(now.AddMinutes(10))).HandedOff.Should().Be(0); + assets.Verify(a => a.GetAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Test] + public async Task Shift_digest_uses_current_unit_staff_and_fixed_time_overrides_shift_triggers() + { + var now = new DateTime(2026, 9, 8, 9, 0, 0, DateTimeKind.Utc); var row = await TimedOccurrence(now, 600); + row.TargetType = 1; row.TargetId = "12"; await _store.WriteAsync(row, false); + _store.ShiftStarts.Add(new ChecklistShiftStart { UnitId = 12, WorkshiftDayId = Guid.NewGuid().ToString(), StartUtc = now }); + var harness = Reminders(); harness.Units.Setup(u => u.GetUnitByIdAsync(12)).ReturnsAsync(new Unit { UnitId = 12, DepartmentId = 77 }); harness.Units.Setup(u => u.GetActiveRolesForUnitAsync(12)).ReturnsAsync(new List { new UnitActiveRole { UnitId = 12, DepartmentId = 77, UserId = "crew" } }); + (await harness.Service.SweepAsync(now)).HandedOff.Should().Be(0); + var settings = (await _store.ListAsync(77)).Single(); settings.FixedDigestMinute = null; await _store.WriteAsync(settings, false); + (await harness.Service.SweepAsync(now)).HandedOff.Should().Be(1); + harness.Notices.Should().ContainSingle(n => n.Kind == 3 && n.RecipientUserId == "crew"); + (await harness.Service.SweepAsync(now.AddMinutes(5))).HandedOff.Should().Be(0); + } + [Test] + public async Task Access_observer_records_a_complete_off_on_interval_without_worker_sweeps_or_decryption() + { + var setup = await Scheduled(); await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + var observer = new ChecklistAccessMutationObserver(_store, Mock.Of(), setup.Clock); + await observer.BeforeChangeAsync(77, default); await observer.AfterChangeAsync((_, _) => Task.FromResult(false), default); + (await _store.GetAsync(77, setup.Input.Id)).IsSuspended.Should().BeTrue(); + setup.Clock.Now = setup.Clock.Now.AddDays(3); + await observer.BeforeChangeAsync(77, default); await observer.AfterChangeAsync((_, _) => Task.FromResult(true), default); + var resumed = await _store.GetAsync(77, setup.Input.Id); resumed.IsSuspended.Should().BeFalse(); resumed.ActiveFromUtc.Should().Be(setup.Clock.Now.UtcDateTime); + _read.Invocations.Clear(); (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Missed.Should().Be(0); _read.Invocations.Should().BeEmpty(); + (await _store.ListAsync(77)).Where(o => o.PeriodStartUtc < resumed.ActiveFromUtc).Should().OnlyContain(o => o.State == 6); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistPageAcceptanceTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPageAcceptanceTests.cs new file mode 100644 index 000000000..153b70946 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistPageAcceptanceTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + // Exercise production routes, authorization, antiforgery, model binding and compiled Razor + // page bodies. The unrelated global navigation shell is excluded from this isolated host. + private sealed class PageBodyFilter : IResultFilter + { + public void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is ViewResult view) + context.Result = new PartialViewResult { ViewName = "/Areas/User/Views/Checklists/" + (view.ViewName ?? context.RouteData.Values["action"]) + ".cshtml", ViewData = view.ViewData, TempData = view.TempData, StatusCode = view.StatusCode }; + } + public void OnResultExecuted(ResultExecutedContext context) { } + } + private sealed class ChecklistTestAuthentication : AuthenticationHandler + { + public ChecklistTestAuthentication(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue("Test-Member", out var user)) return Task.FromResult(AuthenticateResult.NoResult()); + var claims = new[] { new Claim(ClaimTypes.PrimarySid, user.ToString()), new Claim(ClaimTypes.PrimaryGroupSid, "77"), new Claim(ClaimTypes.Role, user == "author" ? "manager" : "member") }; + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, "checklist-test")), "checklist-test"))); + } + } + [Test, NonParallelizable] + public async Task Authenticated_pages_enforce_permission_antiforgery_localization_binding_and_revision_conflicts() + { + var setup = await Scheduled(); + _authorization.Setup(a => a.TargetsAsync(It.IsAny(), ChecklistTargetType.Department)).ReturnsAsync(new List { new ChecklistTarget { Type = ChecklistTargetType.Department, Id = "77", Name = "Test department" } }); + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); while (directory != null && !System.IO.File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) directory = directory.Parent; + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = Path.Combine(directory.FullName, "Web", "Resgrid.Web"), EnvironmentName = "Testing" }); + builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddHttpContextAccessor(); builder.Services.AddLocalization(); + builder.Services.AddAuthentication("checklist-test").AddScheme("checklist-test", _ => { }); + builder.Services.AddAuthorization(o => o.AddPolicy(ResgridResources.Checklist_Update, p => p.RequireRole("manager"))); + builder.Services.AddControllersWithViews(o => o.Filters.Add(new PageBodyFilter())).AddApplicationPart(typeof(ChecklistsController).Assembly); + builder.Services.AddSingleton(_service as IChecklistsService); builder.Services.AddSingleton(_access.Object); + builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); + await using var app = builder.Build(); var previous = ClaimsAuthorizationHelper._httpContextAccessor; + ClaimsAuthorizationHelper._httpContextAccessor = app.Services.GetRequiredService(); + app.Use(async (context, next) => { try { await next(); } catch (Exception ex) { context.Response.StatusCode = 500; await context.Response.WriteAsync(ex.ToString()); } }); + app.UseRequestLocalization(new RequestLocalizationOptions().SetDefaultCulture("fr").AddSupportedCultures("fr").AddSupportedUICultures("fr")); + app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllerRoute("areas", "{area:exists}/{controller}/{action=Index}/{id?}"); + try + { + await app.StartAsync(); using var handler = new HttpClientHandler { AllowAutoRedirect = false, CookieContainer = new CookieContainer() }; using var client = new HttpClient(handler) { BaseAddress = new Uri(app.Urls.Single()) }; + var path = "/User/Checklists/EditSchedule?id=" + setup.Input.Id; + (await client.GetAsync(path)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + client.DefaultRequestHeaders.Add("Test-Member", "crew"); (await client.GetAsync(path)).StatusCode.Should().Be(HttpStatusCode.Forbidden); + client.DefaultRequestHeaders.Remove("Test-Member"); client.DefaultRequestHeaders.Add("Test-Member", "author"); + var response = await client.GetAsync(path); var html = await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.OK, html); + response.Headers.CacheControl.NoStore.Should().BeTrue(); WebUtility.HtmlDecode(html).Should().Contain("Attribution automatique").And.NotContain("Automatic routing"); + var token = WebUtility.HtmlDecode(Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value); token.Should().NotBeNullOrEmpty(); + var fields = new Dictionary { ["Input.Id"] = setup.Input.Id, ["Input.DefinitionId"] = setup.Input.DefinitionId, ["Input.Revision"] = "1", ["Input.Name"] = "HTTP schedule edit", ["Input.TargetId"] = "77", ["Input.Frequency"] = "2", ["Input.TimeZoneId"] = "UTC", ["Input.StartDate"] = "2026-09-08", ["Input.TimesOfDay"] = "10:00", ["Input.WindowMinutes"] = "60", ["Input.DayOfMonth"] = "1", ["Input.MonthOfYear"] = "1", ["Input.IsActive"] = "true", ["assignment"] = "0:" }; + (await client.PostAsync("/User/Checklists/SaveSchedule", new FormUrlEncodedContent(fields))).StatusCode.Should().Be(HttpStatusCode.BadRequest); + fields["__RequestVerificationToken"] = token; + response = await client.PostAsync("/User/Checklists/SaveSchedule", new FormUrlEncodedContent(fields)); response.StatusCode.Should().Be(HttpStatusCode.Redirect, await response.Content.ReadAsStringAsync()); + (await _service.GetScheduleAsync(_actor, setup.Input.Id)).Content.Name.Should().Be("HTTP schedule edit"); + (await client.PostAsync("/User/Checklists/SaveSchedule", new FormUrlEncodedContent(fields))).StatusCode.Should().Be(HttpStatusCode.Conflict); + await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); var occurrence = (await _store.ListAsync(77)).First(); + foreach (var page in new[] { "/User/Checklists/Due", "/User/Checklists/Reminders", "/User/Checklists/Occurrence?id=" + occurrence.Id }) + { response = await client.GetAsync(page); response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); } + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { RedactedFields = { "checklistschedules.content" } })); + response = await client.GetAsync(path); html = await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.OK, html); html.Should().NotContain("HTTP schedule edit"); + response = await client.GetAsync("/User/Checklists/Occurrence?id=" + occurrence.Id); html = await response.Content.ReadAsStringAsync(); html.Should().Contain("value=\"Occurrence\"").And.NotContain("HTTP schedule edit"); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult())); + response = await client.PostAsync("/User/Checklists/Reopen", new FormUrlEncodedContent(new Dictionary { ["page"] = "Occurrence", ["id"] = occurrence.Id, ["__RequestVerificationToken"] = token })); + response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); + } + finally { await app.StopAsync(); ClaimsAuthorizationHelper._httpContextAccessor = previous; } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr503RegressionTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr503RegressionTests.cs new file mode 100644 index 000000000..47654b07c --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistPr503RegressionTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Repositories.DataRepository.Transactions; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + [TestCase(0), TestCase(49), TestCase(50), TestCase(51)] + public async Task Definition_paging_counts_visible_rows_and_looks_ahead(int count) + { + _authorization.Setup(a => a.CanManageAsync(It.IsAny())).ReturnsAsync(false); + var version = new ChecklistDefinitionVersion { DepartmentId = 77, Content = JsonConvert.SerializeObject(Form()) }; + await _store.WriteAsync(version, true); + for (var i = 0; i < 110; i++) + await _store.WriteAsync(new ChecklistDefinition { DepartmentId = 77, Content = "hidden draft", CreatedOn = DateTime.UtcNow.AddDays(1) }, true); + for (var i = 0; i < count; i++) + await _store.WriteAsync(new ChecklistDefinition { DepartmentId = 77, CurrentVersionId = version.Id, Content = "draft", CreatedOn = DateTime.UtcNow.AddSeconds(i) }, true); + var first = await _service.ListAsync(_actor, includeNext: true); + first.Should().HaveCount(count); + first.All(d => d.Definition.Content == null).Should().BeTrue(); + var second = await _service.ListAsync(_actor, 1, includeNext: true); + second.Should().HaveCount(Math.Max(0, count - 50)); + second.Select(d => d.Definition.Id).Should().NotIntersectWith(first.Take(50).Select(d => d.Definition.Id)); + } + + [Test] + public async Task History_paging_skips_unreadable_runs_before_counting_rows() + { + var definition = Guid.NewGuid().ToString(); + var occurrence = new ChecklistOccurrence { DepartmentId = 77, Content = JsonConvert.SerializeObject(new ChecklistTarget { Name = "Visible target" }) }; + await _store.WriteAsync(occurrence, true); + for (var i = 0; i < 160; i++) + await _store.WriteAsync(new ChecklistCompletion { DepartmentId = 77, ParentId = definition, OccurrenceId = occurrence.Id, + CreatedBy = i < 109 ? "another member" : _actor.UserId, CreatedOn = DateTime.UtcNow.AddSeconds(-i), Content = "{}" }, true); + var first = await _service.HistoryAsync(_actor, definition, includeNext: true); + first.Should().HaveCount(51); + var second = await _service.HistoryAsync(_actor, definition, 1, includeNext: true); + second.Should().ContainSingle(); + second[0].Completion.Id.Should().Be(first[50].Completion.Id); + } + + [Test] + public async Task Member_definition_view_reveals_the_published_version_once() + { + var setup = await Start(); + _authorization.Setup(a => a.CanManageAsync(It.IsAny())).ReturnsAsync(false); + _read.Invocations.Clear(); + var view = await _service.GetDefinitionAsync(_actor, setup.Definition); + view.Form.Name.Should().Be(view.PublishedForm.Name); + _read.Invocations.Count(i => i.Method.Name == "ResolveRecordsEntitiesForReadAsync").Should().Be(1); + } + + [TestCase("\"Signed independently\"", "Signed independently")] + [TestCase("{}", null)] + [TestCase("null", null)] + public async Task Witness_attestation_is_projected_as_text_before_rendering(string value, string expected) + { + var setup = await Start(); + var row = await _store.GetAsync(77, setup.Run); + row.Content = "{\"WitnessAttestation\":" + value + "}"; + await _store.WriteAsync(row, false); + (await _service.GetRunAsync(_actor, setup.Run)).WitnessAttestation.Should().Be(expected); + } + } + + [TestFixture, NonParallelizable] + public class ChecklistPr503ControllerTests + { + [TestCase("null"), TestCase("/* empty */"), TestCase("{")] + public async Task Invalid_json_is_rejected_before_either_save_service_is_called(string json) + { + var previous = ClaimsAuthorizationHelper._httpContextAccessor; + try + { + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.PrimarySid, "author"), new Claim(ClaimTypes.PrimaryGroupSid, "77") }, "test")) + } }; + var service = new Mock(MockBehavior.Strict); + var controller = new ChecklistsController(Mock.Of(), service.Object, Mock.Of(), Mock.Of(), Mock.Of(), null); + Func definition = () => controller.SaveDefinition(null, 0, json); + Func run = () => controller.SaveRun(Guid.NewGuid().ToString(), json, false); + (await definition.Should().ThrowAsync()).Which.StatusCode.Should().Be(400); + (await run.Should().ThrowAsync()).Which.StatusCode.Should().Be(400); + service.VerifyNoOtherCalls(); + } + finally { ClaimsAuthorizationHelper._httpContextAccessor = previous; } + } + } + + public partial class ChecklistDatabaseTests + { + [Test] + public async Task Batched_answers_and_child_reads_preserve_null_outcomes_ownership_and_metadata_only_files() + { + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + var definition = Row(); await store.WriteAsync(definition, true); + var version = Row(definition.Id); version.Version = 1; await store.WriteAsync(version, true); + var parents = new List(); + for (var i = 0; i < 2; i++) + { + var completion = Row(definition.Id); completion.VersionId = version.Id; completion.TargetId = "77"; + var occurrence = Row(definition.Id); occurrence.VersionId = version.Id; occurrence.TargetId = "77"; occurrence.CompletionId = completion.Id; + await store.WriteAsync(occurrence, true); completion.OccurrenceId = occurrence.Id; await store.WriteAsync(completion, true); parents.Add(completion.Id); + var answers = Enumerable.Range(0, 205).Select(n => { var answer = Row(completion.Id); answer.ItemId = Guid.NewGuid().ToString(); answer.IsFailure = n % 2 == 0 ? null : false; answer.Content = "quoted ' content"; return answer; }).ToList(); + await store.ReplaceAnswersAsync(77, completion.Id, answers); + var file = Row(completion.Id); file.ItemId = answers[0].ItemId; file.ContentType = "image/png"; file.Size = 3; file.Data = new byte[] { 1, 2, 3 }; file.Sha256 = "hash"; + await store.WriteAsync(file, true); + } + uow.CommitChanges(); + var rows = await store.ListChildrenAsync(77, parents, take: 500); + rows.Should().HaveCount(410); rows.Count(r => r.IsFailure == null).Should().Be(206); rows.Should().OnlyContain(r => r.Content == "quoted ' content"); + (await store.ListChildrenAsync(88, parents)).Should().BeEmpty(); + (await store.ListChildrenAsync(77, new[] { Guid.NewGuid().ToString() })).Should().BeEmpty(); + (await store.ListChildrenAsync(77, parents)).Should().HaveCount(2).And.OnlyContain(f => f.Data == null && f.Size == 3); + (await store.ListChildrenAsync(77, parents, 400)).Should().HaveCount(10); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + var invalid = Row(parents[1]); invalid.ItemId = Guid.NewGuid().ToString(); + Func replace = () => store.ReplaceAnswersAsync(77, parents[0], new[] { invalid }); + await replace.Should().ThrowAsync(); + (await store.ListChildrenAsync(77, new[] { parents[0] }, take: 500)).Should().HaveCount(205); + uow.DiscardChanges(); + } + + [Test] + public async Task Negative_list_offset_reports_skip() + { + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + Func list = () => store.ListAsync(77, skip: -1); + (await list.Should().ThrowAsync()).Which.ParamName.Should().Be("skip"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistReminderDatabaseTests.cs b/Tests/Resgrid.Tests/Services/ChecklistReminderDatabaseTests.cs new file mode 100644 index 000000000..cfdceeba2 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistReminderDatabaseTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Checklists; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Transactions; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistDatabaseTests + { + [Test, Order(110)] + public async Task Reminder_queue_has_tenant_integrity_deduplication_leases_retry_and_guarded_rollback() + { + _runner.GetRequiredService().LoadVersionInfo(); var runner = _runner.GetRequiredService(); runner.MigrateUp(); + var connections = Connections(); using var first = new UnitOfWork(connections); using var second = new UnitOfWork(connections); + var store = Repository(connections, first); + ChecklistReminderRepository Queue(UnitOfWork uow) => new ChecklistReminderRepository(connections, Configuration(), uow, new Mock().Object); + var queue = Queue(first); var now = new DateTime(2026, 9, 8, 8, 0, 0, DateTimeKind.Utc); + await first.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + var definition = Row(); await store.WriteAsync(definition, true); + var version = Row(definition.Id); version.Version = 1; await store.WriteAsync(version, true); + var occurrence = Row(definition.Id); occurrence.VersionId = version.Id; occurrence.TargetId = "77"; occurrence.CompletionId = Guid.NewGuid().ToString(); await store.WriteAsync(occurrence, true); + var settings = Row(); settings.RemindersEnabled = true; settings.RemindersActiveFromUtc = now; settings.EscalateAfterMinutes = 30; await store.WriteAsync(settings, true); + ChecklistReminder Notice(string user = "author", int kind = 0) => new ChecklistReminder { DepartmentId = 77, OccurrenceId = occurrence.Id, RecipientUserId = user, Kind = kind, CreatedOnUtc = now, NextAttemptUtc = now }; + var reminder = Notice(); await queue.EnqueueAsync(reminder); await queue.EnqueueAsync(Notice()); await queue.EnqueueAsync(Notice(kind: 1)); await queue.EnqueueAsync(Notice("other")); first.CommitChanges(); + (await queue.DepartmentsAsync(0)).Should().Contain(77); (await queue.DepartmentsAsync(77)).Should().NotContain(77); + (await store.ListAsync(77)).Single(s => s.Id == settings.Id).EscalateAfterMinutes.Should().Be(30); + (await queue.ForRecipientAsync(77, "author", 0)).Should().HaveCount(2); (await queue.ForRecipientAsync(88, "author", 0)).Should().BeEmpty(); + await first.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(88); + (await queue.ClaimAsync(88, now, true)).Should().BeEmpty(); + var crossTenant = Notice(); crossTenant.DepartmentId = 88; + Func invalid = () => queue.EnqueueAsync(crossTenant); await invalid.Should().ThrowAsync(); first.DiscardChanges(); + await first.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + var claimed = await queue.ClaimAsync(77, now, true); first.CommitChanges(); + claimed.Select(r => r.RecipientUserId).Distinct().Should().ContainSingle(); claimed.Should().OnlyContain(r => r.Attempts == 1 && r.ClaimToken != null); + await second.CreateOrGetConnectionAsync(); await Repository(connections, second).LockDepartmentAsync(77); + var other = await Queue(second).ClaimAsync(77, now, true); second.CommitChanges(); + other.Select(r => r.Id).Should().NotIntersectWith(claimed.Select(r => r.Id)); + await second.CreateOrGetConnectionAsync(); await Repository(connections, second).LockDepartmentAsync(77); + (await Queue(second).ClaimAsync(77, now.AddMinutes(29), true)).Should().BeEmpty(); + var recovered = await Queue(second).ClaimAsync(77, now.AddMinutes(31), true); second.CommitChanges(); + recovered.Should().NotBeEmpty().And.OnlyContain(r => r.Attempts == 2); + var stale = claimed.Concat(other).Single(r => r.Id == recovered[0].Id); + await first.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + Func staleFinish = () => queue.FinishAsync(stale, ChecklistReminderStatus.HandedOff, now.AddMinutes(31)); await staleFinish.Should().ThrowAsync(); first.DiscardChanges(); + await second.CreateOrGetConnectionAsync(); await Repository(connections, second).LockDepartmentAsync(77); + await Queue(second).FinishAsync(recovered[0], ChecklistReminderStatus.Pending, now.AddMinutes(31)); second.CommitChanges(); + var history = (await queue.ForRecipientAsync(77, recovered[0].RecipientUserId, 0)).Single(r => r.Id == recovered[0].Id); + history.NextAttemptUtc.Should().Be(now.AddMinutes(36)); history.ClaimToken.Should().BeNull(); history.CompletedOnUtc.Should().BeNull(); + await first.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + var retry = await queue.ClaimAsync(77, now.AddMinutes(36), false); first.CommitChanges(); + retry.Should().ContainSingle(); + await first.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + await queue.FinishAsync(retry[0], ChecklistReminderStatus.HandedOff, now.AddMinutes(36)); first.CommitChanges(); + (await queue.ForRecipientAsync(77, retry[0].RecipientUserId, 0)).Single(r => r.Id == retry[0].Id).Status.Should().Be(1); + Action rollback = () => runner.MigrateDown(194); rollback.Should().Throw().WithMessage("*preserved*"); + await using var db = Connect(_connection); (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("ChecklistReminders")} WHERE {Q("DepartmentId")}=77")).Should().Be(3); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistReminderTests.cs b/Tests/Resgrid.Tests/Services/ChecklistReminderTests.cs new file mode 100644 index 000000000..45d074fff --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistReminderTests.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + private sealed class ReminderHarness + { + public ChecklistReminderService Service; + public List Notices = new List(); + public List Members = new List { new DepartmentMember { DepartmentId = 77, UserId = "author", IsAdmin = true }, new DepartmentMember { DepartmentId = 77, UserId = "crew" } }; + public Mock Communication = new Mock(); + public Mock Units = new Mock(); + public Mock Groups = new Mock(); + public Mock Authorization = new Mock(); + public Mock Departments = new Mock(); + public Mock Profiles = new Mock(); + public Action BeforeClaim; + } + private ReminderHarness Reminders(string language = "en", IChecklistAssetSource assets = null, IChecklistAssignmentService assignments = null) + { + var harness = new ReminderHarness(); var queue = new Mock(); + harness.Departments.Setup(s => s.GetDepartmentByIdAsync(77, true)).ReturnsAsync(new Department { DepartmentId = 77, ManagingUserId = "author" }); + harness.Departments.Setup(s => s.GetAllMembersForDepartmentUnlimitedAsync(77, true)).ReturnsAsync(() => harness.Members); + harness.Profiles.Setup(s => s.GetProfileByUserIdAsync(It.IsAny(), false)).ReturnsAsync(new UserProfile { Language = language }); + harness.Communication.Setup(s => s.SendNotificationAsync(It.IsAny(), 77, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), false)).ReturnsAsync(true); + harness.Authorization.Setup(s => s.CanUserViewUnitAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + harness.Authorization.Setup(s => s.CanUserViewPersonAsync(It.IsAny(), It.IsAny(), 77)).ReturnsAsync(true); + queue.Setup(s => s.DepartmentsAsync(It.IsAny(), It.IsAny())).ReturnsAsync((int after, CancellationToken ct) => after < 77 ? new List { 77 } : new List()); + queue.Setup(s => s.EnqueueAsync(It.IsAny(), It.IsAny())).Returns((ChecklistReminder row, CancellationToken ct) => + { + if (!harness.Notices.Any(r => r.DepartmentId == row.DepartmentId && r.OccurrenceId == row.OccurrenceId && r.RecipientUserId == row.RecipientUserId && r.Kind == row.Kind && r.PeriodKey == row.PeriodKey)) harness.Notices.Add(row); + return Task.CompletedTask; + }); + queue.Setup(s => s.ClaimAsync(77, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int department, DateTime now, bool digest, CancellationToken ct) => + { + harness.BeforeClaim?.Invoke(); + var available = harness.Notices.Where(r => r.Status == 0 && r.NextAttemptUtc <= now && (!r.ClaimUntilUtc.HasValue || r.ClaimUntilUtc <= now)).ToList(); + var rows = available.Where(r => r.RecipientUserId == available.FirstOrDefault()?.RecipientUserId).Take(digest ? 500 : 1).ToList(); + foreach (var row in rows) { row.ClaimToken = Guid.NewGuid().ToString(); row.ClaimUntilUtc = now.AddMinutes(30); row.Attempts++; } + return JsonConvert.DeserializeObject>(JsonConvert.SerializeObject(rows)); + }); + queue.Setup(s => s.FinishAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns((ChecklistReminder notice, ChecklistReminderStatus status, DateTime now, CancellationToken ct) => + { + var row = harness.Notices.Single(r => r.Id == notice.Id); + if (row.Status != 0 || row.ClaimToken != notice.ClaimToken) throw new InvalidOperationException("Lost lease"); + row.Status = (int)status; row.ClaimToken = null; row.ClaimUntilUtc = null; row.NextAttemptUtc = now.AddMinutes(5); return Task.CompletedTask; + }); + harness.Service = new ChecklistReminderService(_store, queue.Object, _uow.Object, _access.Object, harness.Departments.Object, harness.Units.Object, harness.Groups.Object, + harness.Authorization.Object, _authorization.Object, harness.Communication.Object, harness.Profiles.Object, Mock.Of(), assignments, assets); + return harness; + } + private async Task ReminderOccurrence() + { + var setup = await Scheduled(); + await _service.SaveReminderSettingsAsync(_actor, new ChecklistReminderSettingsInput { Enabled = true, EscalateAfterMinutes = 30 }); + await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + return (await _store.ListAsync(77)).OrderBy(r => r.PeriodStartUtc).First(); + } + [Test] + public async Task Reminder_settings_require_management_revision_and_valid_ranges_and_preserve_ADP_audits() + { + var input = new ChecklistReminderSettingsInput { Enabled = true }; + (await _service.ReminderSettingsAsync(_actor)).Enabled.Should().BeFalse(); + await _service.SaveReminderSettingsAsync(_actor, input); + (await _service.ReminderSettingsAsync(_actor)).Revision.Should().Be(1); + Func stale = () => _service.SaveReminderSettingsAsync(_actor, input); await stale.Should().ThrowAsync(); + input.Revision = 1; input.NotifyBeforeMinutes = -1; + Func invalid = () => _service.SaveReminderSettingsAsync(_actor, input); await invalid.Should().ThrowAsync(); + input.NotifyBeforeMinutes = 60; input.EscalateAfterMinutes = 0; await invalid.Should().ThrowAsync(); + input.EscalateAfterMinutes = null; + _authorization.Setup(a => a.CanManageAsync(_actor)).ReturnsAsync(false); + Func denied = () => _service.SaveReminderSettingsAsync(_actor, input); (await denied.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + _authorization.Setup(a => a.CanManageAsync(_actor)).ReturnsAsync(true); + _write.SetReturnsDefault(Task.FromResult(ProtectedWriteResult.Allowed(isProtected: true))); + Func unsealedAudit = () => _service.SaveReminderSettingsAsync(_actor, input); await unsealedAudit.Should().ThrowAsync(); + (await _service.ReminderSettingsAsync(_actor)).Revision.Should().Be(1, "audit protection failure rolls back the settings change"); + ReadinessHistoryFields.AuditTypes.Should().Contain((int)AuditLogTypes.ChecklistReminderSettingsUpdated); + } + [TestCaseSource(typeof(ChecklistLocalizationTests), nameof(ChecklistLocalizationTests.Cultures))] + public async Task Reminder_is_localized_generic_and_deduplicated_without_protected_reads(string language) + { + var occurrence = await ReminderOccurrence(); var harness = Reminders(language); _read.Invocations.Clear(); + var now = occurrence.PeriodStartUtc.Value; + (await harness.Service.SweepAsync(now)).HandedOff.Should().Be(1); + (await harness.Service.SweepAsync(now)).HandedOff.Should().Be(0); + var invocation = harness.Communication.Invocations.Single(); + var message = (string)invocation.Arguments[2]; message.Should().Contain("/User/Checklists/Due").And.NotContain("SYNTHETIC"); + if (language != "en") { message.Should().NotContain("Sign in"); ((string)invocation.Arguments[5]).Should().NotBe("Checklist reminders"); } + _read.Invocations.Should().BeEmpty(); + JsonConvert.SerializeObject(harness.Notices).Should().NotContain("SYNTHETIC").And.NotContain("Sign in"); + } + [TestCase(true, 1), TestCase(false, 2)] + public async Task Reminders_digest_combines_distinct_checks_for_one_recipient(bool digest, int sends) + { + var occurrence = await ReminderOccurrence(); var harness = Reminders(); + var copy = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(occurrence)); copy.Id = Guid.NewGuid().ToString(); await _store.WriteAsync(copy, true); + var settings = await _service.ReminderSettingsAsync(_actor); settings.DigestMode = digest; await _service.SaveReminderSettingsAsync(_actor, settings); + (await harness.Service.SweepAsync(occurrence.PeriodStartUtc.Value)).HandedOff.Should().Be(2); + harness.Communication.Invocations.Should().HaveCount(sends); + } + [TestCase(2), TestCase(5), TestCase(6)] + public async Task Reminder_pending_delivery_is_suppressed_after_completion_skip_or_cancellation(int state) + { + var row = await ReminderOccurrence(); var harness = Reminders(); + harness.BeforeClaim = () => { row.State = state; _store.WriteAsync(row, false).GetAwaiter().GetResult(); }; + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value)).Suppressed.Should().Be(1); + harness.Communication.Invocations.Should().BeEmpty(); + } + [Test] + public async Task Reminder_pending_delivery_rechecks_flag_and_membership() + { + var row = await ReminderOccurrence(); var harness = Reminders(); + harness.BeforeClaim = () => _access.Setup(a => a.CanUseChecklistsAsync(77)).ReturnsAsync(false); + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value)).Suppressed.Should().Be(1); + harness.Communication.Invocations.Should().BeEmpty(); + _access.Setup(a => a.CanUseChecklistsAsync(77)).ReturnsAsync(true); harness = Reminders(); + harness.BeforeClaim = () => harness.Members[0].IsDisabled = true; + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value)).Suppressed.Should().Be(1); + harness.Communication.Invocations.Should().BeEmpty(); + } + [Test] + public async Task Reminder_provider_exception_retries_after_backoff_but_policy_suppression_does_not() + { + var row = await ReminderOccurrence(); var harness = Reminders(); var now = row.PeriodStartUtc.Value; + harness.Communication.SetReturnsDefault(Task.FromException(new Exception("SYNTHETIC-PHI"))); + harness.Communication.Setup(s => s.SendNotificationAsync(It.IsAny(), 77, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), false)).ThrowsAsync(new Exception("SYNTHETIC-PHI")); + (await harness.Service.SweepAsync(now)).Errors.Should().Be(1); harness.Notices.Single().Status.Should().Be(0); + await harness.Service.SweepAsync(now.AddMinutes(1)); harness.Communication.Invocations.Should().ContainSingle(); + harness.Communication.Setup(s => s.SendNotificationAsync(It.IsAny(), 77, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), false)).ReturnsAsync(false); + (await harness.Service.SweepAsync(now.AddMinutes(6))).Suppressed.Should().Be(1); + await harness.Service.SweepAsync(now.AddMinutes(10)); harness.Communication.Invocations.Should().HaveCount(2); + JsonConvert.SerializeObject(harness.Notices).Should().NotContain("SYNTHETIC"); + } + [Test] + public async Task Reminder_routes_unit_checks_to_current_staff_and_missed_escalations_to_admins() + { + var row = await ReminderOccurrence(); var harness = Reminders(); row.TargetType = (int)ChecklistTargetType.Unit; row.TargetId = "12"; await _store.WriteAsync(row, false); + harness.Units.Setup(s => s.GetUnitByIdAsync(12)).ReturnsAsync(new Unit { UnitId = 12, DepartmentId = 77 }); + harness.Units.Setup(s => s.GetActiveRolesForUnitAsync(12)).ReturnsAsync(new List { new UnitActiveRole { DepartmentId = 77, UnitId = 12, UserId = "crew" }, new UnitActiveRole { DepartmentId = 88, UnitId = 12, UserId = "foreign" } }); + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value)).HandedOff.Should().Be(1); + ((string)harness.Communication.Invocations.Single().Arguments[0]).Should().Be("crew"); + row.State = 4; row.MissedOn = row.WindowEndUtc; await _store.WriteAsync(row, false); + (await harness.Service.SweepAsync(row.WindowEndUtc.Value)).HandedOff.Should().Be(1); + (await harness.Service.SweepAsync(row.WindowEndUtc.Value.AddMinutes(30))).HandedOff.Should().Be(1); + harness.Notices.Should().ContainSingle(n => n.Kind == 2 && n.RecipientUserId == "author").And.NotContain(n => n.RecipientUserId == "foreign"); + } + [Test] + public async Task Reminder_rechecks_completion_after_profile_lookup() + { + var row = await ReminderOccurrence(); var harness = Reminders(); + harness.Profiles.Setup(s => s.GetProfileByUserIdAsync("author", false)).ReturnsAsync(() => + { row.State = 2; _store.WriteAsync(row, false).GetAwaiter().GetResult(); return new UserProfile { Language = "fr" }; }); + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value)).Suppressed.Should().Be(1); + harness.Communication.Invocations.Should().BeEmpty(); + } + [Test] + public async Task Reminder_unit_assignment_changes_suppress_old_recipient_and_reach_current_staff() + { + var row = await ReminderOccurrence(); var harness = Reminders(); row.TargetType = (int)ChecklistTargetType.Unit; row.TargetId = "12"; await _store.WriteAsync(row, false); + harness.Units.Setup(s => s.GetUnitByIdAsync(12)).ReturnsAsync(new Unit { UnitId = 12, DepartmentId = 77 }); + var currentUser = "crew"; + harness.Units.Setup(s => s.GetActiveRolesForUnitAsync(12)).ReturnsAsync(() => new List { new UnitActiveRole { DepartmentId = 77, UnitId = 12, UserId = currentUser } }); + harness.BeforeClaim = () => currentUser = "author"; + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value)).Suppressed.Should().Be(1); + harness.Communication.Invocations.Should().BeEmpty(); + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value.AddMinutes(5))).HandedOff.Should().Be(1); + ((string)harness.Communication.Invocations.Single().Arguments[0]).Should().Be("author"); + } + [Test] + public async Task Reminder_unstaffed_unit_uses_its_current_station_group_and_rejects_cross_tenant_members() + { + var row = await ReminderOccurrence(); var harness = Reminders(); row.TargetType = (int)ChecklistTargetType.Unit; row.TargetId = "12"; await _store.WriteAsync(row, false); + harness.Units.Setup(s => s.GetUnitByIdAsync(12)).ReturnsAsync(new Unit { UnitId = 12, DepartmentId = 77, StationGroupId = 8 }); + harness.Units.Setup(s => s.GetActiveRolesForUnitAsync(12)).ReturnsAsync(new List()); + harness.Groups.Setup(s => s.GetGroupByIdAsync(8, true)).ReturnsAsync(new DepartmentGroup { DepartmentId = 77, DepartmentGroupId = 8 }); + harness.Groups.Setup(s => s.GetAllMembersForGroupAsync(8)).ReturnsAsync(new List { new DepartmentGroupMember { DepartmentId = 77, UserId = "crew" }, new DepartmentGroupMember { DepartmentId = 88, UserId = "foreign" } }); + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value)).HandedOff.Should().Be(1); + ((string)harness.Communication.Invocations.Single().Arguments[0]).Should().Be("crew"); + } + [Test] + public async Task Reminder_reenable_does_not_send_checks_from_the_disabled_interval() + { + var row = await ReminderOccurrence(); var harness = Reminders(); + var settings = (await _store.ListAsync(77)).Single(); settings.RemindersActiveFromUtc = row.PeriodStartUtc.Value.AddMinutes(1); await _store.WriteAsync(settings, false); + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value.AddMinutes(5))).HandedOff.Should().Be(0); + harness.Notices.Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistSchedulingDatabaseTests.cs b/Tests/Resgrid.Tests/Services/ChecklistSchedulingDatabaseTests.cs new file mode 100644 index 000000000..0272985e2 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistSchedulingDatabaseTests.cs @@ -0,0 +1,75 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Resgrid.Model.Checklists; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Transactions; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistDatabaseTests + { + [Test, Order(100)] + public async Task Scheduling_migration_enforces_unique_periods_and_tenant_references_and_preserves_history_on_rollback() + { + var runner = _runner.GetRequiredService(); runner.MigrateUp(); + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + var definition = Row(); await store.WriteAsync(definition, true); + var version = Row(definition.Id); version.Version = 1; await store.WriteAsync(version, true); + var now = new DateTime(2026, 9, 8, 8, 0, 0, DateTimeKind.Utc); + var schedule = Row(definition.Id); schedule.VersionId = version.Id; schedule.TargetId = "77"; schedule.Frequency = 2; + schedule.TimeZoneId = "UTC"; schedule.ClockMinutes = "480"; schedule.StartDate = now.Date; schedule.DayOfMonth = 1; schedule.MonthOfYear = 1; + schedule.Weekdays = 127; schedule.WindowMinutes = 60; schedule.IsActive = true; schedule.ActiveFromUtc = now; schedule.GeneratedThroughUtc = now.AddDays(7); schedule.LastSweepUtc = now; + await store.WriteAsync(schedule, true); + ChecklistOccurrence Occurrence(string scheduleId = null) { var row = Row(definition.Id); row.VersionId = version.Id; row.CompletionId = Guid.NewGuid().ToString(); row.TargetId = "77"; row.ScheduleId = scheduleId; row.ScheduleRevision = scheduleId == null ? null : 1; row.PeriodStartUtc = scheduleId == null ? null : now; row.WindowEndUtc = scheduleId == null ? null : now.AddHours(1); row.State = 3; return row; } + var occurrence = Occurrence(schedule.Id); await store.WriteAsync(occurrence, true); + await store.WriteAsync(Occurrence(), true); await store.WriteAsync(Occurrence(), true); // Multiple on-demand NULL periods are valid on both engines. + uow.CommitChanges(); + (await store.SchedulingDepartmentsAsync(0)).Should().Contain(77); + (await store.ActiveSchedulesAsync(77, "")).Should().ContainSingle(s => s.Id == schedule.Id); + (await store.ActiveSchedulesAsync(88, "")).Should().BeEmpty(); + (await store.GetAsync(88, schedule.Id)).Should().BeNull(); + (await store.ScheduledOccurrencesAsync(77, schedule.Id, now, now.AddHours(2))).Should().ContainSingle(); + (await store.ScheduledOccurrencesAsync(77, schedule.Id, now.AddHours(2), now.AddHours(3))).Should().BeEmpty(); + (await store.DueOccurrencesAsync(77, now, 0)).Should().ContainSingle(o => o.Id == occurrence.Id); + (await store.OccurrencesInWindowAsync(77, schedule.Id, now, now.AddDays(1))).Should().ContainSingle(); + (await store.OccurrencesInWindowAsync(88, schedule.Id, now, now.AddDays(1))).Should().BeEmpty(); + await uow.CreateOrGetConnectionAsync(); + Func duplicate = () => store.WriteAsync(Occurrence(schedule.Id), true); + await duplicate.Should().ThrowAsync(); uow.DiscardChanges(); + await uow.CreateOrGetConnectionAsync(); + var crossTenant = Occurrence(schedule.Id); crossTenant.DepartmentId = 88; + Func foreign = () => store.WriteAsync(crossTenant, true); await foreign.Should().ThrowAsync(); uow.DiscardChanges(); + await uow.CreateOrGetConnectionAsync(); + await store.CancelUnstartedOccurrencesAsync(88, schedule.Id, null, now); // Cannot cancel another department's work. + uow.CommitChanges(); (await store.GetAsync(77, occurrence.Id)).State.Should().Be(3); + await uow.CreateOrGetConnectionAsync(); await store.CancelUnstartedOccurrencesAsync(77, schedule.Id, null, now); uow.CommitChanges(); + (await store.GetAsync(77, occurrence.Id)).State.Should().Be(6); + Action down = () => runner.MigrateDown(193); down.Should().Throw(); + _runner.GetRequiredService().LoadVersionInfo(); runner.MigrateUp(); + (await store.GetAsync(77, schedule.Id)).Content.Should().Be("{}"); + (await store.GetAsync(77, occurrence.Id)).ScheduleRevision.Should().Be(1); + } + [Test, Order(101)] + public async Task Scheduling_reads_only_selected_department_workshift_starts_with_half_open_bounds() + { + await using var db = Connect(_connection); + await db.ExecuteAsync($"CREATE TABLE {Q("Workshifts")} ({Q("WorkshiftId")} varchar(36) PRIMARY KEY,{Q("DepartmentId")} int,{Q("DeletedOn")} timestamp NULL); CREATE TABLE {Q("WorkshiftDays")} ({Q("WorkshiftId")} varchar(36),{Q("Day")} timestamp);".Replace("timestamp", _type == Resgrid.Config.DatabaseTypes.Postgres ? "timestamp" : "datetime2")); + var id = Guid.NewGuid().ToString(); var from = new DateTime(2026, 9, 8, 8, 0, 0, DateTimeKind.Utc); + await db.ExecuteAsync($"INSERT INTO {Q("Workshifts")} VALUES(@id,77,NULL); INSERT INTO {Q("WorkshiftDays")} VALUES(@id,@from),(@id,@until)", new { id, from, until = from.AddDays(1) }); + var connections = Connections(); using var uow = new UnitOfWork(connections); var store = Repository(connections, uow); + (await store.WorkshiftExistsAsync(77, id)).Should().BeTrue(); (await store.WorkshiftExistsAsync(88, id)).Should().BeFalse(); + (await store.WorkshiftStartsAsync(77, id, from, from.AddDays(1))).Should().Equal(from); + (await store.WorkshiftStartsAsync(88, id, from, from.AddDays(1))).Should().BeEmpty(); + await db.ExecuteAsync($"UPDATE {Q("Workshifts")} SET {Q("DeletedOn")}=@from WHERE {Q("WorkshiftId")}=@id", new { from, id }); + (await store.WorkshiftStartsAsync(77, id, from, from.AddDays(1))).Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistSchedulingMemoryStore.cs b/Tests/Resgrid.Tests/Services/ChecklistSchedulingMemoryStore.cs new file mode 100644 index 000000000..36ec14b3b --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistSchedulingMemoryStore.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + internal sealed partial class MemoryStore + { + public List ShiftStarts = new(); + public Task LockAccessFenceAsync(CancellationToken ct = default) => Task.CompletedTask; + public async Task ApplyAccessStateAsync(int departmentId, bool enabled, DateTime nowUtc, CancellationToken ct = default, bool inventoryEnabled = true) + { + foreach (var row in (await ListAsync(departmentId)).Where(s => s.IsActive && s.IsSuspended != !(enabled && (s.TargetType != 5 || inventoryEnabled)))) + { + await CancelUnstartedOccurrencesAsync(departmentId, row.Id, null, nowUtc, ct); + row.IsSuspended = !(enabled && (row.TargetType != 5 || inventoryEnabled)); if (!row.IsSuspended) { row.Revision++; row.ActiveFromUtc = row.GeneratedThroughUtc = row.LastSweepUtc = nowUtc; } + await WriteAsync(row, false, ct); + } + } + public async Task> CalendarOccurrencesAsync(int departmentId, DateTime fromUtc, DateTime untilUtc, int skip, CancellationToken ct = default) => (await ListAsync(departmentId, take: int.MaxValue)).Where(o => o.ScheduleId != null && o.PeriodStartUtc < untilUtc && o.WindowEndUtc > fromUtc && o.State != 6).OrderBy(o => o.PeriodStartUtc).Skip(skip).Take(500).ToList(); + public Task> ShiftStartsAsync(int departmentId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default) => Task.FromResult(ShiftStarts.Where(s => s.StartUtc > fromUtc && s.StartUtc <= untilUtc).ToList()); + public async Task AdvanceDigestSweepAsync(int departmentId, DateTime nowUtc, CancellationToken ct = default) + { foreach (var row in await ListAsync(departmentId)) { row.LastDigestSweepUtc = nowUtc; await WriteAsync(row, false, ct); } } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistSchedulingTests.cs b/Tests/Resgrid.Tests/Services/ChecklistSchedulingTests.cs new file mode 100644 index 000000000..8f23fd20f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistSchedulingTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + private sealed class ScheduleClock : TimeProvider + { + public DateTimeOffset Now = new DateTimeOffset(2026, 9, 8, 7, 0, 0, TimeSpan.Zero); + public override DateTimeOffset GetUtcNow() => Now; + } + private async Task<(ChecklistScheduleInput Input, ScheduleClock Clock)> Scheduled() + { + var clock = new ScheduleClock(); + _service = new ChecklistsService(_store, _authorization.Object, _access.Object, _uow.Object, _audits.Object, _outbox.Object, + new Lazy(() => _read.Object), new Lazy(() => _write.Object), _scanner.Object, clock); + var definition = await _service.SaveDefinitionAsync(_actor, null, 0, Form()); + await _service.PublishAsync(_actor, definition, 1); + var input = new ChecklistScheduleInput { DefinitionId = definition, Name = "SYNTHETIC-SENSITIVE-SCHEDULE", Notes = "SYNTHETIC-SENSITIVE-NOTES", TargetId = "77", StartDate = clock.Now.Date }; + input.Id = await _service.SaveScheduleAsync(_actor, input); input.Revision = 1; + return (input, clock); + } + [Test] + public async Task Scheduling_pins_versions_and_generates_once_without_decrypting_or_copying_content() + { + var setup = await Scheduled(); + var schedule = (await _service.GetScheduleAsync(_actor, setup.Input.Id)).Schedule; + var changed = Form(); changed.Name = "New published version"; + await _service.SaveDefinitionAsync(_actor, setup.Input.DefinitionId, 2, changed); + await _service.PublishAsync(_actor, setup.Input.DefinitionId, 3); + _read.Invocations.Clear(); + var first = await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + first.Errors.Should().Be(0); first.Generated.Should().Be(7); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Generated.Should().Be(0); + _read.Invocations.Should().BeEmpty("the unattended worker must never request decryption"); + var occurrences = await _store.ListAsync(77); + occurrences.Should().HaveCount(7); occurrences.Select(o => o.PeriodStartUtc).Should().OnlyHaveUniqueItems(); + occurrences.Should().OnlyContain(o => o.Content == null && o.VersionId == schedule.VersionId); + setup.Clock.Now = setup.Clock.Now.AddHours(1); + var due = occurrences.OrderBy(o => o.PeriodStartUtc).First(); + var run = await _service.StartOccurrenceAsync(_actor, due.Id); + (await _service.StartOccurrenceAsync(_actor, due.Id)).Should().Be(run); + (await _service.GetRunAsync(_actor, run)).Form.Name.Should().Be("Shift readiness"); + (await _store.ListAsync(77)).Should().ContainSingle(); + JsonConvert.SerializeObject(_events).Should().NotContain("SYNTHETIC-SENSITIVE"); + } + [Test] + public async Task Missed_check_emits_once_and_late_completion_keeps_missed_timestamp() + { + var setup = await Scheduled(); await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + setup.Clock.Now = setup.Clock.Now.AddHours(3); + var sweep = await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + sweep.Errors.Should().Be(0); sweep.Missed.Should().Be(1); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Missed.Should().Be(0); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.ChecklistMissed).Should().Be(1); + var occurrence = (await _store.ListAsync(77)).Single(o => o.State == 4); + var run = await _service.StartOccurrenceAsync(_actor, occurrence.Id); + var view = await _service.GetRunAsync(_actor, run); + await _service.SaveRunAsync(_actor, run, Answers(view.Form), true); + occurrence = await _store.GetAsync(77, occurrence.Id); + occurrence.State.Should().Be(2); occurrence.MissedOn.Should().Be(setup.Clock.Now.UtcDateTime); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime.AddDays(1))).Errors.Should().Be(0); + } + [Test] + public async Task Excused_skip_requires_reason_revision_and_permission_and_does_not_emit_the_reason() + { + var setup = await Scheduled(); await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + var row = (await _store.ListAsync(77)).First(); + Func empty = () => _service.SkipOccurrenceAsync(_actor, row.Id, 1, " "); await empty.Should().ThrowAsync(); + Func stale = () => _service.SkipOccurrenceAsync(_actor, row.Id, 0, "SYNTHETIC-PRIVATE-REASON"); await stale.Should().ThrowAsync(); + _authorization.Setup(a => a.CanManageAsync(_actor)).ReturnsAsync(false); + Func denied = () => _service.SkipOccurrenceAsync(_actor, row.Id, 1, "SYNTHETIC-PRIVATE-REASON"); await denied.Should().ThrowAsync(); + _authorization.Setup(a => a.CanManageAsync(_actor)).ReturnsAsync(true); + await _service.SkipOccurrenceAsync(_actor, row.Id, 1, "SYNTHETIC-PRIVATE-REASON"); + (await _store.GetAsync(77, row.Id)).State.Should().Be(5); + JsonConvert.SerializeObject(_events).Should().NotContain("SYNTHETIC-PRIVATE"); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.ChecklistOccurrenceSkipped).Should().Be(1); + Func reopen = () => _service.StartOccurrenceAsync(_actor, row.Id); await reopen.Should().ThrowAsync(); + } + [Test] + public async Task Edits_cancel_future_unstarted_checks_and_preserve_started_check_and_pinned_version() + { + var setup = await Scheduled(); await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + setup.Clock.Now = setup.Clock.Now.AddHours(1); + var first = (await _store.ListAsync(77)).OrderBy(o => o.PeriodStartUtc).First(); + var run = await _service.StartOccurrenceAsync(_actor, first.Id); + setup.Input.TimesOfDay = "12:00"; await _service.SaveScheduleAsync(_actor, setup.Input); + (await _store.ListAsync(77)).Count(o => o.State == 6).Should().Be(6); + (await _store.GetAsync(77, first.Id)).CompletionId.Should().Be(run); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Generated.Should().Be(7); + (await _store.ListAsync(77)).Where(o => o.State == 3).Should().OnlyContain(o => o.ScheduleRevision == 2 && o.PeriodStartUtc.Value.Hour == 12); + Func stale = () => _service.SaveScheduleAsync(_actor, setup.Input); await stale.Should().ThrowAsync(); + } + [Test] + public async Task Module_pause_suspends_generation_and_resume_does_not_report_paused_checks_as_missed() + { + var setup = await Scheduled(); await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + _access.Setup(a => a.CanUseChecklistsAsync(77)).ReturnsAsync(false); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Errors.Should().Be(0); + setup.Clock.Now = setup.Clock.Now.AddDays(10); + _access.Setup(a => a.CanUseChecklistsAsync(77)).ReturnsAsync(true); + var sweep = await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + sweep.Errors.Should().Be(0); sweep.Missed.Should().Be(0); sweep.Generated.Should().Be(7); + (await _store.ListAsync(77)).Count(o => o.State == 6).Should().Be(7); + _events.Should().NotContain(e => e.Trigger == WorkflowTriggerEventType.ChecklistMissed); + } + [Test] + public async Task Scheduling_worker_rolls_back_on_ADP_failure_and_never_advances_the_cursor() + { + var setup = await Scheduled(); + var before = (await _service.GetScheduleAsync(_actor, setup.Input.Id)).Schedule; + _write.SetReturnsDefault(Task.FromResult(ProtectedWriteResult.Blocked("broker_unavailable"))); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Errors.Should().Be(1); + (await _store.ListAsync(77)).Should().BeEmpty(); + (await _store.GetAsync(77, before.Id)).GeneratedThroughUtc.Should().Be(before.GeneratedThroughUtc); + _write.SetReturnsDefault(Task.FromResult(ProtectedWriteResult.Allowed())); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Generated.Should().Be(7); + } + [Test] + public async Task Protected_schedule_requires_catalog_17_and_denies_a_redacted_edit() + { + var setup = await Scheduled(); + new ProtectedFieldCatalog().GetAddedBetween(16, 17).Should().ContainSingle(f => f.FieldId == "checklistschedules.content"); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { RedactedFields = { "checklistschedules.content" } })); + Func save = () => _service.SaveScheduleAsync(_actor, setup.Input); + (await save.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + (await _store.GetAsync(77, setup.Input.Id)).Revision.Should().Be(1); + } + [Test] + public async Task Workshift_day_changes_reconcile_future_checks_without_duplicates_or_reviving_excused_checks() + { + var setup = await Scheduled(); setup.Input.Frequency = ChecklistScheduleFrequency.PerShift; setup.Input.WorkshiftId = Guid.NewGuid().ToString(); + await _service.SaveScheduleAsync(_actor, setup.Input); + var first = setup.Clock.Now.UtcDateTime.AddHours(1); _store.WorkshiftDays.Add(first); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Generated.Should().Be(1); + _store.WorkshiftDays[0] = first.AddHours(1); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Generated.Should().Be(1); + var rows = await _store.ListAsync(77); rows.Should().HaveCount(2); rows.Single(o => o.PeriodStartUtc == first).State.Should().Be(6); + _store.WorkshiftDays.Add(first); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Generated.Should().Be(1); + (await _store.ListAsync(77)).Should().HaveCount(2); + var restored = (await _store.ListAsync(77)).Single(o => o.PeriodStartUtc == first); + await _service.SkipOccurrenceAsync(_actor, restored.Id, restored.Revision, "Shift cancelled by supervisor"); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Generated.Should().Be(0); + (await _store.GetAsync(77, restored.Id)).State.Should().Be(5); + setup.Clock.Now = setup.Clock.Now.AddMinutes(5); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Errors.Should().Be(0); + (await _store.GetAsync(77, setup.Input.Id)).GeneratedThroughUtc.Should().Be(setup.Clock.Now.UtcDateTime.AddDays(7)); + } + [TestCase(false), TestCase(true)] + public async Task Editing_a_schedule_preserves_started_and_excused_periods_without_generating_past_checks(bool workshift) + { + var setup = await Scheduled(); + if (workshift) + { + setup.Input.Frequency = ChecklistScheduleFrequency.PerShift; setup.Input.WorkshiftId = Guid.NewGuid().ToString(); + await _service.SaveScheduleAsync(_actor, setup.Input); setup.Input.Revision++; + _store.WorkshiftDays.AddRange(new[] { setup.Clock.Now.UtcDateTime.AddHours(1), setup.Clock.Now.UtcDateTime.AddDays(1).AddHours(1) }); + } + await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime); + var first = (await _store.ListAsync(77)).OrderBy(o => o.PeriodStartUtc).First(); + var second = (await _store.ListAsync(77)).OrderBy(o => o.PeriodStartUtc).Skip(1).First(); + setup.Clock.Now = setup.Clock.Now.AddHours(1); await _service.StartOccurrenceAsync(_actor, first.Id); + await _service.SkipOccurrenceAsync(_actor, second.Id, second.Revision, "Supervisor approved"); + await _service.SaveScheduleAsync(_actor, setup.Input); setup.Input.Revision++; + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Errors.Should().Be(0); + var rows = await _store.ListAsync(77); + rows.Count(o => o.PeriodStartUtc == first.PeriodStartUtc).Should().Be(1); + rows.Count(o => o.PeriodStartUtc == second.PeriodStartUtc).Should().Be(1); + setup.Clock.Now = setup.Clock.Now.AddHours(1); + await _service.SaveScheduleAsync(_actor, setup.Input); + (await _service.SweepSchedulesAsync(setup.Clock.Now.UtcDateTime)).Errors.Should().Be(0); + (await _store.ListAsync(77)).Count(o => o.PeriodStartUtc == first.PeriodStartUtc).Should().Be(1); + } + internal sealed partial class MemoryStore + { + public List WorkshiftDays { get; } = new List(); + private IEnumerable All() where T : ChecklistRow => _rows.Where(r => r.Key.Item1 == typeof(T)).Select(r => JsonConvert.DeserializeObject(r.Value)); + public Task> SchedulingDepartmentsAsync(int afterDepartmentId, CancellationToken ct = default) => Task.FromResult(All().Where(s => s.IsActive && s.DepartmentId > afterDepartmentId).Select(s => s.DepartmentId).Distinct().OrderBy(d => d).Take(100).ToList()); + public Task> ActiveSchedulesAsync(int departmentId, string afterId, CancellationToken ct = default) => Task.FromResult(All().Where(s => s.IsActive && s.DepartmentId == departmentId && string.CompareOrdinal(s.Id, afterId) > 0).OrderBy(s => s.Id, StringComparer.Ordinal).Take(100).ToList()); + public Task> ScheduledOccurrencesAsync(int departmentId, string scheduleId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default) => Task.FromResult(All().Where(s => s.DepartmentId == departmentId && s.ScheduleId == scheduleId && new[] { 0, 1, 3 }.Contains(s.State) && s.WindowEndUtc >= fromUtc && s.WindowEndUtc < untilUtc).OrderBy(s => s.PeriodStartUtc).Take(500).ToList()); + public Task> DueOccurrencesAsync(int departmentId, DateTime untilUtc, int skip, CancellationToken ct = default) => Task.FromResult(All().Where(s => s.DepartmentId == departmentId && s.ScheduleId != null && new[] { 0, 1, 3, 4 }.Contains(s.State) && s.PeriodStartUtc <= untilUtc).OrderBy(s => s.PeriodStartUtc).Skip(skip).Take(50).ToList()); + public async Task CancelUnstartedOccurrencesAsync(int departmentId, string scheduleId, DateTime? fromUtc, DateTime nowUtc, CancellationToken ct = default) + { + foreach (var row in All().Where(s => s.DepartmentId == departmentId && s.ScheduleId == scheduleId && s.State == 3 && (!fromUtc.HasValue || s.PeriodStartUtc >= fromUtc)).ToArray()) + { row.State = 6; row.Revision++; row.UpdatedOn = nowUtc; await WriteAsync(row, false, ct); } + } + public Task> OccurrencesInWindowAsync(int departmentId, string scheduleId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default) => Task.FromResult(All().Where(o => o.DepartmentId == departmentId && o.ScheduleId == scheduleId && o.PeriodStartUtc >= fromUtc && o.PeriodStartUtc < untilUtc).ToList()); + public Task WorkshiftExistsAsync(int departmentId, string workshiftId, CancellationToken ct = default) => Task.FromResult(departmentId == 77); + public Task> WorkshiftStartsAsync(int departmentId, string workshiftId, DateTime fromUtc, DateTime untilUtc, CancellationToken ct = default) => Task.FromResult(WorkshiftDays.Where(d => departmentId == 77 && d >= fromUtc && d < untilUtc).ToList()); + } + } + [TestFixture] + public class ChecklistRecurrenceTests + { + private static ChecklistSchedule Schedule(ChecklistScheduleFrequency frequency = ChecklistScheduleFrequency.Daily) => new ChecklistSchedule { Frequency = (int)frequency, TimeZoneId = "UTC", StartDate = new DateTime(2020, 1, 1), ClockMinutes = "480", Weekdays = 127, DayOfMonth = 31, MonthOfYear = 2 }; + [TestCase(2024, 2, 29)] [TestCase(2026, 2, 28)] [TestCase(2026, 4, 30)] + public void Monthly_checks_use_the_last_day_of_short_months(int year, int month, int day) + { + var from = new DateTime(year, month, 25, 0, 0, 0, DateTimeKind.Utc); + ChecklistRecurrence.Expand(Schedule(ChecklistScheduleFrequency.Monthly), from, from.AddDays(7)).Should().Equal(new DateTime(year, month, day, 8, 0, 0, DateTimeKind.Utc)); + } + [TestCase(ChecklistScheduleFrequency.Quarterly, 5, 1)] [TestCase(ChecklistScheduleFrequency.Quarterly, 6, 0)] + [TestCase(ChecklistScheduleFrequency.SemiAnnual, 8, 1)] [TestCase(ChecklistScheduleFrequency.SemiAnnual, 5, 0)] + [TestCase(ChecklistScheduleFrequency.Annual, 2, 1)] [TestCase(ChecklistScheduleFrequency.Annual, 8, 0)] + public void Longer_recurrences_use_the_anchor_month(ChecklistScheduleFrequency frequency, int month, int count) + { + var from = new DateTime(2026, month, 25, 0, 0, 0, DateTimeKind.Utc); + ChecklistRecurrence.Expand(Schedule(frequency), from, from.AddDays(7)).Should().HaveCount(count); + } + [Test] + public void DST_gap_moves_to_first_valid_minute_and_fold_uses_one_earlier_instant() + { + var schedule = Schedule(); schedule.TimeZoneId = "America/New_York"; schedule.ClockMinutes = "150,180"; + var spring = new DateTime(2026, 3, 8, 0, 0, 0, DateTimeKind.Utc); + ChecklistRecurrence.Expand(schedule, spring, spring.AddDays(1)).Should().Equal(spring.AddHours(7)); + schedule.ClockMinutes = "90"; var fall = new DateTime(2026, 11, 1, 0, 0, 0, DateTimeKind.Utc); + ChecklistRecurrence.Expand(schedule, fall, fall.AddDays(1)).Should().Equal(fall.AddHours(5.5)); + } + [Test] + public void Weekly_dates_half_open_windows_and_shift_fallback_are_deterministic() + { + var schedule = Schedule(ChecklistScheduleFrequency.Weekly); schedule.Weekdays = 1 << (int)DayOfWeek.Tuesday; + var from = new DateTime(2026, 9, 8, 8, 0, 0, DateTimeKind.Utc); + ChecklistRecurrence.Expand(schedule, from, from.AddDays(7)).Should().Equal(from); + schedule.Frequency = (int)ChecklistScheduleFrequency.PerShift; schedule.ClockMinutes = "480,1200"; + ChecklistRecurrence.Expand(schedule, from, from.AddDays(1)).Should().Equal(from, from.AddHours(12)); + schedule.WorkshiftId = Guid.NewGuid().ToString(); + ChecklistRecurrence.Expand(schedule, from, from.AddDays(1)).Should().BeEmpty(); + ChecklistRecurrence.Expand(schedule, from, from.AddDays(1), new[] { from, from, from.AddHours(4), from.AddDays(1) }).Should().Equal(from, from.AddHours(4)); + schedule.EndDate = from.AddDays(-1); ChecklistRecurrence.Expand(schedule, from, from.AddDays(1), new[] { from }).Should().BeEmpty(); + } + [TestCase("")] [TestCase("25:00")] [TestCase("8:00")] [TestCase("08:00,")] [TestCase("08:00,09:00,10:00,11:00,12:00,13:00,14:00,15:00,16:00")] + public void Invalid_clock_lists_are_rejected(string value) => ((Action)(() => ChecklistRecurrence.ParseTimes(value))).Should().Throw(); + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistValidationTests.cs b/Tests/Resgrid.Tests/Services/ChecklistValidationTests.cs index e7f5412a3..a7619ac11 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistValidationTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistValidationTests.cs @@ -93,7 +93,7 @@ public void Applying_templates_copies_content_and_generates_department_owned_ids [Test] public void Every_persisted_content_slot_and_binary_has_a_catalog_and_upgrade_binding() { - var catalog = new ProtectedFieldCatalog(); var bindings = AdpTableBindings.ForVersionRange(catalog, 13, 14); + var catalog = new ProtectedFieldCatalog(); var bindings = AdpTableBindings.ForVersionRange(catalog, 13, catalog.Version); foreach (var table in ChecklistTables.All.Values) { bindings.Should().ContainSingle(b => b.TableName == table && b.DepartmentColumn == "DepartmentId" && b.ProtectedMarkerColumn == "IsProtected"); diff --git a/Tests/Resgrid.Tests/Services/ChecklistWorkflowTests.cs b/Tests/Resgrid.Tests/Services/ChecklistWorkflowTests.cs index a57dc9ae0..bd6b724bd 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistWorkflowTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistWorkflowTests.cs @@ -19,7 +19,7 @@ namespace Resgrid.Tests.Services { [TestFixture] - public class ChecklistWorkflowTests + public partial class ChecklistWorkflowTests { private readonly ChecklistActor _actor = new ChecklistActor { DepartmentId = 77, UserId = "author" }; private MemoryStore _store; @@ -43,7 +43,9 @@ public void Setup() _authorization.Setup(s => s.CanManageAsync(It.IsAny())).ReturnsAsync(true); _authorization.Setup(s => s.CanReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, ChecklistCompletion c) => c != null && c.DepartmentId == a.DepartmentId && (c.CreatedBy == a.UserId || c.WitnessUserId == a.UserId)); _authorization.Setup(s => s.TargetAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, ChecklistTargetType t, string id) => new ChecklistTarget { Type = t, Id = id, Name = "Test target" }); - _audits = new Mock(); _audits.SetReturnsDefault(Task.FromResult(new AuditLog())); + _audits = new Mock(); + _audits.Setup(s => s.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((AuditLog log, CancellationToken ct, bool first) => { log.AuditLogId = 1; return log; }); _outbox = new Mock(); _outbox.Setup(s => s.EnqueueAsync(77, "Checklists", It.IsAny(), It.IsAny())).ReturnsAsync((int d, string p, DomainEventEnvelope e, CancellationToken c) => { _events.Add(e); return new DomainEventOutboxEntry { DomainEventOutboxId = _events.Count }; }); _read = new Mock(); _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult())); @@ -62,6 +64,25 @@ public void Setup() } private static ChecklistRunInput Answers(ChecklistForm form, string value = "pass") => new ChecklistRunInput { Revision = 1, Answers = form.Sections.SelectMany(s => s.Items).Select(i => new ChecklistAnswer { ItemId = i.Id, Status = ChecklistAnswerStatus.Answered, Value = value, Note = value == "fail" ? "Removed from service; supervisor notified" : null }).ToList() }; + + [Test] + public async Task Protected_writes_cannot_persist_fields_skipped_by_an_older_catalog() + { + _write.SetReturnsDefault(Task.FromResult(ProtectedWriteResult.Allowed(isProtected: true))); + Func save = () => _service.SaveDefinitionAsync(_actor, null, 0, Form()); + (await save.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + (await _store.ListAsync(77)).Should().BeEmpty(); + } + + [Test] + public async Task History_cannot_read_unmigrated_protected_outcomes_and_audits_do_not_copy_them() + { + var run = await Start(); await _service.SaveRunAsync(_actor, run.Run, Answers(run.Form, "fail"), true); + _audits.Verify(s => s.InsertAsync(It.Is(l => l.Data.Contains("Score") || l.Data.Contains("Passed") || l.Data.Contains("FailedItemIds")), It.IsAny(), It.IsAny()), Times.Never); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { IsProtected = true })); + Func history = () => _service.HistoryAsync(_actor, run.Definition); + (await history.Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + } [Test] public async Task Published_version_is_pinned_and_draft_edits_do_not_change_a_started_run() { @@ -192,7 +213,7 @@ public async Task Images_need_a_clean_scan_and_scan_rejection_leaves_no_file() var view = await _service.GetRunAsync(_actor, run.Run); var input = Answers(run.Form); input.Revision = view.Completion.Revision; await _service.SaveRunAsync(_actor, run.Run, input, true); Func remove = () => _service.DeleteFileAsync(_actor, view.Files[0].Id); (await remove.Should().ThrowAsync()).Which.StatusCode.Should().Be(409); } - internal sealed class MemoryStore : IChecklistRepository + internal sealed partial class MemoryStore : IChecklistRepository { private Dictionary<(Type, string), string> _rows = new Dictionary<(Type, string), string>(); private Dictionary<(Type, string), string> _backup; @@ -205,6 +226,15 @@ public Task GetAsync(int departmentId, string id, CancellationToken ct = d return Task.FromResult(row?.DepartmentId == departmentId ? row : null); } public Task> ListAsync(int departmentId, string parentId = null, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow => Task.FromResult(_rows.Where(p => p.Key.Item1 == typeof(T)).Select(p => JsonConvert.DeserializeObject(p.Value)).Where(r => r.DepartmentId == departmentId && (parentId == null || r.ParentId == parentId)).OrderByDescending(r => r.CreatedOn).Skip(skip).Take(take).ToList()); + public int ChildQueries { get; private set; } + public Task> ListChildrenAsync(int departmentId, IReadOnlyCollection parentIds, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow + { + ChildQueries++; + var rows = _rows.Where(p => p.Key.Item1 == typeof(T)).Select(p => JsonConvert.DeserializeObject(p.Value)) + .Where(r => r.DepartmentId == departmentId && parentIds.Contains(r.ParentId)).OrderByDescending(r => r.CreatedOn).ThenBy(r => r.Id).Skip(skip).Take(take).ToList(); + foreach (var file in rows.OfType()) file.Data = null; + return Task.FromResult(rows); + } public Task WriteAsync(T row, bool insert, CancellationToken ct = default) where T : ChecklistRow { var key = (typeof(T), row.Id); if (insert && _rows.ContainsKey(key)) throw new InvalidOperationException("Duplicate ID"); _rows[key] = JsonConvert.SerializeObject(row); return Task.CompletedTask; } public async Task ReplaceAnswersAsync(int departmentId, string completionId, IEnumerable items, CancellationToken ct = default) { foreach (var old in await ListAsync(departmentId, completionId, take: 500)) _rows.Remove((typeof(ChecklistCompletionItem), old.Id)); foreach (var item in items) await WriteAsync(item, true, ct); } diff --git a/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs b/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs index 1ae382b82..e674216cc 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs @@ -109,6 +109,76 @@ public async Task Enrollment_envelopes_every_populated_text_value_and_persists_t .Should().Be("Smoke showing"); } + [Test] + public async Task Checklist_catalog_upgrade_and_offboarding_preserve_boolean_and_decimal_outcomes() + { + var id = Guid.NewGuid().ToString(); var itemId = Guid.NewGuid().ToString(); + // Snapshot from catalog 14: Content is already sealed; typed outcomes have no companions yet. + _bulk.Seed("Calls", "CallId"); + _bulk.Seed("ChecklistCompletions", "Id", Row(("Id", id), ("Score", 87.25m), ("Passed", false), ("Content", _crypto.EncryptText(_dek, 1, "SYNTHETIC-PHI-CANARY", DeptId, "checklistcompletions.content", id)))); + _bulk.Seed("ChecklistCompletionItems", "Id", Row(("Id", itemId), ("IsFailure", true), ("Content", _crypto.EncryptText(_dek, 1, "SYNTHETIC-ANSWER", DeptId, "checklistcompletionitems.content", itemId)))); + var completion = _bulk.Table("ChecklistCompletions").Single(); var item = _bulk.Table("ChecklistCompletionItems").Single(); + completion["Score"].Should().Be(87.25m); completion["Passed"].Should().Be(false); + var contentEnvelope = (string)completion["Content"]; + var upgrade = Context(DepartmentDataProtectionMigrationKind.CatalogUpgrade); upgrade.FromCatalogVersion = 14; upgrade.CatalogVersion = 15; + (await _engine.RunEncryptionNightAsync(upgrade, CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + completion["Content"].Should().Be(contentEnvelope, "an outcome-only catalog upgrade must preserve the existing content envelope"); + completion["Score"].Should().BeNull(); completion["Passed"].Should().BeNull(); item["IsFailure"].Should().BeNull(); + ((string)completion["ProtectedPassedEnvelope"]).Should().StartWith("rgdp:"); + var offboarding = Context(DepartmentDataProtectionMigrationKind.Offboarding); offboarding.CatalogVersion = 15; + (await _engine.RunDecryptionNightAsync(offboarding, CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + completion["Score"].Should().Be(87.25m); completion["Passed"].Should().Be(false); item["IsFailure"].Should().Be(true); + completion["Content"].Should().Be("SYNTHETIC-PHI-CANARY"); completion["ProtectedPassedEnvelope"].Should().BeNull(); + } + + [Test] + public async Task Checklist_history_catalog_upgrade_preserves_all_eight_fields_through_resume_and_offboarding() + { + _bulk.Seed("Calls", "CallId"); + var bindings = AdpTableBindings.ForVersionRange(new ProtectedFieldCatalog(), 15, 16); + bindings.Should().HaveCount(4); bindings.Sum(b => b.Columns.Count).Should().Be(8); + var expected = new Dictionary(); + foreach (var binding in bindings) + { + var key = binding.PkIsNumeric ? "19" : Guid.NewGuid().ToString(); + var row = Row((binding.PkColumn, key)); + foreach (var field in binding.Columns) row[field.ColumnName] = expected[field.FieldId] = "SYNTHETIC-PHI-CANARY:" + field.FieldId; + _bulk.Seed(binding.TableName, binding.PkColumn, row); + } + var upgrade = Context(DepartmentDataProtectionMigrationKind.CatalogUpgrade); upgrade.FromCatalogVersion = 15; upgrade.CatalogVersion = 16; + (await _engine.RunEncryptionNightAsync(upgrade, CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + var encrypted = new Dictionary(); + foreach (var binding in bindings) + foreach (var field in binding.Columns) + { + var row = _bulk.Table(binding.TableName).Single(); var value = (string)row[field.ColumnName]; + encrypted[field.FieldId] = value; value.Should().StartWith("rgdp:").And.NotContain("CANARY"); + _crypto.DecryptText(_dek, value, DeptId, field.FieldId, (string)row[binding.PkColumn]).Should().Be(expected[field.FieldId]); + } + (await _engine.RunEncryptionNightAsync(upgrade, CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + foreach (var binding in bindings) + foreach (var field in binding.Columns) _bulk.Table(binding.TableName).Single()[field.ColumnName].Should().Be(encrypted[field.FieldId]); + var offboarding = Context(DepartmentDataProtectionMigrationKind.Offboarding); offboarding.CatalogVersion = 16; + (await _engine.RunDecryptionNightAsync(offboarding, CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + foreach (var binding in bindings) + foreach (var field in binding.Columns) _bulk.Table(binding.TableName).Single()[field.ColumnName].Should().Be(expected[field.FieldId]); + } + + [Test] + public async Task Checklist_schedule_catalog_upgrade_preserves_routing_and_restores_content_on_offboarding() + { + _bulk.Seed("Calls", "CallId"); var id = Guid.NewGuid().ToString(); + _bulk.Seed("ChecklistSchedules", "Id", Row(("Id", id), ("Content", "SYNTHETIC-PRIVATE-SCHEDULE"), ("AssignmentId", "member-route"), ("ClockMinutes", "480"))); + var upgrade = Context(DepartmentDataProtectionMigrationKind.CatalogUpgrade); upgrade.FromCatalogVersion = 16; upgrade.CatalogVersion = 17; + (await _engine.RunEncryptionNightAsync(upgrade, CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + var row = _bulk.Table("ChecklistSchedules").Single(); var envelope = (string)row["Content"]; envelope.Should().StartWith("rgdp:"); + row["AssignmentId"].Should().Be("member-route"); row["ClockMinutes"].Should().Be("480"); + (await _engine.RunEncryptionNightAsync(upgrade, CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); row["Content"].Should().Be(envelope); + var offboarding = Context(DepartmentDataProtectionMigrationKind.Offboarding); offboarding.CatalogVersion = 17; + (await _engine.RunDecryptionNightAsync(offboarding, CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + row["Content"].Should().Be("SYNTHETIC-PRIVATE-SCHEDULE"); row["AssignmentId"].Should().Be("member-route"); + } + [Test] public async Task Double_run_proof_resuming_an_open_run_skips_the_completed_range() { @@ -410,11 +480,14 @@ public Task> GetBatchAsync(AdpTableBinding bindin if (!_tables.TryGetValue(binding.TableName, out var table)) return Task.FromResult>(Array.Empty()); - var ordered = table.Rows.OrderBy(r => Convert.ToInt64(r[table.PkColumn], CultureInfo.InvariantCulture)).ToList(); + var ordered = (binding.PkIsNumeric + ? table.Rows.OrderBy(r => Convert.ToInt64(r[table.PkColumn], CultureInfo.InvariantCulture)) + : table.Rows.OrderBy(r => Convert.ToString(r[table.PkColumn], CultureInfo.InvariantCulture), StringComparer.Ordinal)).ToList(); var filtered = string.IsNullOrEmpty(afterCursor) ? ordered - : ordered.Where(r => Convert.ToInt64(r[table.PkColumn], CultureInfo.InvariantCulture) > - long.Parse(afterCursor, CultureInfo.InvariantCulture)).ToList(); + : ordered.Where(r => binding.PkIsNumeric + ? Convert.ToInt64(r[table.PkColumn], CultureInfo.InvariantCulture) > long.Parse(afterCursor, CultureInfo.InvariantCulture) + : string.CompareOrdinal(Convert.ToString(r[table.PkColumn], CultureInfo.InvariantCulture), afterCursor) > 0).ToList(); var batch = filtered.Take(batchSize).Select(r => new AdpBulkFieldRow { diff --git a/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs b/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs index c811160ff..dccbf284a 100644 --- a/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs +++ b/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs @@ -25,7 +25,7 @@ namespace Resgrid.Tests.Services /// back has to be declared rather than silently dropped from a subject access request. /// [TestFixture] - public class GdprExportProtectedDataTests + public partial class GdprExportProtectedDataTests { private const int DeptId = 4; private const string UserId = "user-1"; @@ -107,11 +107,14 @@ public void SetUp() _emailService = new Mock(); + var checklistReminders = new Mock(); + checklistReminders.SetReturnsDefault(Task.FromResult(new List())); _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, _emergencyContactService.Object, _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, - _trainingService.Object, _shiftsService.Object, _emailService.Object); + _trainingService.Object, _shiftsService.Object, _emailService.Object, new ChecklistWorkflowTests.MemoryStore(), + new Lazy(() => new ReadinessHistoryProtectionService(Mock.Of(), Mock.Of())), checklistReminders.Object); } private async Task> RunExportAsync() diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index dd1be10d4..457146d1a 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -12,6 +12,7 @@ using Resgrid.Model.Providers; using Resgrid.Model.Services; using Resgrid.Services; +using Resgrid.Model.Checklists; namespace Resgrid.Tests.Services { @@ -72,6 +73,50 @@ private string IssueGrant(string userId = UserId, long epoch = Epoch) }).Token; } + [Test] + public async Task Checklist_outcomes_encrypt_with_content_and_reseal_after_an_authorized_read() + { + SetupWriteEnforced(); + _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())).ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, PolicyEpoch = Epoch, CatalogVersion = 15 }); + var crypto = new ProtectedFieldCryptoService(); var key = RandomNumberGenerator.GetBytes(32); + _brokerClient.Setup(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => new ProtectedDataBrokerResult + { Success = true, Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = crypto.EncryptText(key, 1, i.Value, d, i.FieldId, i.RowKey) }).ToList() }); + _brokerClient.Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => new ProtectedDataBrokerResult + { Success = true, Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = crypto.DecryptText(key, i.Value, d, i.FieldId, i.RowKey) }).ToList() }); + var row = new ChecklistCompletion { DepartmentId = DeptId, Content = "SYNTHETIC-PHI-CANARY", Score = 87.25m, Passed = false }; + var grant = IssueGrant(); + var write = await _service.PrepareRecordsEntityWriteAsync(DeptId, row, null, row.Id, ChecklistTables.Fields(), () => row.IsProtected = true, grant, UserId, false); + write.Success.Should().BeTrue(); row.Score.Should().BeNull(); row.Passed.Should().BeNull(); + row.Content.Should().NotContain("SYNTHETIC-PHI-CANARY"); row.ProtectedScoreEnvelope.Should().StartWith("rgdp:"); row.ProtectedPassedEnvelope.Should().StartWith("rgdp:"); + var stored = Newtonsoft.Json.JsonConvert.DeserializeObject(Newtonsoft.Json.JsonConvert.SerializeObject(row)); + var read = await _service.ResolveRecordsEntitiesForReadAsync(DeptId, new[] { (row, row.Id) }, ChecklistTables.Fields(), grant, UserId); + read.RedactedFields.Should().BeEmpty(); row.Score.Should().Be(87.25m); row.Passed.Should().BeFalse(); row.ProtectedScoreEnvelope.Should().BeNull(); + row.Passed = true; + (await _service.PrepareRecordsEntityWriteAsync(DeptId, row, null, row.Id, ChecklistTables.Fields(), () => row.IsProtected = true, grant, UserId, false)).Success.Should().BeTrue(); + row.Score.Should().BeNull(); row.Passed.Should().BeNull(); + crypto.DecryptText(key, row.ProtectedPassedEnvelope, DeptId, "checklistcompletions.passed", row.Id).Should().Be("True"); + var denied = await _service.ResolveRecordsEntitiesForReadAsync(DeptId, new[] { (stored, stored.Id) }, ChecklistTables.Fields(), null, UserId); + denied.RedactedFields.Should().HaveCount(3); stored.Score.Should().BeNull(); stored.Passed.Should().BeNull(); + var item = new ChecklistCompletionItem { DepartmentId = DeptId, Content = "SYNTHETIC-ANSWER", IsFailure = true }; + (await _service.PrepareRecordsEntityWriteAsync(DeptId, item, null, item.Id, ChecklistTables.Fields(), () => item.IsProtected = true, grant, UserId, false)).Success.Should().BeTrue(); + item.IsFailure.Should().BeNull(); item.Content.Should().NotContain("SYNTHETIC-ANSWER"); + (await _service.ResolveRecordsEntitiesForReadAsync(DeptId, new[] { (item, item.Id) }, ChecklistTables.Fields(), grant, UserId)).RedactedFields.Should().BeEmpty(); + item.IsFailure.Should().BeTrue(); + CryptographicOperations.ZeroMemory(key); + } + + [TestCase("missing"), TestCase("other-user"), TestCase("revoked")] + public async Task Checklist_sensitive_outcomes_cannot_be_revealed_with_invalid_grants(string reason) + { + var row = new ChecklistCompletion { DepartmentId = DeptId, Content = "rgdp:1:1:content==", ProtectedScoreEnvelope = "rgdp:1:1:score==", ProtectedPassedEnvelope = "rgdp:1:1:passed==", Score = null, Passed = null }; + var grant = reason == "missing" ? null : IssueGrant(reason == "other-user" ? "different-user" : UserId, reason == "revoked" ? Epoch - 1 : Epoch); + var result = await _service.ResolveRecordsEntitiesForReadAsync(DeptId, new[] { (row, row.Id) }, ChecklistTables.Fields(), grant, UserId); + result.RedactedFields.Should().HaveCount(3); row.Score.Should().BeNull(); row.Passed.Should().BeNull(); row.Content.Should().Be(ProtectedDataEnvelope.RedactionValue); + _brokerClient.Verify(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + private static Call EnvelopedCall(int callId = 17) => new Call { CallId = callId, @@ -309,7 +354,12 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude // RMS-5 prevention and investigations plus the RMS-4 quality review, catalog v13: read through the generic Records resolvers. "RmsOccupancies", "RmsOccupancyHazards", "RmsInspections", "RmsViolations", "RmsPermits", "RmsPlanReviews", "RmsInvestigationCases", "RmsInvestigationNotes", "RmsInvestigationEvidence", "RmsInvestigationCustody", "RmsInvestigationReferrals", - "RmsQualityReviews", "RmsPreventionAttachments" + "RmsQualityReviews", "RmsPreventionAttachments", + // Checklist content and outcome companions use ChecklistTables.Fields() and the generic resolver. + "ChecklistDefinitions", "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions", + "ChecklistCompletionItems", "ChecklistCompletionFiles", "DepartmentChecklistSettings", "ChecklistSchedules", + // ReadinessHistoryFields supplies masked audit/run/log views; outbox consumers use the safe routing copy. + "AuditLogs", "DomainEventOutbox", "WorkflowRuns", "WorkflowRunLogs" }; AdpTableBindings.V1.Select(b => b.TableName) diff --git a/Tests/Resgrid.Tests/Services/ReadinessAccessServiceTests.cs b/Tests/Resgrid.Tests/Services/ReadinessAccessServiceTests.cs index e4db58838..5a0903287 100644 --- a/Tests/Resgrid.Tests/Services/ReadinessAccessServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ReadinessAccessServiceTests.cs @@ -34,7 +34,7 @@ public void SetUp() _settings = new Mock(); _billing = new Mock(MockBehavior.Strict); _modules = new DepartmentModuleSettings(); - _settings.Setup(s => s.GetDepartmentModuleSettingsAsync(DepartmentId, false)).ReturnsAsync(() => _modules); + _settings.Setup(s => s.GetDepartmentModuleSettingsAsync(DepartmentId, It.IsAny())).ReturnsAsync(() => _modules); SetFlag(FeatureFlagKeys.ChecklistsSystem, true); SetFlag(FeatureFlagKeys.MaintenanceWorkOrders, true); _payment = new PaymentAddon @@ -59,7 +59,11 @@ public void TearDown() Resgrid.Config.ApiConfig.BackendInternalApikey = _billingKey; } - private void SetFlag(string key, bool enabled) => _flags.Setup(f => f.IsEnabledAsync(key, DepartmentId, false, null)).ReturnsAsync(enabled); + private void SetFlag(string key, bool enabled) + { + _flags.Setup(f => f.IsEnabledAsync(key, DepartmentId, false, null)).ReturnsAsync(enabled); + _flags.Setup(f => f.EvaluateFreshAsync(key, DepartmentId)).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = enabled }); + } [Test] public async Task Free_checklists_never_query_billing_or_require_maintenance() diff --git a/Tests/Resgrid.Tests/Services/ReadinessBillingReviewTests.cs b/Tests/Resgrid.Tests/Services/ReadinessBillingReviewTests.cs new file mode 100644 index 000000000..99bf4587c --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ReadinessBillingReviewTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture, NonParallelizable] + public class ReadinessBillingReviewTests + { + [Test] + public async Task Both_addon_helpers_bound_unresponsive_billing_requests() + { + var previousUrl = SystemBehaviorConfig.BillingApiBaseUrl; + var previousKey = ApiConfig.BackendInternalApikey; + var previousCache = SystemBehaviorConfig.CacheEnabled; + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + await using var app = builder.Build(); + app.MapMethods("/api/Billing/{action}", new[] { "GET", "POST" }, async context => + await Task.Delay(TimeSpan.FromSeconds(30), context.RequestAborted)); + try + { + await app.StartAsync(); + SystemBehaviorConfig.BillingApiBaseUrl = app.Urls.Single(); ApiConfig.BackendInternalApikey = "test-only"; SystemBehaviorConfig.CacheEnabled = false; + var service = new SubscriptionsService(null, null, null, null, null, null, null, null); + var plans = service.GetAllAddonPlansByTypeAsync(PlanAddonTypes.ReadinessPro); + var payments = service.GetCurrentPaymentAddonsForDepartmentAsync(77, new List { "readiness" }); + await Task.WhenAll(plans, payments).WaitAsync(TimeSpan.FromSeconds(12)); + (await plans).Should().BeNull(); (await payments).Should().BeNull(); + } + finally + { + SystemBehaviorConfig.BillingApiBaseUrl = previousUrl; ApiConfig.BackendInternalApikey = previousKey; SystemBehaviorConfig.CacheEnabled = previousCache; + await app.StopAsync(); + } + } + + [Test] + public async Task Readiness_plan_metadata_uses_the_shared_cache_with_a_short_expiration() + { + var previousUrl = SystemBehaviorConfig.BillingApiBaseUrl; + var previousKey = ApiConfig.BackendInternalApikey; + var previousCache = SystemBehaviorConfig.CacheEnabled; + try + { + SystemBehaviorConfig.BillingApiBaseUrl = "http://127.0.0.1:1"; ApiConfig.BackendInternalApikey = "test-only"; SystemBehaviorConfig.CacheEnabled = true; + var plans = new List { new PlanAddon { PlanAddonId = "readiness", AddonType = (int)PlanAddonTypes.ReadinessPro } }; + var cache = new Mock(MockBehavior.Strict); + cache.Setup(c => c.RetrieveAsync($"AddonPlansByType_{(int)PlanAddonTypes.ReadinessPro}", It.IsAny>>>(), TimeSpan.FromMinutes(5))).ReturnsAsync(plans); + var service = new SubscriptionsService(null, null, cache.Object, null, null, null, null, null); + (await service.GetAllAddonPlansByTypeAsync(PlanAddonTypes.ReadinessPro)).Should().BeSameAs(plans); + cache.VerifyAll(); cache.VerifyNoOtherCalls(); + } + finally { SystemBehaviorConfig.BillingApiBaseUrl = previousUrl; ApiConfig.BackendInternalApikey = previousKey; SystemBehaviorConfig.CacheEnabled = previousCache; } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ReadinessHistoryProtectionTests.cs b/Tests/Resgrid.Tests/Services/ReadinessHistoryProtectionTests.cs new file mode 100644 index 000000000..cf397c3cb --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ReadinessHistoryProtectionTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ReadinessHistoryProtectionTests + { + private Mock _policy; + private ReadinessHistoryTestProtection _protection; + [SetUp] public void SetUp() + { + _policy = new Mock(); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + _protection = new ReadinessHistoryTestProtection(_policy); + } + [TearDown] public void TearDown() => _protection.Dispose(); + + [Test] + public async Task All_history_fields_encrypt_losslessly_and_display_without_disclosure_or_mutation() + { + async Task Check(T row, string key, IReadOnlyDictionary Get, Action Set)> fields) where T : class + { + foreach (var field in fields) field.Value.Set(row, "SYNTHETIC-PHI-CANARY:" + field.Key); + await _protection.Service.ProtectAsync(42, key, row, fields); + var first = fields.ToDictionary(f => f.Key, f => f.Value.Get(row)); + await _protection.Service.ProtectAsync(42, key, row, fields); + var display = await _protection.Service.ForDisplayAsync(42, row, fields); + foreach (var field in fields) + { + var stored = field.Value.Get(row); + stored.Should().StartWith("rgdp:").And.Be(first[field.Key]).And.NotContain("CANARY"); + _protection.Decrypt(42, field.Key, key, stored).Should().Be("SYNTHETIC-PHI-CANARY:" + field.Key); + field.Value.Get(display).Should().Be(ProtectedDataEnvelope.RedactionValue); + } + } + await Check(new AuditLog(), "19", ReadinessHistoryFields.Audits); + await Check(new DomainEventOutboxEntry(), "1024", ReadinessHistoryFields.Outbox); + await Check(new WorkflowRun(), Guid.NewGuid().ToString(), ReadinessHistoryFields.Runs); + await Check(new WorkflowRunLog(), Guid.NewGuid().ToString(), ReadinessHistoryFields.Logs); + _protection.Broker.Invocations.Should().OnlyContain(i => i.Method.Name == "EncryptAsync"); + } + + [Test] + public async Task Older_catalog_and_redacted_views_cannot_overwrite_history() + { + _policy.Setup(s => s.GetPolicyByDepartmentIdAsync(42, It.IsAny())).ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = 42, CatalogVersion = 15 }); + var row = new AuditLog { Data = "SYNTHETIC-PHI-CANARY" }; + Func write = () => _protection.Service.ProtectAsync(42, "19", row, ReadinessHistoryFields.Audits); + await write.Should().ThrowAsync(); row.Data.Should().Be("SYNTHETIC-PHI-CANARY"); + row.Data = ProtectedDataEnvelope.RedactionValue; + await write.Should().ThrowAsync(); row.Data.Should().Be(ProtectedDataEnvelope.RedactionValue); + } + + [Test] + public async Task Audit_view_redacts_legacy_checklist_data_on_unknown_policy_and_leaves_other_audits_unchanged() + { + var checklist = new AuditLog { DepartmentId = 42, AuditLogId = 19, LogType = ReadinessHistoryFields.AuditTypes.First(), Data = "SYNTHETIC-PHI-CANARY" }; + var other = new AuditLog { DepartmentId = 42, AuditLogId = 20, LogType = (int)AuditLogTypes.GroupAdded, Data = "Legacy group audit" }; + var repository = new Mock(); + repository.Setup(s => s.GetAllByDepartmentIdAsync(42)).ReturnsAsync(new[] { checklist, other }); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ThrowsAsync(new InvalidOperationException()); + var service = new AuditService(repository.Object, Mock.Of(), _protection.Lazy); + var logs = await service.GetAllAuditLogsForDepartmentAsync(42); + logs[0].Data.Should().Be(ProtectedDataEnvelope.RedactionValue); checklist.Data.Should().Be("SYNTHETIC-PHI-CANARY"); + logs[1].Should().BeSameAs(other); + } + + [Test] + public async Task Read_boundary_remains_redacted_until_offboarding_restores_plaintext() + { + var row = new WorkflowRun { InputPayload = "SYNTHETIC-PHI-CANARY" }; + var key = Guid.NewGuid().ToString(); + await _protection.Service.ProtectAsync(42, key, row, ReadinessHistoryFields.Runs); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(false); + (await _protection.Service.ForDisplayAsync(42, row, ReadinessHistoryFields.Runs)).InputPayload.Should().Be(ProtectedDataEnvelope.RedactionValue); + row.InputPayload = _protection.Decrypt(42, "workflowruns.inputpayload", key, row.InputPayload); + (await _protection.Service.ForDisplayAsync(42, row, ReadinessHistoryFields.Runs)).InputPayload.Should().Be("SYNTHETIC-PHI-CANARY"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ReadinessHistoryTestProtection.cs b/Tests/Resgrid.Tests/Services/ReadinessHistoryTestProtection.cs new file mode 100644 index 000000000..c37c60fb4 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ReadinessHistoryTestProtection.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using Moq; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// Real protected-write pipeline and authenticated encryption with an in-process broker. + internal sealed class ReadinessHistoryTestProtection : IDisposable + { + private readonly byte[] _key = RandomNumberGenerator.GetBytes(32); + private readonly ProtectedFieldCryptoService _crypto = new(); + public Mock Broker { get; } = new(); + public ReadinessHistoryProtectionService Service { get; } + public Lazy Lazy => new(() => Service); + public ReadinessHistoryTestProtection(Mock policy) + { + policy.Setup(s => s.ShouldEncryptNewWritesAsync(It.IsAny())).Returns((int d) => policy.Object.IsProtectionEnforcedAsync(d)); + policy.Setup(s => s.GetPolicyByDepartmentIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, bool b) => new DepartmentDataProtectionPolicy { DepartmentId = d, CatalogVersion = ReadinessHistoryFields.CatalogVersion }); + Broker.Setup(s => s.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string grant, string request, IReadOnlyList items, CancellationToken ct) => new ProtectedDataBrokerResult + { + Success = true, Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = _crypto.EncryptText(_key, 1, i.Value, d, i.FieldId, i.RowKey) }).ToList() + }); + Service = new ReadinessHistoryProtectionService(new ProtectedReadService(policy.Object, Mock.Of(), Broker.Object, new ProtectedFieldCatalog()), policy.Object); + } + public string Decrypt(int departmentId, string field, string row, string envelope) => _crypto.DecryptText(_key, envelope, departmentId, field, row); + public void Dispose() => CryptographicOperations.ZeroMemory(_key); + } +} diff --git a/Tests/Resgrid.Tests/Web/checklist-localization.test.cjs b/Tests/Resgrid.Tests/Web/checklist-localization.test.cjs index d1f56204f..6c9f55705 100644 --- a/Tests/Resgrid.Tests/Web/checklist-localization.test.cjs +++ b/Tests/Resgrid.Tests/Web/checklist-localization.test.cjs @@ -26,10 +26,12 @@ const makeItem = (suffix, type) => ({ Id: suffix.padStart(8, '0') + '-1111-1111- const custom = { ...makeItem('2', 6), Options: ['Yes', 'No'], PassingValue: 'Yes', VisibleWhen: { ItemId: first.Id, EqualsValue: 'fail' } }; const definition = { Name: 'User-owned title', Category: 0, TargetType: 0, PassThreshold: 100, Sections: [{ Id: id, Name: 'User-owned section', Items: [first, custom] }] }; const localeScript = ``; - await page.setContent(`
${localeScript}`); + await page.setContent(`
${localeScript}`); await page.evaluate(() => { window.saved = null; window.fetch = async (_, options) => { saved = JSON.parse(options.body.get('formJson')); return { ok: true, json: async () => ({ revision: 2 }) }; }; }); await page.addScriptTag({ path: script }); assert.equal(await page.getByLabel(translate('Checklist name'), { exact: true }).inputValue(), definition.Name); + assert.equal(await page.getByLabel(translate('Target type'), { exact: true }).locator('option[value="5"]').textContent(), translate('InventoryAsset')); + await page.getByLabel(translate('Target type'), { exact: true }).selectOption('5'); assert.equal(await page.getByLabel(translate('Answer type'), { exact: true }).first().locator('option:checked').textContent(), translate('Pass / Fail')); assert.equal(await page.getByLabel(translate('Category'), { exact: true }).locator('option:checked').textContent(), translate('Start of shift')); const condition = page.getByLabel(translate('Answer that activates this condition'), { exact: true }); diff --git a/Tests/Resgrid.Tests/Web/checklists.test.cjs b/Tests/Resgrid.Tests/Web/checklists.test.cjs index c7100468c..0485d0c3c 100644 --- a/Tests/Resgrid.Tests/Web/checklists.test.cjs +++ b/Tests/Resgrid.Tests/Web/checklists.test.cjs @@ -35,6 +35,15 @@ const htmlJson = value => JSON.stringify(value).replace(/ window.pwned), undefined); const conditional = { ...item, Id: otherId, Name: 'Failure detail', Type: 5, Critical: false, Required: false, VisibleWhen: { ItemId: itemId, EqualsValue: 'fail' }, RequiredWhen: { ItemId: itemId, EqualsValue: 'fail' } }; + await page.setContent(`
`); + await page.evaluate(() => { window.requests = []; window.fetch = async (url, options) => { requests.push(Object.fromEntries(options.body.entries())); return { ok: true, json: async () => ({ revision: 2 }) }; }; }); + await page.addScriptTag({ path: script }); + await page.getByRole('button', { name: 'Move item up', exact: true }).nth(1).click(); + assert.equal(await page.getByLabel('Show only when', { exact: true }).nth(0).inputValue(), ''); + await page.getByRole('button', { name: 'Save draft', exact: true }).click(); + const moved = JSON.parse(await page.evaluate(() => requests[0].formJson)).Sections[0].Items[0]; + assert.equal(moved.VisibleWhen, null); assert.equal(moved.RequiredWhen, null, 'Reordering clears conditions that no longer reference earlier items'); + const run = { Completion: { Id: '44444444-4444-4444-4444-444444444444' }, Form: { ...definition, Sections: [{ ...definition.Sections[0], Items: [item, conditional] }] }, Input: { Revision: 1, Answers: [], Note: null }, Files: [] }; await page.setContent(`
`); await page.evaluate(() => { window.requests = []; window.fetch = (url, options) => new Promise(resolve => requests.push({ url, fields: Object.fromEntries(options.body.entries()), resolve })); }); @@ -61,6 +70,13 @@ const htmlJson = value => JSON.stringify(value).replace(/ { window.uploadAttempts = 0; window.fetch = async () => { uploadAttempts++; return { ok: false, status: 400, json: async () => ({ message: 'Retry upload' }) }; }; }); + const picker = page.locator('input[type="file"]').first(); + const uploadFile = { name: 'evidence.png', mimeType: 'image/png', buffer: Buffer.from([1, 2, 3]) }; + await picker.setInputFiles(uploadFile); + await page.waitForFunction(() => document.querySelector('input[type=file]').value === ''); + await picker.setInputFiles(uploadFile); + await page.waitForFunction(() => window.uploadAttempts === 2 && document.querySelector('input[type=file]').value === ''); assert.deepEqual(errors, []); console.log('PASS: builder types and ordering, escaped content, conditional visibility, serialized saves, revision updates, CSRF, conflict preserves unsaved work.'); } finally { await browser.close(); } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs index b3bf7f834..17e6779da 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Model.Services; using System.Threading.Tasks; @@ -34,6 +34,7 @@ namespace Resgrid.Web.Services.Controllers.v4 public class CalendarController : V4AuthenticatedApiControllerbase { #region Members and Constructors + private readonly IChecklistsService _checklists; private readonly ICalendarService _calendarService; private readonly IDepartmentsService _departmentsService; private readonly IAuthorizationService _authorizationService; @@ -43,9 +44,9 @@ public class CalendarController : V4AuthenticatedApiControllerbase public CalendarController(ICalendarService calendarService, IDepartmentsService departmentsService, IAuthorizationService authorizationService, IEventAggregator eventAggregator, - IUserProfileService userProfileService, IProtectedReadService protectedReadService) + IUserProfileService userProfileService, IProtectedReadService protectedReadService, IChecklistsService checklists = null) { - _protectedReadService = protectedReadService; + _protectedReadService = protectedReadService; _checklists = checklists; _calendarService = calendarService; _departmentsService = departmentsService; _authorizationService = authorizationService; @@ -123,8 +124,9 @@ await _protectedReadService.ResolveCalendarItemsForReadAsync(DepartmentId, items [HttpGet("GetDepartmentCalendarItemsInRange")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Schedule_View)] - public async Task> GetDepartmentCalendarItemsInRange(DateTime start, DateTime end) + public async Task> GetDepartmentCalendarItemsInRange(DateTime start, DateTime end, bool includeChecklists = false) { + if (includeChecklists && (end.Date < start.Date || (end.Date - start.Date).TotalDays > 92 || end.Date == DateTime.MaxValue.Date)) return BadRequest(); var result = new GetAllCalendarItemResult(); result.Data = new List(); @@ -161,6 +163,17 @@ await _protectedReadService.ResolveCalendarItemsForReadAsync(DepartmentId, items result.Status = ResponseHelper.NotFound; } + if (includeChecklists && _checklists != null) + { + try + { + foreach (var entry in await _checklists.CalendarAsync(new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = ProtectedGrantToken }, start.Date, end.Date.AddDays(1))) + result.Data.Add(new GetAllCalendarItemResultData { CalendarItemId = entry.Id, Title = entry.Title, StartUtc = entry.StartUtc, EndUtc = entry.EndUtc, Start = entry.StartUtc.TimeConverter(department), End = entry.EndUtc.TimeConverter(department), StartTimezone = department?.TimeZone, EndTimezone = department?.TimeZone, IsVirtual = true, LockEditing = true, SourceType = "Checklist", SourceId = entry.OccurrenceId, IsRedacted = entry.IsRedacted, DeepLinkUrl = "/User/Checklists/Occurrence?id=" + entry.OccurrenceId, ChecklistState = entry.State, TypeColor = entry.State == 4 ? "#c0392b" : entry.State == 2 ? "#247a42" : "#6a4c93" }); + } + catch (Resgrid.Model.Checklists.ChecklistException ex) { return StatusCode(ex.StatusCode); } + result.PageSize = result.Data.Count; result.Status = result.Data.Count > 0 ? ResponseHelper.Success : ResponseHelper.NotFound; + } + Response.Headers["Cache-Control"] = "no-store"; ResponseHelper.PopulateV4ResponseData(result); return result; diff --git a/Web/Resgrid.Web.Services/Models/v4/Calendar/GetAllCalendarItemResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calendar/GetAllCalendarItemResult.cs index aa80fed4f..126549c0e 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calendar/GetAllCalendarItemResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calendar/GetAllCalendarItemResult.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Newtonsoft.Json; @@ -22,6 +22,12 @@ public class GetAllCalendarItemResult : StandardApiResponseV4Base /// public class GetAllCalendarItemResultData { + public bool IsVirtual { get; set; } + public int? ChecklistState { get; set; } + public string SourceType { get; set; } + public string SourceId { get; set; } + public string DeepLinkUrl { get; set; } + public bool IsRedacted { get; set; } /// /// Identifier for the calendar item /// diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index c04ce0b43..b0025ac92 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -123,7 +123,7 @@ ActionResult<List<CalendarItem>>. - + Gets the department calendar items in range. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs index feb2ceaf5..58cba895e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs @@ -26,6 +26,7 @@ namespace Resgrid.Web.Areas.User.Controllers public class CalendarController : SecureBaseController { #region Private Members and Constructors + private readonly IChecklistsService _checklists; private readonly IDepartmentsService _departmentsService; private readonly IUsersService _usersService; private readonly ICalendarService _calendarService; @@ -42,9 +43,9 @@ public CalendarController(IDepartmentsService departmentsService, IUsersService IDepartmentGroupsService departmentGroupsService, IGeoLocationProvider geoLocationProvider, IEventAggregator eventAggregator, IAuthorizationService authorizationService, IUserProfileService userProfileService, IPermissionsService permissionsService, IPersonnelRolesService personnelRolesService, - IProtectedReadService protectedReadService) + IProtectedReadService protectedReadService, IChecklistsService checklists = null) { - _protectedReadService = protectedReadService; + _protectedReadService = protectedReadService; _checklists = checklists; _departmentsService = departmentsService; _usersService = usersService; _calendarService = calendarService; @@ -611,12 +612,13 @@ public async Task GetDepartmentCalendarItems() [HttpGet] [Authorize(Policy = ResgridResources.Schedule_View)] - public async Task GetV2CalendarEntriesForCal(string start, string end) + public async Task GetV2CalendarEntriesForCal(string start, string end, bool includeChecklists = false) { - var jsonItems = new List(); + var jsonItems = new List(); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); var items = await _calendarService.GetAllCalendarItemsForDepartmentAsync(DepartmentId, DateTime.UtcNow.AddMonths(-6)); + await _protectedReadService.ResolveCalendarItemsForReadAsync(DepartmentId, items, null, UserId); var itemTypes = await _calendarService.GetAllCalendarItemTypesForDepartmentAsync(DepartmentId); foreach (var item in items) @@ -654,6 +656,17 @@ public async Task GetV2CalendarEntriesForCal(string start, string jsonItems.Add(jsonItem); } + if (includeChecklists && _checklists != null) + { + if (!DateTimeOffset.TryParse(start, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var from) || !DateTimeOffset.TryParse(end, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var until)) return BadRequest(); + try + { + foreach (var entry in await _checklists.CalendarAsync(new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = DepartmentId, UserId = UserId }, from.UtcDateTime, until.UtcDateTime)) + jsonItems.Add(new { id = entry.Id, title = entry.Title, start = entry.StartUtc.ToString("O"), end = entry.EndUtc.ToString("O"), allDay = false, backgroundColor = entry.State == 4 ? "#c0392b" : entry.State == 2 ? "#247a42" : "#6a4c93", checklistState = entry.State, url = Url.Action("Occurrence", "Checklists", new { area = "User", id = entry.OccurrenceId }), isVirtual = true, isRedacted = entry.IsRedacted }); + } + catch (Resgrid.Model.Checklists.ChecklistException ex) { return StatusCode(ex.StatusCode); } + } + Response.Headers["Cache-Control"] = "no-store"; return Json(jsonItems); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistRemindersController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistRemindersController.cs new file mode 100644 index 000000000..512666b4c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistRemindersController.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Checklists; +using Resgrid.Providers.Claims; + +namespace Resgrid.Web.Areas.User.Controllers +{ + public partial class ChecklistsController + { + [HttpGet, Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task Reminders() => View("Reminders", await _checklists.ReminderSettingsAsync(Actor)); + [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task SaveReminders(ChecklistReminderSettingsInput input) + { + if (!ModelState.IsValid) throw new ChecklistException(400, "ReminderValidation"); + await _checklists.SaveReminderSettingsAsync(Actor, input); + return RedirectToAction("Reminders"); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs index 40c22e2d9..b328e0104 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsController.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; @@ -18,7 +19,7 @@ namespace Resgrid.Web.Areas.User.Controllers { [Area("User")] [Authorize] - public class ChecklistsController : SecureBaseController + public partial class ChecklistsController : SecureBaseController { private readonly IChecklistTemplateService _templates; private readonly IChecklistsService _checklists; @@ -26,8 +27,10 @@ public class ChecklistsController : SecureBaseController private readonly IProtectedGrantContext _grant; private readonly IDepartmentDataProtectionService _protection; private readonly IStringLocalizer _strings; - public ChecklistsController(IChecklistTemplateService templates, IChecklistsService checklists, IReadinessAccessService access, IProtectedGrantContext grant, IDepartmentDataProtectionService protection, IStringLocalizer strings) - { _templates = templates; _checklists = checklists; _access = access; _grant = grant; _protection = protection; _strings = strings; } + private readonly Lazy _workshifts; + private readonly Lazy _departments; + public ChecklistsController(IChecklistTemplateService templates, IChecklistsService checklists, IReadinessAccessService access, IProtectedGrantContext grant, IDepartmentDataProtectionService protection, IStringLocalizer strings, Lazy workshifts = null, Lazy departments = null) + { _templates = templates; _checklists = checklists; _access = access; _grant = grant; _protection = protection; _strings = strings; _workshifts = workshifts; _departments = departments; } private ChecklistActor Actor => new ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = _grant.GrantToken }; public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { @@ -42,12 +45,17 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context { executed.ExceptionHandled = true; if (HttpMethods.IsGet(Request.Method) && ex.StatusCode == 403 && ex.Message.StartsWith("Unlock protected", StringComparison.Ordinal)) - executed.Result = View("Locked", new ChecklistLockedView { Page = context.RouteData.Values["action"]?.ToString(), Id = Request.Query["id"].ToString() is { Length: > 0 } queryId ? queryId : context.RouteData.Values["id"]?.ToString() }); - else executed.Result = StatusCode(ex.StatusCode, new { message = ex.Message }); + executed.Result = View("Locked", new ChecklistLockedView { Page = context.RouteData.Values["action"]?.ToString() == "EditSchedule" && Request.Query.ContainsKey("definitionId") ? "NewSchedule" : context.RouteData.Values["action"]?.ToString(), Id = Request.Query["id"].ToString() is { Length: > 0 } queryId ? queryId : Request.Query["definitionId"].ToString() is { Length: > 0 } definitionId ? definitionId : context.RouteData.Values["id"]?.ToString() }); + else { var message = _strings[ex.Message]; executed.Result = StatusCode(ex.StatusCode, new { message = message.ResourceNotFound ? _strings["The request could not be completed."].Value : message.Value }); } } } [HttpGet] - public async Task Index(int page = 0) => View("Index", new ChecklistIndexView { Definitions = await _checklists.ListAsync(Actor, page), CanManage = await _checklists.CanManageAsync(Actor) && await _access.CanUseChecklistsAsync(DepartmentId), Page = page }); + public async Task Index(int page = 0) + { + var rows = await _checklists.ListAsync(Actor, page, includeNext: true); + return View("Index", new ChecklistIndexView { Definitions = rows.Take(50).ToList(), HasMore = rows.Count > 50, + CanManage = await _checklists.CanManageAsync(Actor) && await _access.CanUseChecklistsAsync(DepartmentId), Page = page }); + } [HttpGet] public async Task Templates(string query = null) { @@ -68,14 +76,15 @@ public async Task Template(string id) public async Task New(string templateId = null) { if (!await _access.CanUseChecklistsAsync(DepartmentId) || !await _checklists.CanManageAsync(Actor)) return NotFound(); + var assetsAvailable = await _checklists.AssetTargetsAvailableAsync(Actor); var form = new ChecklistForm { Name = "", Sections = { new ChecklistSection { Name = _strings["Checks"].Value, Items = { new ChecklistItem { Name = "" } } } } }; if (templateId != null) { var template = await _templates.GetByIdAsync(DepartmentId, templateId); if (template == null) return NotFound(); - form = ChecklistForm.FromTemplate(template); + form = ChecklistForm.FromTemplate(template, assetsAvailable); } - return View("Edit", new ChecklistEditView { Form = form }); + return View("Edit", new ChecklistEditView { Form = form, AssetsAvailable = assetsAvailable }); } [HttpGet, Authorize(Policy = ResgridResources.Checklist_Update)] public async Task Edit(string id) @@ -83,7 +92,7 @@ public async Task Edit(string id) if (!await _checklists.CanManageAsync(Actor)) return Forbid(); if (!await _access.CanUseChecklistsAsync(DepartmentId)) return NotFound(); var row = await _checklists.GetDefinitionAsync(Actor, id); - return View("Edit", new ChecklistEditView { Id = id, Revision = row.Definition.Revision, Form = row.Form }); + return View("Edit", new ChecklistEditView { AssetsAvailable = await _checklists.AssetTargetsAvailableAsync(Actor), Id = id, Revision = row.Definition.Revision, Form = row.Form }); } [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = ResgridResources.Checklist_Update)] public async Task SaveDefinition(string id, int revision, string formJson) @@ -92,10 +101,10 @@ public async Task SaveDefinition(string id, int revision, string var saved = await _checklists.SaveDefinitionAsync(Actor, id, revision, form); return Json(new { url = Url.Action("Detail", new { id = saved }) }); } - private static T Parse(string json) + private static T Parse(string json) where T : class { if (string.IsNullOrWhiteSpace(json) || json.Length > 1000000) throw new ChecklistException(400, "Form content is missing or too large."); - try { return JsonConvert.DeserializeObject(json, new JsonSerializerSettings { MaxDepth = 20, TypeNameHandling = TypeNameHandling.None }); } + try { return JsonConvert.DeserializeObject(json, new JsonSerializerSettings { MaxDepth = 20, TypeNameHandling = TypeNameHandling.None }) ?? throw new ChecklistException(400, "The form content is invalid."); } catch (JsonException) { throw new ChecklistException(400, "The form content is invalid."); } } [HttpGet] @@ -103,7 +112,8 @@ public async Task Detail(string id, int page = 0) { var row = await _checklists.GetDefinitionAsync(Actor, id); var canStart = await _access.CanUseChecklistsAsync(DepartmentId) && !row.Definition.Retired && row.Definition.CurrentVersionId != null; - return View("Detail", new ChecklistDetailView { Definition = row, History = await _checklists.HistoryAsync(Actor, id, page), CanManage = await _checklists.CanManageAsync(Actor) && await _access.CanUseChecklistsAsync(DepartmentId), CanStart = canStart, + var history = await _checklists.HistoryAsync(Actor, id, page, includeNext: true); + return View("Detail", new ChecklistDetailView { Definition = row, History = history.Take(50).ToList(), HasMore = history.Count > 50, CanManage = await _checklists.CanManageAsync(Actor) && await _access.CanUseChecklistsAsync(DepartmentId), CanStart = canStart, Targets = canStart ? await _checklists.TargetsAsync(Actor, row.PublishedForm.TargetType) : new System.Collections.Generic.List(), Page = page }); } [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = ResgridResources.Checklist_Update)] @@ -148,6 +158,8 @@ public async Task Export(string id) public Task Reopen(string page, string id) => page switch { "Index" => Index(), "Detail" => Detail(id), "Edit" => Edit(id), "Run" => Run(id), "CompletionDetail" => CompletionDetail(id), + "Schedules" => Schedules(id), "EditSchedule" => EditSchedule(id), "NewSchedule" => EditSchedule(definitionId: id), "Due" => Due(), + "Occurrence" => Occurrence(id), "Reminders" => Reminders(), _ => Task.FromResult(BadRequest()) }; } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs new file mode 100644 index 000000000..d37c4b37c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistsSchedulingController.cs @@ -0,0 +1,66 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Checklists; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Checklists; + +namespace Resgrid.Web.Areas.User.Controllers +{ + public partial class ChecklistsController + { + [HttpGet, Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task Schedules(string id, int page = 0) => View("Schedules", new ChecklistSchedulesView { DefinitionId = id, Schedules = await _checklists.SchedulesAsync(Actor, id, page), Page = page, CanEdit = await _access.CanUseChecklistsAsync(DepartmentId) }); + [HttpGet, Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task EditSchedule(string id = null, string definitionId = null) + { + if (!await _access.CanUseChecklistsAsync(DepartmentId) || !await _checklists.CanManageAsync(Actor)) return Forbid(); + var input = new ChecklistScheduleInput { DefinitionId = definitionId }; + if (id != null) + { + var view = await _checklists.GetScheduleAsync(Actor, id); var row = view.Schedule; + input = new ChecklistScheduleInput { Id = row.Id, DefinitionId = row.ParentId, Revision = row.Revision, Name = view.Content.Name, Notes = view.Content.Notes, TargetId = row.TargetId, AssignmentType = row.AssignmentType, AssignmentId = row.AssignmentId, + Frequency = (ChecklistScheduleFrequency)row.Frequency, TimeZoneId = row.TimeZoneId, StartDate = row.StartDate, EndDate = row.EndDate, TimesOfDay = string.Join(", ", row.ClockMinutes.Split(',').Select(v => TimeOnly.MinValue.AddMinutes(int.Parse(v, CultureInfo.InvariantCulture)).ToString("HH:mm", CultureInfo.InvariantCulture))), + Weekdays = row.Weekdays, DayOfMonth = row.DayOfMonth, MonthOfYear = row.MonthOfYear, WindowMinutes = row.WindowMinutes, WorkshiftId = row.WorkshiftId, IsActive = row.IsActive }; + } + var definition = await _checklists.GetDefinitionAsync(Actor, input.DefinitionId); + if (definition.PublishedForm == null) throw new ChecklistException(409, "ScheduleRequiresPublished"); + if (id == null) + { + input.Name = definition.PublishedForm.Name; + var department = _departments == null ? null : await _departments.Value.GetDepartmentByIdAsync(DepartmentId); + input.TimeZoneId = department?.TimeZone ?? "UTC"; + try { input.StartDate = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(DateTime.UtcNow, input.TimeZoneId).Date; } catch (TimeZoneNotFoundException) { input.TimeZoneId = "UTC"; } + } + var model = new ChecklistScheduleEditView { Input = input, Targets = await _checklists.TargetsAsync(Actor, definition.PublishedForm.TargetType), Assignments = await _checklists.AssignmentChoicesAsync(Actor) }; + if (_workshifts != null) model.Workshifts = (await _workshifts.Value.GetAllWorkshiftsByDepartmentAsync(DepartmentId)).Where(s => !s.DeletedOn.HasValue).Select(s => new ChecklistWorkshiftChoice { Id = s.WorkshiftId, Name = s.Name }).ToList(); + return View("EditSchedule", model); + } + [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task SaveSchedule(ChecklistScheduleInput input, int[] selectedWeekdays, string assignment = null) + { + if (!ModelState.IsValid) throw new ChecklistException(400, "ScheduleValidation"); + if (assignment != null) + { + var split = assignment.IndexOf(':'); + if (split < 0 || !int.TryParse(assignment.Substring(0, split), out var type)) throw new ChecklistException(400, "AssignmentUnavailable"); + input.AssignmentType = type; input.AssignmentId = type == 0 ? null : assignment.Substring(split + 1); + } + if (selectedWeekdays?.Any(d => d < 0 || d > 6) == true) throw new ChecklistException(400, "ScheduleValidation"); + input.Weekdays = (selectedWeekdays ?? Array.Empty()).Distinct().Aggregate(0, (mask, day) => mask | 1 << day); + if (input.Frequency != ChecklistScheduleFrequency.Weekly && input.Weekdays == 0) input.Weekdays = 127; + await _checklists.SaveScheduleAsync(Actor, input); return RedirectToAction("Schedules", new { id = input.DefinitionId }); + } + [HttpGet] + public async Task Due(int page = 0) => View("Due", new ChecklistDueView { Occurrences = await _checklists.DueAsync(Actor, page), Page = page }); + [HttpGet] + public async Task Occurrence(string id) => View("Occurrence", await _checklists.OccurrenceAsync(Actor, id)); + [HttpPost, ValidateAntiForgeryToken] + public async Task StartOccurrence(string id) => RedirectToAction("Run", new { id = await _checklists.StartOccurrenceAsync(Actor, id) }); + [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = ResgridResources.Checklist_Update)] + public async Task SkipOccurrence(string id, int revision, string reason) { await _checklists.SkipOccurrenceAsync(Actor, id, revision, reason); return RedirectToAction("Due"); } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistScheduleViews.cs b/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistScheduleViews.cs new file mode 100644 index 000000000..ae501ec91 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistScheduleViews.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Resgrid.Model.Checklists; + +namespace Resgrid.Web.Areas.User.Models.Checklists +{ + public class ChecklistSchedulesView { public string DefinitionId { get; set; } public List Schedules { get; set; } public int Page { get; set; } public bool CanEdit { get; set; } } + public class ChecklistScheduleEditView + { + public ChecklistScheduleInput Input { get; set; } + public List Targets { get; set; } + public List Assignments { get; set; } = new(); + public List Workshifts { get; set; } = new(); + } + public class ChecklistWorkshiftChoice { public string Id { get; set; } public string Name { get; set; } } + public class ChecklistDueView { public List Occurrences { get; set; } public int Page { get; set; } } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistViews.cs b/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistViews.cs index ce7baf0f0..22cf0d6e9 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Checklists/ChecklistViews.cs @@ -3,8 +3,8 @@ namespace Resgrid.Web.Areas.User.Models.Checklists { - public class ChecklistIndexView { public List Definitions { get; set; } public bool CanManage { get; set; } public int Page { get; set; } } - public class ChecklistEditView { public string Id { get; set; } public int Revision { get; set; } public ChecklistForm Form { get; set; } } + public class ChecklistIndexView { public List Definitions { get; set; } public bool HasMore { get; set; } public bool CanManage { get; set; } public int Page { get; set; } } + public class ChecklistEditView { public bool AssetsAvailable { get; set; } public string Id { get; set; } public int Revision { get; set; } public ChecklistForm Form { get; set; } } public class ChecklistDetailView { public ChecklistDefinitionView Definition { get; set; } @@ -12,6 +12,7 @@ public class ChecklistDetailView public List Targets { get; set; } = new List(); public bool CanManage { get; set; } public bool CanStart { get; set; } + public bool HasMore { get; set; } public int Page { get; set; } } public class ChecklistLockedView { public string Page { get; set; } public string Id { get; set; } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/Index.cshtml index 245ea25b9..66b58e95a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/Index.cshtml @@ -1,4 +1,5 @@ -@using Resgrid.Model.Helpers +@inject IStringLocalizer checklistStrings +@using Resgrid.Model.Helpers @using Resgrid.Web.Helpers @using Resgrid.Framework @model Resgrid.Web.Areas.User.Models.Calendar.IndexView @@ -53,7 +54,7 @@
-
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml index 369ee862a..fdbffda6f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml @@ -9,10 +9,10 @@
@localizer["State"]
@localizer[((ChecklistRunState)run.State).ToString()]
@localizer["Score"]
@(run.Score.HasValue ? run.Score + "%" : localizer["NoScore"].Value)
-
@localizer["Result"]
@(run.SubmittedOn.HasValue ? run.Passed ? localizer["Passed"] : localizer["Failed"] : localizer["Incomplete"])
+
@localizer["Result"]
@(run.SubmittedOn.HasValue ? run.Passed.HasValue ? run.Passed.Value ? localizer["Passed"] : localizer["Failed"] : "—" : localizer["Incomplete"])
@localizer["Author"]
@run.CreatedBy
@localizer["Submitted"] (UTC)
@run.SubmittedOn?.ToString("u")
@localizer["Version"]
@Model.VersionNumber
- @if (run.WitnessedOn.HasValue) {
@localizer["WitnessLabel"]
@run.WitnessUserId · @run.WitnessedOn.Value.ToString("u")
@localizer["Attestation"]
@((string)Newtonsoft.Json.Linq.JObject.Parse(run.Content ?? "{}")["WitnessAttestation"])
} + @if (run.WitnessedOn.HasValue) {
@localizer["WitnessLabel"]
@run.WitnessUserId · @run.WitnessedOn.Value.ToString("u")
@localizer["Attestation"]
@Model.WitnessAttestation
}
@foreach (var checklistSection in Model.Form.Sections) { diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml index e79b7ae84..94f423a7f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Detail.cshtml @@ -8,7 +8,7 @@ @await Html.PartialAsync("_Protection") @if (Model.CanManage) { -

@localizer["EditDraft"]

+

@localizer["EditDraft"]@localizer["Schedules"]

@Html.AntiForgeryToken() @localizer["PublishGuidance"] @@ -34,11 +34,11 @@ @foreach (var entry in Model.History) { var run = entry.Completion; - @run.CreatedOn.ToString("yyyy-MM-dd HH:mm:ss")@entry.TargetName@localizer[((ChecklistRunState)run.State).ToString()]@(run.Score.HasValue ? run.Score + "%" : "—")@(run.SubmittedOn.HasValue ? run.Passed ? localizer["Passed"] : localizer["Failed"] : localizer["Incomplete"]) + @run.CreatedOn.ToString("yyyy-MM-dd HH:mm:ss")@entry.TargetName@localizer[((ChecklistRunState)run.State).ToString()]@(run.Score.HasValue ? run.Score + "%" : "—")@(run.SubmittedOn.HasValue ? run.Passed.HasValue ? run.Passed.Value ? localizer["Passed"] : localizer["Failed"] : "—" : localizer["Incomplete"]) } @if (Model.Page > 0) { @localizer["Previous"] } - @localizer["Next"] + @if (Model.HasMore) { @localizer["Next"] } @localizer["Back"] @section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml new file mode 100644 index 000000000..d9d899d68 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Due.cshtml @@ -0,0 +1,22 @@ +@model Resgrid.Web.Areas.User.Models.Checklists.ChecklistDueView +@using Resgrid.Model.Checklists +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["DueChecks"]; } +
+

@localizer["DueChecks"]

@localizer["DueGuidance"]

+ @await Html.PartialAsync("_Protection") + @if (Model.Occurrences.Count == 0) {

@localizer["NoDueChecks"]

} +
+ @foreach (var entry in Model.Occurrences) + { + var row = entry.Occurrence; + + } +
@localizer["Name"]@localizer["Target"]@localizer["ScheduledFor"] (UTC)@localizer["WindowEnds"] (UTC)@localizer["State"]@localizer["Actions"]
@entry.Name@entry.Target.Name@row.PeriodStartUtc?.ToString("yyyy-MM-dd HH:mm")@row.WindowEndUtc?.ToString("yyyy-MM-dd HH:mm")@localizer[((ChecklistOccurrenceState)row.State).ToString()] + @if (entry.CanStart) { @Html.AntiForgeryToken() } + @if (entry.CanSkip) {
@localizer["ExcuseCheck"]
@Html.AntiForgeryToken()
} +
+ @if (Model.Page > 0) { @localizer["Previous"] } + @localizer["Next"]@localizer["Back"] +
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml index 8f80e6440..6e313b115 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Edit.cshtml @@ -9,7 +9,7 @@
@Html.AntiForgeryToken() -
+
@localizer["Back"]
diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml new file mode 100644 index 000000000..d3a9cbdf4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml @@ -0,0 +1,33 @@ +@model Resgrid.Web.Areas.User.Models.Checklists.ChecklistScheduleEditView +@using Resgrid.Model.Checklists +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["EditSchedule"]; } +
+

@localizer["EditSchedule"]

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

@localizer["ScheduleTimeGuidance"]

+
+
+

@localizer["TimesOfDayGuidance"]

+
@localizer["WeeklyDays"]@for (var day = 0; day < 7; day++) { }
+
+

@localizer["MonthGuidance"]

+

@localizer["WorkshiftGuidance"]

+
+
+
+

@localizer["ScheduleVersionGuidance"]

+ @localizer["Back"] +
+
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml index 4793cd675..cf43f701a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Index.cshtml @@ -4,8 +4,8 @@

@localizer["Checklists"]

@localizer["Free"]

@await Html.PartialAsync("_Protection") -

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

+

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

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

@localizer["NoDefinitions"]

} @foreach (var item in Model.Definitions) @@ -14,6 +14,6 @@ }
@localizer["Name"]@localizer["Version"]@localizer["State"]
@if (Model.Page > 0) { @localizer["Previous"] } - @localizer["Next"] + @if (Model.HasMore) { @localizer["Next"] }
@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Occurrence.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Occurrence.cshtml new file mode 100644 index 000000000..4ecf5957a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Occurrence.cshtml @@ -0,0 +1,13 @@ +@model Resgrid.Model.Checklists.ChecklistOccurrenceView +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["DueChecks"]; } +
+ @await Html.PartialAsync("_Protection") +

@Model.Name

@Model.Target.Name

+

+

@localizer[((Resgrid.Model.Checklists.ChecklistOccurrenceState)Model.Occurrence.State).ToString()]

+ @if (Model.CanStart) {
} + @if (Model.CanSkip) {
} + @localizer["DueChecks"] +
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Reminders.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Reminders.cshtml new file mode 100644 index 000000000..8344f9cd8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Reminders.cshtml @@ -0,0 +1,22 @@ +@model Resgrid.Model.Checklists.ChecklistReminderSettingsInput +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["ReminderSettings"]; } +

@localizer["ReminderSettings"]

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

@localizer["ReminderHelp"]

+
+ @Html.AntiForgeryToken() + +
+
+
+
+
+
+
+ + @localizer["Checklists"] +
+
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/Schedules.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/Schedules.cshtml new file mode 100644 index 000000000..e82f0895d --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/Schedules.cshtml @@ -0,0 +1,21 @@ +@model Resgrid.Web.Areas.User.Models.Checklists.ChecklistSchedulesView +@using Resgrid.Model.Checklists +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["Schedules"]; } +
+

@localizer["Schedules"]

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

@localizer["ScheduleVersionGuidance"]

+ @if (Model.CanEdit) { @localizer["NewSchedule"] } + @if (Model.Schedules.Count == 0) {

@localizer["NoSchedules"]

} + + @foreach (var entry in Model.Schedules) + { + + } +
@localizer["Name"]@localizer["Frequency"]@localizer["TimeZone"]@localizer["State"]
@if (Model.CanEdit) { @entry.Content.Name } else { @entry.Content.Name }@localizer[((ChecklistScheduleFrequency)entry.Schedule.Frequency).ToString()]@entry.Schedule.TimeZoneId@localizer[entry.Schedule.IsActive && !entry.Schedule.IsSuspended ? "Active" : "SchedulePaused"]
+ @if (Model.Page > 0) { @localizer["Previous"] } + @localizer["Next"] + @localizer["Back"] +
+@section Scripts { @await Html.PartialAsync("_ProtectionScripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/ModuleSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/ModuleSettings.cshtml index f6991e8ed..031e772c9 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/ModuleSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/ModuleSettings.cshtml @@ -196,7 +196,15 @@
- +
+
+ + +
+

@checklistLocalizer["Free"]

diff --git a/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml index 1baf85537..8ac7ac4da 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml @@ -1,10 +1,11 @@ -@model Resgrid.Model.Workflow +@inject IStringLocalizer checklistStrings +@model Resgrid.Model.Workflow @inject IStringLocalizer localizer @{ ViewData["Title"] = "Resgrid | " + localizer["EditWorkflowHeader"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; var credentialsJson = (string)(ViewBag.CredentialsJson ?? "[]"); - var triggerEventTypeName = (string)(ViewBag.TriggerEventTypeName ?? Model.TriggerEventType.ToString()); + var triggerEventTypeName = Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)Model.TriggerEventType) ? checklistStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : (string)(ViewBag.TriggerEventTypeName ?? Model.TriggerEventType.ToString()); } @section Styles { diff --git a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml index de1862f69..dc84286f5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml @@ -1,4 +1,5 @@ -@model Resgrid.Model.Workflow +@inject IStringLocalizer checklistStrings +@model Resgrid.Model.Workflow @inject IStringLocalizer localizer @{ ViewData["Title"] = "Resgrid | " + localizer["NewWorkflowHeader"]; @@ -9,7 +10,7 @@ .Cast() .Where(e => !usedEventTypes.Contains((int)e)) .Where(e => recordsTriggersAvailable || !Resgrid.Model.WorkflowTriggerEventTypes.IsRecordsTrigger(e)) - .Select(e => new SelectListItem { Value = ((int)e).ToString(), Text = e.ToString() }) + .Select(e => new SelectListItem { Value = ((int)e).ToString(), Text = Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)e) ? checklistStrings[e.ToString()].Value : e.ToString() }) .ToList(); var noEventTypesAvailable = !eventTypes.Any(); } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.index.js b/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.index.js index cec2b0bc6..3c492ec5a 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.index.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.index.js @@ -28,6 +28,7 @@ var resgrid; dayMaxEvents: true, // show "+N more" when too many events events: { url: resgrid.absoluteBaseUrl + '/User/Calendar/GetV2CalendarEntriesForCal', + extraParams: function () { return { includeChecklists: document.getElementById('include-checklists')?.checked === true }; }, failure: function () { console.error('Failed to load calendar events.'); } @@ -54,6 +55,7 @@ var resgrid; }); calendarInstance.render(); + document.getElementById('include-checklists')?.addEventListener('change', function () { calendarInstance.refetchEvents(); }); }); index.getCalendar = function () { return calendarInstance; }; diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js b/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js index ca47b857a..96f2e91d5 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js @@ -59,7 +59,9 @@ field(root, 'Checklist name', model, 'Name').maxLength = 200; field(root, 'Instructions (do not include patient data)', model, 'Instructions', 'textarea').maxLength = 10000; field(root, 'Category', model, 'Category', 'number', categories.map((name, i) => [i, name])); - field(root, 'Target type', model, 'TargetType', 'number', [[0, 'Department'], [1, 'Unit'], [2, 'Group / station'], [3, 'Personnel']]); + const targets = [[0, 'Department'], [1, 'Unit'], [2, 'Group / station'], [3, 'Personnel']]; + if (root.dataset.assetsAvailable === 'true' || model.TargetType === 5) targets.push([5, 'InventoryAsset']); + field(root, 'Target type', model, 'TargetType', 'number', targets); const threshold = field(root, 'Passing score (%)', model, 'PassThreshold', 'number'); threshold.min = 0; threshold.max = 100; field(root, 'Require reported location', model, 'RequireLocation', 'checkbox'); field(root, 'Require a different authenticated member to witness the submission', model, 'RequiresIndependentWitness', 'checkbox'); @@ -90,6 +92,7 @@ field(box, 'Exact passing choice', item, 'PassingValue'); } [['VisibleWhen', 'Show only when'], ['RequiredWhen', 'Also required when']].forEach(pair => { + if (item[pair[0]] && !earlier.some(source => source.Id === item[pair[0]].ItemId)) item[pair[0]] = null; const value = { ItemId: item[pair[0]] ? item[pair[0]].ItemId : '' }; field(box, pair[1], value, 'ItemId', 'text', [['', 'Always / no condition']].concat(earlier.map(i => [i.Id, i.Name || tr('Unnamed earlier item'), false]))).addEventListener('change', () => { item[pair[0]] = value.ItemId ? { ItemId: value.ItemId, EqualsValue: '' } : null; render(); @@ -177,7 +180,8 @@ field(box, item.RequireNoteOnFail ? 'Note (required on failure)' : 'Note', answer, 'Note', 'textarea').maxLength = 5000; const evidence = el('div', null, box); const block = { box, item, answer, required, status, value, na, evidence }; blocks.push(block); const uploadLabel = el('label', tr('Evidence image (PNG/JPEG, up to 10 MB; scanning required)'), box); - const picker = el('input', null, uploadLabel); picker.type = 'file'; picker.accept = 'image/png,image/jpeg'; picker.addEventListener('change', () => upload(block, picker.files[0])); + const picker = el('input', null, uploadLabel); picker.type = 'file'; picker.accept = 'image/png,image/jpeg'; + picker.addEventListener('change', async () => { try { await upload(block, picker.files[0]); } finally { picker.value = ''; } }); if (item.Type === 9) { el('p', tr('Draw your signature or upload a signature image. A required independent witness must sign in separately.'), box); const canvas = el('canvas', null, box); canvas.width = 600; canvas.height = 160; canvas.style.cssText = 'max-width:100%;border:1px solid #777;touch-action:none;background:white'; diff --git a/Workers/Resgrid.Workers.Console/Commands/ChecklistReminderCommand.cs b/Workers/Resgrid.Workers.Console/Commands/ChecklistReminderCommand.cs new file mode 100644 index 000000000..85a9702b3 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/ChecklistReminderCommand.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + public sealed class ChecklistReminderCommand : IQuidjiboCommand + { + public ChecklistReminderCommand(int id) { Id = id; } + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/Workers/Resgrid.Workers.Console/Commands/ChecklistSchedulingCommand.cs b/Workers/Resgrid.Workers.Console/Commands/ChecklistSchedulingCommand.cs new file mode 100644 index 000000000..fbcf93efe --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/ChecklistSchedulingCommand.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + public sealed class ChecklistSchedulingCommand : IQuidjiboCommand + { + public ChecklistSchedulingCommand(int id) { Id = id; } + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index 4000892cc..62275aa57 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using Autofac; using Microsoft.Extensions.Logging; using Quidjibo; @@ -491,6 +491,10 @@ await Client.ScheduleAsync("Domain Event Outbox Dispatch", Cron.MinuteIntervals(1), stoppingToken); + // Worker 63 is the readiness registry allocation. Department gating and pauses are evaluated by the service. + await Client.ScheduleAsync("Checklist Scheduling", new Commands.ChecklistSchedulingCommand(63), Cron.MinuteIntervals(5), stoppingToken); + await Client.ScheduleAsync("Checklist Reminders", new Commands.ChecklistReminderCommand(64), Cron.MinuteIntervals(5), stoppingToken); + // Worker ID 44 (Identifier Allocation Registry section 3.3, the Unified Search allocation absorbed by RMS-1): records search index maintenance. This // process holds the single Lucene writer; retention erasure still runs while search indexing is disabled. _logger.Log(LogLevel.Information, "Scheduling Records Search Index"); diff --git a/Workers/Resgrid.Workers.Console/Tasks/ChecklistReminderTask.cs b/Workers/Resgrid.Workers.Console/Tasks/ChecklistReminderTask.cs new file mode 100644 index 000000000..e9bcd27bc --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/ChecklistReminderTask.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class ChecklistReminderTask : IQuidjiboHandler + { + public string Name => "Checklist Reminders"; + public int Priority => 1; + public async Task ProcessAsync(ChecklistReminderCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + var result = await new ChecklistReminderLogic().Process(cancellationToken); + if (!result.Item1) throw new InvalidOperationException(result.Item2); + progress?.Report(100, result.Item2); + } + } +} diff --git a/Workers/Resgrid.Workers.Console/Tasks/ChecklistSchedulingTask.cs b/Workers/Resgrid.Workers.Console/Tasks/ChecklistSchedulingTask.cs new file mode 100644 index 000000000..050355ea5 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/ChecklistSchedulingTask.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class ChecklistSchedulingTask : IQuidjiboHandler + { + public string Name => "Checklist Scheduling"; + public int Priority => 1; + public async Task ProcessAsync(ChecklistSchedulingCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + var result = await new ChecklistSchedulingLogic().Process(cancellationToken); + if (!result.Item1) throw new InvalidOperationException(result.Item2); + progress?.Report(100, result.Item2); + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/ChecklistReminderLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ChecklistReminderLogic.cs new file mode 100644 index 000000000..bd22c9056 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/ChecklistReminderLogic.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + public sealed class ChecklistReminderLogic + { + public async Task> Process(CancellationToken ct) + { + try + { + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var result = await scope.Resolve().SweepAsync(DateTime.UtcNow, ct); + return Tuple.Create(result.Errors == 0, $"Checklist reminders: handedOff={result.HandedOff}, suppressed={result.Suppressed}, errors={result.Errors}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch { return Tuple.Create(false, "Checklist reminders failed."); } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/ChecklistSchedulingLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ChecklistSchedulingLogic.cs new file mode 100644 index 000000000..b7d361a46 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/ChecklistSchedulingLogic.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + public sealed class ChecklistSchedulingLogic + { + public async Task> Process(CancellationToken ct) + { + try + { + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var result = await scope.Resolve().SweepSchedulesAsync(DateTime.UtcNow, ct); + return Tuple.Create(result.Errors == 0, $"Checklist scheduling: generated={result.Generated}, missed={result.Missed}, errors={result.Errors}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch { return Tuple.Create(false, "Checklist scheduling failed."); } + } + } +}