diff --git a/Core/Resgrid.Config/RecordsSystemAccessConfig.cs b/Core/Resgrid.Config/RecordsSystemAccessConfig.cs new file mode 100644 index 00000000..45d322e5 --- /dev/null +++ b/Core/Resgrid.Config/RecordsSystemAccessConfig.cs @@ -0,0 +1,21 @@ +namespace Resgrid.Config +{ + /// + /// Explicitly scoped Record access for system principals — the SMTP relay API key and the + /// client_credentials service accounts (Identifier Allocation Registry section 4.4). A system principal + /// receives no Record claim at all unless a grant below names its department, and the most a grant can + /// ever give is Record_View: every mutating and restricted Record policy stays denied by + /// construction, not by configuration. Empty (the default) means no system principal reads Records. + /// Environment key: RESGRID:RecordsSystemAccessConfig:Grants. + /// + public static class RecordsSystemAccessConfig + { + /// + /// Semicolon-delimited grants, each departmentId|purpose|scope, where scope is either + /// DepartmentWide or Groups:1,2,3. The purpose is required, is written to the Record + /// access audit on every read, and is what makes the grant explicit rather than ambient. + /// Example: 12|NerisAudit|DepartmentWide;45|StationReporting|Groups:101,102. + /// + public static string Grants = ""; + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index 37e4bb73..f9a6e128 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -343,4 +343,625 @@ قائمة انتظار الإرسال تم حفظ إعدادات NERIS. تمت إضافة الإرسال إلى قائمة انتظار NERIS. + + مقبول + + + كل التعريفات + + + تحليلات بانتظار الإيداع + + + اكتمل هذا التحليل وسيُودَع فور وصول الحادث نفسه إلى NERIS. + + + أقسام التحليل + + + ينطبق + + + مُسند إلى + + + سيارة + + + بانتظار المراجعة + + + سجلات أُرسلت للمراجعة ولم تُعتمد بعد + + + العودة إلى تقرير الحادث + + + طراز الهيكل + + + بلاغ + + + التقاط + + + سبب التقاط هذا الدليل + + + التقطه + + + تاريخ الالتقاط + + + المصابون وعمليات الإنقاذ + + + مصاب + + + النشاط وقت الإصابة + + + سبب الإصابة + + + توقيت الحدوث + + + مدني + + + إغلاق الطلب + + + تاريخ الإغلاق + + + الأقسام المشروطة + + + نوع البناء + + + خسارة المحتويات + + + قيمة المحتويات + + + تغطية مطابقة الرموز + + + كل نوع بلاغ محلي بلا رمز NERIS سيحتاج إلى تصنيف يدوي وقت الحادث. + + + العملة + + + درجة الضرر + + + تعذّر إنتاج بعض الإحصاءات: + + + التعريف + + + التفاصيل + + + طلب الاطلاع على السجلات + + + أُغلق طلب الاطلاع على السجلات. + + + تم تسجيل طلب الاطلاع على السجلات. + + + لطلب الاطلاع مهلة قانونية، لذلك يُتابَع هنا بدل الرد عليه ارتجالاً. + + + تجاوز هذا الطلب مهلته القانونية. + + + أُعدّت نسخة منقّحة للتسليم. + + + طلبات الاطلاع على السجلات + + + يلزم بيان السبب لإغلاق طلب الاطلاع. + + + سُلِّمت النسخة إلى مقدّم الطلب. + + + تم حفظ النطاق. + + + الاطلاع على السجلات + + + سبب القرار + + + المسودات + + + نوع المهمة حينها + + + تحرير مطابقة الرموز + + + تحرير تحليل الحادث + + + خطأ + + + الخسارة التقديرية + + + القيمة التقديرية + + + الأدلة + + + تم التقاط الدليل. + + + الشهادات وقت الحادث + + + رسائل الحادث المُدرجة + + + كل دليل لقطة محدودة تُلتقط مرة واحدة ولها بصمة تحقق ولا تُعدَّل أبداً؛ والتصحيح التقاط جديد. + + + المستهلكات والمواد الخاضعة للرقابة المستخدمة + + + الجاهزية وقت البلاغ + + + يلزم بيان السبب لإدراج دليل في سجل رسمي. + + + قرار الإرسال + + + المصدر + + + مواقع الوحدات + + + لم يكن لدى مصدر الأدلة هذا ما يلتقطه لهذا السجل. + + + العنصر المعرَّض + + + العناصر المعرَّضة + + + قاتلة + + + الإنهاء يكتب النسخة غير القابلة للتغيير. ويظل الإيداع منتظراً معرّف NERIS للحادث إن لم يكن متاحاً بعد. + + + تاريخ الإنهاء + + + امتداد الحريق + + + رجل إطفاء + + + الجنس + + + السبب العام + + + تحليل الحادث + + + يُودع التحقيق في الحريق والمواد الخطرة بشكل منفصل عن الحادث بعد أن تصبح الجهة المستقبِلة تملكه. + + + تقارير غير مكتملة + + + تقارير حوادث لم تُنهَ بعد + + + تضمين قيود السجل القديم + + + غير مكتمل + + + مصاب + + + أنواع التحقيق + + + العناصر + + + الاختصاص القضائي + + + النوع + + + لوحة التسجيل + + + الرمز المحلي + + + تسجيل طلب اطلاع + + + الصانع + + + بيان المحتوى + + + مطابَقة + + + مطابقة + + + الطراز + + + سنة الطراز + + + معرّف تحليل NERIS + + + رمز NERIS + + + لا توجد طلبات اطلاع مسجّلة. + + + لم يُلتقط أي دليل لهذا التقرير. + + + لم يُنتَج شيء بعد. + + + لا تنطبق أي أقسام مشروطة قبل اختيار نوع الحادث. + + + مفتوحة + + + فتح التحليل + + + فتح طلبات الاطلاع + + + فتح قائمة الحوادث + + + فتح قائمة السجلات + + + السجلات التشغيلية + + + متأخرة + + + التزامات تجاوزت موعدها + + + نوع الشخص + + + عضو + + + نظام الدفع + + + تاريخ الإعداد + + + في السجل + + + الرمز الأساسي + + + إعداد النسخة + + + النسخة المُعدّة وثيقة جديدة غير قابلة للتغيير؛ ولا يغيّر أي تعديل لاحق ما جرى تسليمه. + + + قابل للتسليم + + + لا تزال بصمة التحقق مطابقة؛ لم تُعدَّل النسخة. + + + بصمة التحقق لا تطابق ما هو مخزَّن. + + + النسخ المُعدّة + + + إصدار العقد + + + الممتلكات + + + الكمية + + + إدراج الإيداع في الطابور + + + في الطابور + + + العِرق + + + الرتبة + + + تاريخ الاستلام + + + السجلات + + + لوحة السجلات + + + لم تُفعَّل وحدة السجلات لهذه الإدارة بعد. + + + ملف التنقيح + + + مرفوضة + + + رُفضت من الجهة المستقبِلة ولم تُصحَّح بعد + + + تسليم + + + تاريخ التسليم + + + الطلب + + + الرقم المرجعي + + + مقدّم الطلب + + + بيانات الاتصال + + + هوية مقدّم الطلب محظورة ولا تُعرض هنا. + + + اسم مقدّم الطلب + + + الجهة + + + إلزامي + + + إنقاذ + + + منسوب الإنقاذ + + + أسلوب الإنقاذ + + + مسار الإنقاذ + + + نوع الإنقاذ + + + المورد + + + الموارد + + + تُحفظ في سجل الإدارة فقط ولا تُرسل إلى NERIS + + + بعض الحقول محظورة ولا تُعرض لك. + + + أُعيدت للتصحيح + + + حفظ النطاق + + + النطاق + + + النطاق مجمَّد لأنه سبق إعداد نسخة استناداً إليه. + + + ما تم طلبه + + + ما يشمله النطاق + + + عدد السجلات المطابقة أكبر مما هو مدرج هنا. + + + الرمز الثانوي + + + البطارية المعنية + + + المادة الكيميائية المتسربة + + + نظام إطفاء المطابخ + + + أنابيب غاز فولاذية مموجة + + + خطر كهربائي + + + تفاصيل الحريق + + + نظام إنذار الحريق + + + نظام الإطفاء + + + الوضع الخطر + + + تحليل الوضع الخطر + + + رعاية المريض + + + الأكسجين الطبي المنزلي + + + لم يعد هذا القسم ينطبق على أنواع الحوادث المختارة. + + + إنذار آخر + + + تحليل الحريق في العراء + + + موقع الحريق في العراء + + + خطر توليد الطاقة + + + المنتج المعني + + + جهاز إنذار الدخان + + + موقع حريق المبنى + + + منشأ حريق المبنى + + + أُرسل + + + مجموعة القيم + + + قديمة + + + بدء التحليل + + + الحالات + + + إذا تُرك فارغاً حدّدته المهلة القانونية للإدارة. + + + الموعد القانوني + + + طوابق فوق الأرض + + + طوابق تحت الأرض + + + إيداع + + + مُودعة + + + مُقترح + + + مُستبدَل + + + العنوان + + + حالة الإشغال + + + المركبات + + + التحقق من بصمة السلامة + + + رقم هيكل المركبة + + + كان مشغولاً حينها + + + السنة + + + سنة البناء + + + مركبة أخرى + + + يلزم إدخال بيانات اعتماد جديدة عند تغيير نوع منح الوصول في NERIS. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index 54b39584..386e6977 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -343,4 +343,625 @@ Übermittlungswarteschlange NERIS-Einstellungen gespeichert. Übermittlung an NERIS eingereiht. + + Angenommen + + + Alle Definitionen + + + Analysen warten auf Einreichung + + + Diese Analyse ist abgeschlossen und wird eingereicht, sobald der Einsatz selbst NERIS erreicht. + + + Analyseabschnitte + + + Gilt + + + Zugewiesen an + + + Personenkraftwagen + + + Wartet auf Prüfung + + + Berichte zur Prüfung eingereicht und noch nicht freigegeben + + + Zurück zum Einsatzbericht + + + Karosserieform + + + Einsatz + + + Erfassen + + + Grund für die Erfassung dieses Nachweises + + + Erfasst von + + + Erfasst am + + + Verletzte und Rettungen + + + Verletzte Person + + + Tätigkeit zum Zeitpunkt + + + Ursache der Verletzung + + + Zeitpunkt im Einsatzverlauf + + + Zivilperson + + + Antrag abschließen + + + Abgeschlossen am + + + Bedingte Abschnitte + + + Bauart + + + Schaden am Inventar + + + Wert des Inventars + + + Abdeckung der Codezuordnung + + + Jede lokale Einsatzart ohne NERIS-Code muss im Ernstfall von Hand zugeordnet werden. + + + Währung + + + Schadensgrad + + + Einige Zählungen konnten nicht erstellt werden: + + + Definition + + + Angabe + + + Auskunftsersuchen + + + Das Auskunftsersuchen wurde abgeschlossen. + + + Das Auskunftsersuchen wurde erfasst. + + + Ein Auskunftsersuchen unterliegt einer gesetzlichen Frist und wird deshalb hier nachverfolgt statt beiläufig beantwortet. + + + Die gesetzliche Frist für dieses Ersuchen ist abgelaufen. + + + Eine geschwärzte Fassung wurde erstellt. + + + Auskunftsersuchen + + + Zum Abschluss eines Auskunftsersuchens ist eine Begründung erforderlich. + + + Die Fassung wurde an die antragstellende Person herausgegeben. + + + Der Umfang wurde gespeichert. + + + Auskunftsersuchen + + + Begründung der Entscheidung + + + Entwürfe + + + Dienstart zum Zeitpunkt + + + Codezuordnung bearbeiten + + + Einsatzanalyse bearbeiten + + + Fehler + + + Geschätzter Schaden + + + Geschätzter Wert + + + Nachweise + + + Der Nachweis wurde erfasst. + + + Qualifikationen zum Einsatzzeitpunkt + + + Übernommene Einsatznachrichten + + + Jeder Nachweis ist eine einmalige, begrenzte Momentaufnahme mit Prüfsumme, die nie geändert wird; eine Korrektur ist eine neue Erfassung. + + + Verbrauchtes Material und Betäubungsmittel + + + Einsatzbereitschaft zum Alarmzeitpunkt + + + Für die Aufnahme eines Nachweises in einen amtlichen Bericht ist eine Begründung erforderlich. + + + Alarmierungsentscheidung + + + Quelle + + + Fahrzeugpositionen + + + Diese Nachweisquelle hatte für diesen Bericht nichts zu erfassen. + + + Betroffenes Objekt + + + Nachbarobjekte + + + Tödlich + + + Der Abschluss schreibt die unveränderliche Fassung. Die Einreichung wartet auf die NERIS-Kennung des Einsatzes, falls diese noch fehlt. + + + Abgeschlossen am + + + Brandausbreitung + + + Einsatzkraft + + + Geschlecht + + + Allgemeine Ursache + + + Einsatzanalyse + + + Die Brand- und Gefahrgutuntersuchung wird getrennt vom Einsatz eingereicht, sobald dieser beim Empfänger vorliegt. + + + Unvollständige Berichte + + + Einsatzberichte, die noch nicht abgeschlossen sind + + + Alt-Protokolleinträge einbeziehen + + + Unvollständig + + + Verletzt + + + Untersuchungsarten + + + Einträge + + + Rechtsraum + + + Art + + + Kennzeichen + + + Lokaler Code + + + Auskunftsersuchen erfassen + + + Marke + + + Nachweisinhalt + + + zugeordnet + + + gefunden + + + Modell + + + Baujahr + + + NERIS-Analysekennung + + + NERIS-Code + + + Es sind keine Auskunftsersuchen erfasst. + + + Für diesen Bericht wurden noch keine Nachweise erfasst. + + + Es wurde noch nichts erstellt. + + + Bedingte Abschnitte gelten erst, wenn eine Einsatzart gewählt ist. + + + Offen + + + Analyse öffnen + + + Auskunftsersuchen öffnen + + + Einsatzberichte öffnen + + + Berichtsliste öffnen + + + Einsatzdokumentation + + + Überfällig + + + Pflichten, deren Frist abgelaufen ist + + + Personengruppe + + + Mitglied + + + Antriebsart + + + Erstellt am + + + Im Bericht enthalten + + + Hauptcode + + + Fassung erstellen + + + Eine Fassung ist ein neues unveränderliches Dokument; eine spätere Änderung kann das Herausgegebene nicht verändern. + + + Herausgabefähig + + + Die Prüfsumme stimmt weiterhin; die Fassung wurde nicht verändert. + + + Die Prüfsumme stimmt nicht mit dem gespeicherten Wert überein. + + + Herausgegebene Fassungen + + + Vertragsversion + + + Objekte + + + Menge + + + Einreichung einreihen + + + Eingereiht + + + Ethnische Zugehörigkeit + + + Dienstgrad + + + Eingegangen am + + + Berichte + + + Berichtsübersicht + + + Die Berichtsverwaltung ist für diese Organisation noch nicht aktiviert. + + + Schwärzungsprofil + + + Abgelehnt + + + Vom Empfänger abgelehnt und noch nicht korrigiert + + + Herausgeben + + + Herausgegeben am + + + Ersuchen + + + Aktenzeichen + + + Antragstellende Person + + + Kontaktangaben + + + Wer das Ersuchen gestellt hat, ist geschützt und wird hier nicht angezeigt. + + + Name der antragstellenden Person + + + Organisation + + + Erforderlich + + + Rettung + + + Höhenlage der Rettung + + + Rettungsart + + + Rettungsweg + + + Rettungsart + + + Mittel + + + Eingesetzte Mittel + + + nur im Bericht der Organisation, wird nie an NERIS gesendet + + + Einige Felder sind geschützt und werden Ihnen nicht angezeigt. + + + Zur Korrektur zurückgegeben + + + Umfang speichern + + + Umfang + + + Der Umfang ist festgeschrieben, weil bereits eine Fassung daraus erstellt wurde. + + + Gegenstand des Ersuchens + + + Was der Umfang derzeit umfasst + + + Es entsprechen mehr Berichte, als hier aufgeführt sind. + + + Zweiter Code + + + Beteiligte Batterie + + + Freigesetzter Stoff + + + Küchenlöschanlage + + + Gewellte Edelstahl-Gasleitung + + + Elektrische Gefahr + + + Branddaten + + + Brandmeldeanlage + + + Löschanlage + + + Gefahrenlage + + + Analyse der Gefahrenlage + + + Patientenversorgung + + + Medizinischer Sauerstoff im Haushalt + + + Dieser Abschnitt gilt für die gewählten Einsatzarten nicht mehr. + + + Sonstige Warnanlage + + + Analyse des Freilandbrands + + + Brandort im Freien + + + Gefahr durch Stromerzeugung + + + Beteiligtes Erzeugnis + + + Rauchwarnmelder + + + Brandort im Gebäude + + + Brandausbruchstelle im Gebäude + + + Gesendet + + + Werteliste + + + veraltet + + + Analyse beginnen + + + Status + + + Bleibt das Feld leer, setzt die gesetzliche Frist der Organisation den Termin. + + + Gesetzliche Frist + + + Geschosse über Grund + + + Geschosse unter Grund + + + Einreichen + + + Eingereicht + + + Empfohlen + + + Ersetzt + + + Bezeichnung + + + Nutzungszustand + + + Fahrzeuge + + + Prüfsumme überprüfen + + + Fahrgestellnummer + + + Zum Zeitpunkt besetzt + + + Jahr + + + Baujahr + + + Sonstiges Fahrzeug + + + Beim Wechsel des NERIS-Grant-Typs sind neue Zugangsdaten erforderlich. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index 9eefd3e3..a1b591db 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -343,4 +343,625 @@ Ουρά υποβολών Οι ρυθμίσεις NERIS αποθηκεύτηκαν. Η υποβολή μπήκε στην ουρά για το NERIS. + + Έγινε αποδεκτό + + + Όλοι οι ορισμοί + + + Αναλύσεις σε αναμονή υποβολής + + + Η ανάλυση οριστικοποιήθηκε και θα υποβληθεί μόλις το ίδιο το συμβάν φτάσει στο NERIS. + + + Ενότητες ανάλυσης + + + Ισχύει + + + Ανατέθηκε σε + + + Αυτοκίνητο + + + Σε αναμονή ελέγχου + + + Αρχεία που υποβλήθηκαν για έλεγχο και δεν έχουν εγκριθεί + + + Επιστροφή στην αναφορά συμβάντος + + + Τύπος αμαξώματος + + + Κλήση + + + Καταγραφή + + + Λόγος καταγραφής αυτού του τεκμηρίου + + + Καταγράφηκε από + + + Καταγράφηκε στις + + + Τραυματίες και διασώσεις + + + Τραυματίας + + + Ενέργεια τη στιγμή εκείνη + + + Αιτία τραυματισμού + + + Πότε συνέβη + + + Πολίτης + + + Κλείσιμο του αιτήματος + + + Έκλεισε στις + + + Υπό όρους ενότητες + + + Τύπος κατασκευής + + + Ζημιά περιεχομένου + + + Αξία περιεχομένου + + + Κάλυψη αντιστοίχισης κωδικών + + + Κάθε τοπικός τύπος κλήσης χωρίς κωδικό NERIS θα πρέπει να ταξινομηθεί χειροκίνητα την ίδια νύχτα. + + + Νόμισμα + + + Βαθμός ζημιάς + + + Ορισμένες μετρήσεις δεν ήταν δυνατό να παραχθούν: + + + Ορισμός + + + Λεπτομέρεια + + + Αίτημα πρόσβασης σε αρχεία + + + Το αίτημα πρόσβασης έκλεισε. + + + Το αίτημα πρόσβασης καταχωρήθηκε. + + + Ένα αίτημα πρόσβασης έχει νόμιμη προθεσμία, γι' αυτό παρακολουθείται εδώ αντί να απαντάται περιστασιακά. + + + Το αίτημα αυτό έχει ξεπεράσει τη νόμιμη προθεσμία. + + + Ετοιμάστηκε παραδοτέο με απαλείψεις. + + + Αιτήματα πρόσβασης σε αρχεία + + + Απαιτείται αιτιολογία για το κλείσιμο ενός αιτήματος. + + + Το παραδοτέο δόθηκε στον αιτούντα. + + + Το εύρος αποθηκεύτηκε. + + + Πρόσβαση σε αρχεία + + + Αιτιολογία της έκβασης + + + Πρόχειρα + + + Υπηρεσία τη στιγμή εκείνη + + + Επεξεργασία αντιστοίχισης + + + Επεξεργασία ανάλυσης συμβάντος + + + Σφάλμα + + + Εκτιμώμενη ζημιά + + + Εκτιμώμενη αξία + + + Τεκμήρια + + + Το τεκμήριο καταγράφηκε. + + + Πιστοποιήσεις τη στιγμή του συμβάντος + + + Μηνύματα συμβάντος που εντάχθηκαν + + + Κάθε τεκμήριο είναι ένα οριοθετημένο στιγμιότυπο που λαμβάνεται μία φορά, με άθροισμα ελέγχου και δεν αλλάζει ποτέ· η διόρθωση είναι νέα καταγραφή. + + + Αναλώσιμα και ελεγχόμενες ουσίες που χρησιμοποιήθηκαν + + + Ετοιμότητα τη στιγμή της κλήσης + + + Απαιτείται αιτιολογία για την ένταξη τεκμηρίου σε επίσημο αρχείο. + + + Απόφαση κινητοποίησης + + + Πηγή + + + Στίγματα οχημάτων + + + Αυτή η πηγή τεκμηρίων δεν είχε τίποτα να καταγράψει για το αρχείο αυτό. + + + Εκτεθειμένο αντικείμενο + + + Εκτεθειμένα αντικείμενα + + + Θανατηφόρο + + + Η οριστικοποίηση γράφει την αμετάβλητη αναθεώρηση. Η υποβολή περιμένει το αναγνωριστικό NERIS του συμβάντος αν δεν υπάρχει ακόμη. + + + Οριστικοποιήθηκε στις + + + Εξάπλωση πυρκαγιάς + + + Πυροσβέστης + + + Φύλο + + + Γενική αιτία + + + Ανάλυση συμβάντος + + + Η διερεύνηση πυρκαγιάς και επικίνδυνων υλικών υποβάλλεται χωριστά από το συμβάν, αφού ο παραλήπτης το έχει ήδη. + + + Ημιτελείς αναφορές + + + Αναφορές συμβάντων που δεν έχουν οριστικοποιηθεί + + + Συμπερίληψη παλαιών καταχωρίσεων + + + Ημιτελείς + + + Τραυματισμένος + + + Είδη διερεύνησης + + + Στοιχεία + + + Δικαιοδοσία + + + Είδος + + + Πινακίδα κυκλοφορίας + + + Τοπικός κωδικός + + + Καταχώριση αιτήματος πρόσβασης + + + Μάρκα + + + Κατάλογος περιεχομένου + + + αντιστοιχισμένοι + + + αντιστοιχίες + + + Μοντέλο + + + Έτος μοντέλου + + + Αναγνωριστικό ανάλυσης NERIS + + + Κωδικός NERIS + + + Δεν έχουν καταχωριστεί αιτήματα πρόσβασης. + + + Δεν έχουν καταγραφεί τεκμήρια για την αναφορά αυτή. + + + Δεν έχει παραχθεί τίποτα ακόμη. + + + Καμία υπό όρους ενότητα δεν ισχύει έως ότου επιλεγεί τύπος συμβάντος. + + + Ανοικτά + + + Άνοιγμα ανάλυσης + + + Άνοιγμα αιτημάτων πρόσβασης + + + Άνοιγμα ουράς συμβάντων + + + Άνοιγμα ουράς αρχείων + + + Επιχειρησιακά αρχεία + + + Εκπρόθεσμα + + + Υποχρεώσεις που έχουν λήξει + + + Τύπος προσώπου + + + Μέλος + + + Σύστημα κίνησης + + + Ετοιμάστηκε στις + + + Στο αρχείο + + + Κύριος κωδικός + + + Παραγωγή + + + Το παραδοτέο είναι νέο αμετάβλητο τεκμήριο· μεταγενέστερη τροποποίηση δεν αλλάζει ό,τι δόθηκε. + + + Δυνατόν να δοθεί + + + Το άθροισμα ελέγχου εξακολουθεί να ταιριάζει· δεν έχει αλλοιωθεί. + + + Το άθροισμα ελέγχου δεν ταιριάζει με το αποθηκευμένο. + + + Παραδοτέα + + + Έκδοση συμβολαίου + + + Ακίνητα + + + Ποσότητα + + + Προσθήκη υποβολής στην ουρά + + + Σε ουρά + + + Εθνοτική καταγωγή + + + Βαθμός + + + Παραλήφθηκε στις + + + Αρχεία + + + Πίνακας αρχείων + + + Το αρχείο δεν έχει ενεργοποιηθεί ακόμη για την υπηρεσία αυτή. + + + Προφίλ απαλείψεων + + + Απορρίφθηκαν + + + Απορρίφθηκαν από τον παραλήπτη και δεν διορθώθηκαν + + + Χορήγηση + + + Χορηγήθηκε στις + + + Αίτημα + + + Αριθμός αναφοράς + + + Αιτών + + + Στοιχεία επικοινωνίας + + + Η ταυτότητα του αιτούντος είναι περιορισμένη και δεν εμφανίζεται εδώ. + + + Όνομα αιτούντος + + + Οργανισμός + + + Υποχρεωτική + + + Διάσωση + + + Υψόμετρο διάσωσης + + + Τρόπος διάσωσης + + + Διαδρομή διάσωσης + + + Είδος διάσωσης + + + Πόρος + + + Πόροι + + + τηρούνται μόνο στο αρχείο της υπηρεσίας, δεν αποστέλλονται στο NERIS + + + Ορισμένα πεδία είναι περιορισμένα και δεν σας εμφανίζονται. + + + Επιστράφηκαν για διόρθωση + + + Αποθήκευση εύρους + + + Εύρος + + + Το εύρος έχει παγώσει επειδή έχει ήδη παραχθεί υλικό βάσει αυτού. + + + Τι ζητήθηκε + + + Τι αποδίδει το εύρος + + + Ταιριάζουν περισσότερα αρχεία από όσα εμφανίζονται εδώ. + + + Δευτερεύων κωδικός + + + Εμπλεκόμενη μπαταρία + + + Χημική ουσία που διέρρευσε + + + Σύστημα κατάσβεσης κουζίνας + + + Κυματοειδής ανοξείδωτη σωλήνωση αερίου + + + Ηλεκτρικός κίνδυνος + + + Στοιχεία πυρκαγιάς + + + Σύστημα πυρανίχνευσης + + + Σύστημα κατάσβεσης + + + Επικίνδυνη κατάσταση + + + Ανάλυση επικίνδυνης κατάστασης + + + Φροντίδα ασθενούς + + + Ιατρικό οξυγόνο κατ' οίκον + + + Η ενότητα αυτή δεν ισχύει πλέον για τους επιλεγμένους τύπους συμβάντος. + + + Άλλος συναγερμός + + + Ανάλυση υπαίθριας πυρκαγιάς + + + Θέση υπαίθριας πυρκαγιάς + + + Κίνδυνος από παραγωγή ενέργειας + + + Εμπλεκόμενο προϊόν + + + Ανιχνευτής καπνού + + + Θέση πυρκαγιάς σε κτίριο + + + Εστία πυρκαγιάς κτιρίου + + + Στάλθηκε + + + Σύνολο τιμών + + + παρωχημένοι + + + Έναρξη ανάλυσης + + + Καταστάσεις + + + Αν μείνει κενό, το ορίζει η νόμιμη προθεσμία της υπηρεσίας. + + + Νόμιμη προθεσμία + + + Όροφοι πάνω από το έδαφος + + + Όροφοι κάτω από το έδαφος + + + Υποβολή + + + Υποβλήθηκαν + + + Προτεινόμενη + + + Αντικαταστάθηκε + + + Τίτλος + + + Κατάσταση κατοίκησης + + + Οχήματα + + + Έλεγχος αθροίσματος ελέγχου + + + Αριθμός πλαισίου + + + Κατειλημμένο τη στιγμή εκείνη + + + Έτος + + + Έτος κατασκευής + + + Άλλο όχημα + + + Απαιτούνται νέα διαπιστευτήρια όταν αλλάζει ο τύπος παραχώρησης NERIS. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index 6a41a5d3..f6fa6c34 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -343,4 +343,625 @@ Submission queue NERIS settings saved. Submission queued for NERIS. + + Accepted + + + All definitions + + + Analyses awaiting filing + + + This analysis is finalized and will be filed as soon as the incident itself reaches NERIS. + + + Analysis sections + + + Applies + + + Assigned to + + + Automobile + + + Awaiting review + + + Records submitted for review and not yet approved + + + Back to the incident report + + + Body style + + + Call + + + Capture + + + Why this evidence is being captured + + + Captured by + + + Captured on + + + Casualties and rescues + + + Casualty + + + Action at the time + + + Cause of the casualty + + + When it happened + + + Civilian + + + Close the request + + + Closed on + + + Conditional sections + + + Construction type + + + Contents loss + + + Contents value + + + Crosswalk coverage + + + Every local call type that has no NERIS code will need classifying by hand on the night. + + + Currency + + + Damage rating + + + Some counts could not be produced: + + + Definition + + + Detail + + + Records request + + + The records request was closed. + + + The records request was logged. + + + A records request carries a statutory clock, so it is tracked here rather than answered ad hoc. + + + This request is past its statutory due date. + + + A redacted production was prepared. + + + Records requests + + + A reason is required to close a records request. + + + The production was released to the requester. + + + The scope was saved. + + + Records requests + + + Reason for the outcome + + + Drafts + + + Duty at the time + + + Edit the crosswalk + + + Edit the incident analysis + + + Error + + + Estimated loss + + + Estimated value + + + Evidence + + + The evidence artifact was captured. + + + Certifications at the incident time + + + Promoted incident messages + + + Each artifact is a bounded snapshot taken once, checksummed and never changed; a correction is a new capture. + + + Supplies and controlled substances used + + + Readiness at the time of the call + + + A reason is required to capture evidence into an official record. + + + Dispatch decision + + + Source + + + Unit tracking fixes + + + That evidence source had nothing to capture for this record. + + + Exposed item + + + Exposures + + + Fatal + + + Finalizing writes the immutable revision. Filing waits for the incident's NERIS id if it does not have one yet. + + + Finalized on + + + Fire spread + + + Firefighter + + + Gender + + + General cause + + + Incident analysis + + + The fire and hazmat investigation is filed separately from the incident, once the destination already holds it. + + + Incomplete reports + + + Incident reports not yet finalized + + + Include legacy log entries + + + Incomplete + + + Injured + + + Investigation types + + + Items + + + Jurisdiction + + + Kind + + + Registration plate + + + Local code + + + Log a records request + + + Make + + + Manifest + + + mapped + + + matched + + + Model + + + Model year + + + NERIS analysis id + + + NERIS code + + + No records requests are logged. + + + No evidence has been captured against this report. + + + Nothing has been produced yet. + + + No conditional sections apply until an incident type is chosen. + + + Open + + + Open the analysis + + + Open the records requests + + + Open the incident queue + + + Open the records queue + + + Operational records + + + Overdue + + + Obligations past their due date + + + Person type + + + Member + + + Powertrain + + + Prepared on + + + On the record + + + Primary code + + + Produce + + + A production is a new immutable artifact; a later amendment cannot change what was released. + + + Producible + + + The production checksum still matches; it has not been altered. + + + The production checksum does not match what was stored. + + + Productions + + + Contract version + + + Properties + + + Quantity + + + Queue the filing + + + Queued + + + Race + + + Rank + + + Received on + + + Records + + + Records dashboard + + + Records is not activated for this department yet. + + + Redaction profile + + + Rejected + + + Rejected by the destination and not yet corrected + + + Release + + + Released on + + + Request + + + Reference + + + Requester + + + Contact details + + + Who asked is restricted and is not shown here. + + + Requester name + + + Organisation + + + Required + + + Rescue + + + Elevation of the rescue + + + Rescue mode + + + Route of the rescue + + + Rescue type + + + Resource + + + Resources + + + kept on the department record only, never sent to NERIS + + + Some fields are restricted and are not shown to you. + + + Returned for correction + + + Save the scope + + + Scope + + + The scope is frozen because something has already been produced against it. + + + What was asked for + + + What the scope resolves to + + + More records match than are listed here. + + + Secondary code + + + Battery involved + + + Released chemical + + + Kitchen suppression system + + + Corrugated stainless gas tubing + + + Electrical hazard + + + Fire detail + + + Fire alarm system + + + Fire suppression system + + + Hazardous situation + + + Hazardous situation analysis + + + Patient care + + + Home medical oxygen + + + This section no longer applies to the selected incident types. + + + Other alarm + + + Outdoor fire analysis + + + Outdoor fire location + + + Power generation hazard + + + Product involved + + + Smoke alarm + + + Structure fire location + + + Origin of the structure fire + + + Sent + + + Value set + + + stale + + + Start the analysis + + + States + + + Left blank, the department's statutory clock sets it. + + + Statutory due date + + + Storeys above grade + + + Storeys below grade + + + File it + + + Submitted + + + Suggested + + + Superseded + + + Title + + + Occupancy status + + + Vehicles + + + Verify the checksum + + + VIN + + + Occupied at the time + + + Year + + + Year built + + + Other vehicle + + + A new credential is required when the NERIS grant type changes. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index 6bdab4fe..79abf044 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -343,4 +343,625 @@ Cola de envíos Configuración NERIS guardada. Envío en cola para NERIS. + + Aceptado + + + Todas las definiciones + + + Análisis pendientes de envío + + + Este análisis está finalizado y se enviará en cuanto el propio incidente llegue a NERIS. + + + Secciones del análisis + + + Se aplica + + + Asignado a + + + Automóvil + + + Pendiente de revisión + + + Registros enviados a revisión y aún no aprobados + + + Volver al informe del incidente + + + Tipo de carrocería + + + Aviso + + + Capturar + + + Motivo por el que se captura esta evidencia + + + Capturado por + + + Capturado el + + + Víctimas y rescates + + + Víctima + + + Actividad en ese momento + + + Causa de la lesión + + + Momento en que ocurrió + + + Civil + + + Cerrar la solicitud + + + Cerrada el + + + Secciones condicionales + + + Tipo de construcción + + + Pérdida de contenido + + + Valor del contenido + + + Cobertura de la correspondencia + + + Cada tipo de aviso local sin código NERIS habrá que clasificarlo a mano durante el servicio. + + + Moneda + + + Grado de daño + + + No se han podido calcular algunos recuentos: + + + Definición + + + Detalle + + + Solicitud de acceso a registros + + + Se ha cerrado la solicitud de acceso. + + + Se ha registrado la solicitud de acceso. + + + Una solicitud de acceso tiene un plazo legal, por eso se registra aquí y no se responde de forma improvisada. + + + Esta solicitud ha superado su plazo legal. + + + Se ha preparado una entrega con datos ocultados. + + + Solicitudes de acceso + + + Se necesita un motivo para cerrar una solicitud de acceso. + + + La entrega se ha remitido a la persona solicitante. + + + Se ha guardado el alcance. + + + Acceso a registros + + + Motivo de la resolución + + + Borradores + + + Servicio en ese momento + + + Editar la correspondencia + + + Editar el análisis del incidente + + + Error + + + Pérdida estimada + + + Valor estimado + + + Evidencias + + + Se ha capturado la evidencia. + + + Certificaciones en el momento del incidente + + + Mensajes del incidente incorporados + + + Cada evidencia es una instantánea acotada, tomada una vez, con suma de verificación y nunca modificada; una corrección es una captura nueva. + + + Material y sustancias controladas utilizados + + + Disponibilidad en el momento del aviso + + + Se necesita un motivo para incorporar evidencia a un registro oficial. + + + Decisión de despacho + + + Fuente + + + Posiciones de las unidades + + + Esa fuente de evidencia no tenía nada que capturar para este registro. + + + Elemento expuesto + + + Elementos expuestos + + + Mortal + + + Al finalizar se escribe la revisión inmutable. El envío espera al identificador NERIS del incidente si aún no lo tiene. + + + Finalizado el + + + Propagación del fuego + + + Bombero + + + Sexo + + + Causa general + + + Análisis del incidente + + + La investigación de incendio y mercancías peligrosas se envía aparte del incidente, una vez que el destino ya lo tiene. + + + Informes incompletos + + + Informes de incidente aún sin finalizar + + + Incluir entradas del registro heredado + + + Incompleto + + + Herido + + + Tipos de investigación + + + Elementos + + + Jurisdicción + + + Tipo + + + Matrícula + + + Código local + + + Registrar una solicitud de acceso + + + Marca + + + Manifiesto + + + asignados + + + coincidencias + + + Modelo + + + Año del modelo + + + Identificador NERIS del análisis + + + Código NERIS + + + No hay solicitudes de acceso registradas. + + + No se ha capturado ninguna evidencia para este informe. + + + Todavía no se ha preparado ninguna entrega. + + + No se aplica ninguna sección condicional hasta elegir un tipo de incidente. + + + Abiertas + + + Abrir el análisis + + + Abrir las solicitudes de acceso + + + Abrir la cola de incidentes + + + Abrir la cola de registros + + + Registros operativos + + + Vencidas + + + Obligaciones fuera de plazo + + + Tipo de persona + + + Miembro + + + Motorización + + + Preparada el + + + En el registro + + + Código principal + + + Preparar entrega + + + La entrega es un documento nuevo e inmutable; una modificación posterior no puede cambiar lo entregado. + + + Entregable + + + La suma de verificación sigue coincidiendo; la entrega no se ha alterado. + + + La suma de verificación no coincide con lo almacenado. + + + Entregas + + + Versión del contrato + + + Inmuebles + + + Cantidad + + + Poner el envío en cola + + + En cola + + + Origen étnico + + + Rango + + + Recibida el + + + Registros + + + Panel de registros + + + El módulo de registros aún no está activado para este departamento. + + + Perfil de ocultación + + + Rechazados + + + Rechazados por el destino y aún sin corregir + + + Entregar + + + Entregada el + + + Solicitud + + + Referencia + + + Persona solicitante + + + Datos de contacto + + + La identidad de quien solicita está restringida y no se muestra aquí. + + + Nombre de la persona solicitante + + + Organización + + + Obligatoria + + + Rescate + + + Altura del rescate + + + Modo de rescate + + + Vía de rescate + + + Tipo de rescate + + + Recurso + + + Recursos + + + solo en el registro del departamento, nunca se envía a NERIS + + + Algunos campos están restringidos y no se le muestran. + + + Devueltos para corrección + + + Guardar el alcance + + + Alcance + + + El alcance está congelado porque ya se ha preparado una entrega a partir de él. + + + Qué se ha solicitado + + + Resultado del alcance + + + Coinciden más registros de los que se listan aquí. + + + Código secundario + + + Batería implicada + + + Sustancia liberada + + + Sistema de extinción de cocina + + + Tubería de gas de acero inoxidable corrugado + + + Riesgo eléctrico + + + Datos del incendio + + + Sistema de alarma de incendios + + + Sistema de extinción + + + Situación peligrosa + + + Análisis de la situación peligrosa + + + Atención al paciente + + + Oxígeno medicinal domiciliario + + + Esta sección ya no corresponde a los tipos de incidente elegidos. + + + Otra alarma + + + Análisis del incendio exterior + + + Ubicación del incendio exterior + + + Riesgo de generación eléctrica + + + Producto implicado + + + Detector de humo + + + Ubicación del incendio en el edificio + + + Origen del incendio en el edificio + + + Enviado + + + Conjunto de valores + + + obsoletos + + + Iniciar el análisis + + + Estados + + + Si se deja en blanco, lo fija el plazo legal del departamento. + + + Plazo legal + + + Plantas sobre rasante + + + Plantas bajo rasante + + + Enviar + + + Enviados + + + Recomendada + + + Sustituida + + + Título + + + Estado de ocupación + + + Vehículos + + + Verificar la suma de comprobación + + + Número de bastidor + + + Ocupado en ese momento + + + Año + + + Año de construcción + + + Otro vehículo + + + Se necesitan credenciales nuevas al cambiar el tipo de concesión NERIS. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index 72b4cf68..43045aad 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -343,4 +343,625 @@ File d'envoi Paramètres NERIS enregistrés. Envoi mis en file pour NERIS. + + Accepté + + + Toutes les définitions + + + Analyses en attente de dépôt + + + Cette analyse est finalisée et sera déposée dès que l'incident lui-même parviendra à NERIS. + + + Sections de l'analyse + + + S'applique + + + Attribué à + + + Voiture + + + En attente de vérification + + + Dossiers soumis à vérification et pas encore approuvés + + + Retour au rapport d'incident + + + Type de carrosserie + + + Appel + + + Capturer + + + Motif de la saisie de cette preuve + + + Saisi par + + + Saisi le + + + Victimes et sauvetages + + + Victime + + + Action au moment des faits + + + Cause de la blessure + + + Moment de survenue + + + Civil + + + Clore la demande + + + Close le + + + Sections conditionnelles + + + Type de construction + + + Perte du contenu + + + Valeur du contenu + + + Couverture de la table de correspondance + + + Chaque type d'appel local sans code NERIS devra être classé à la main le soir même. + + + Devise + + + Niveau de dommage + + + Certains décomptes n'ont pas pu être produits : + + + Définition + + + Détail + + + Demande d'accès aux documents + + + La demande d'accès a été close. + + + La demande d'accès a été enregistrée. + + + Une demande d'accès est soumise à un délai légal ; elle est donc suivie ici plutôt que traitée au cas par cas. + + + Cette demande a dépassé son délai légal. + + + Un dossier expurgé a été préparé. + + + Demandes d'accès + + + Un motif est requis pour clore une demande d'accès. + + + Le dossier a été remis au demandeur. + + + Le périmètre a été enregistré. + + + Accès aux documents + + + Motif de la décision + + + Brouillons + + + Service au moment des faits + + + Modifier la table de correspondance + + + Modifier l'analyse de l'incident + + + Erreur + + + Perte estimée + + + Valeur estimée + + + Preuves + + + La preuve a été saisie. + + + Qualifications au moment de l'intervention + + + Messages d'incident versés au dossier + + + Chaque preuve est un instantané borné, pris une seule fois, avec somme de contrôle et jamais modifié ; une correction est une nouvelle saisie. + + + Consommables et stupéfiants utilisés + + + Disponibilité au moment de l'appel + + + Un motif est requis pour verser une preuve à un dossier officiel. + + + Décision d'engagement + + + Source + + + Positions des engins + + + Cette source de preuve n'avait rien à saisir pour ce dossier. + + + Élément exposé + + + Éléments exposés + + + Mortelle + + + La finalisation écrit la révision immuable. Le dépôt attend l'identifiant NERIS de l'incident s'il n'en a pas encore. + + + Finalisé le + + + Propagation du feu + + + Sapeur-pompier + + + Genre + + + Cause générale + + + Analyse de l'incident + + + L'enquête incendie et matières dangereuses est déposée séparément de l'incident, une fois que le destinataire le détient déjà. + + + Rapports incomplets + + + Rapports d'incident pas encore finalisés + + + Inclure les entrées de journal héritées + + + Incomplet + + + Blessé + + + Types d'enquête + + + Éléments + + + Juridiction + + + Nature + + + Plaque d'immatriculation + + + Code local + + + Enregistrer une demande d'accès + + + Marque + + + Manifeste + + + correspondants + + + correspondances + + + Modèle + + + Année-modèle + + + Identifiant NERIS de l'analyse + + + Code NERIS + + + Aucune demande d'accès n'est enregistrée. + + + Aucune preuve n'a été saisie pour ce rapport. + + + Rien n'a encore été produit. + + + Aucune section conditionnelle ne s'applique tant qu'un type d'incident n'est pas choisi. + + + Ouvertes + + + Ouvrir l'analyse + + + Ouvrir les demandes d'accès + + + Ouvrir la file des incidents + + + Ouvrir la file des dossiers + + + Dossiers opérationnels + + + En retard + + + Obligations dont l'échéance est dépassée + + + Type de personne + + + Membre + + + Motorisation + + + Préparé le + + + Au dossier + + + Code principal + + + Produire + + + Un dossier produit est un nouvel artefact immuable ; une modification ultérieure ne peut pas changer ce qui a été remis. + + + Communicable + + + La somme de contrôle correspond toujours ; le dossier n'a pas été modifié. + + + La somme de contrôle ne correspond pas à ce qui a été enregistré. + + + Dossiers produits + + + Version du contrat + + + Biens + + + Quantité + + + Mettre le dépôt en file + + + En file + + + Origine ethnique + + + Grade + + + Reçue le + + + Dossiers + + + Tableau de bord des dossiers + + + Le module dossiers n'est pas encore activé pour ce service. + + + Profil d'expurgation + + + Rejetés + + + Rejetés par le destinataire et pas encore corrigés + + + Remettre + + + Remise le + + + Demande + + + Référence + + + Demandeur + + + Coordonnées + + + L'identité du demandeur est protégée et n'est pas affichée ici. + + + Nom du demandeur + + + Organisme + + + Obligatoire + + + Sauvetage + + + Niveau du sauvetage + + + Mode de sauvetage + + + Voie du sauvetage + + + Type de sauvetage + + + Ressource + + + Ressources + + + conservé au dossier du service seulement, jamais transmis à NERIS + + + Certains champs sont protégés et ne vous sont pas montrés. + + + Retournés pour correction + + + Enregistrer le périmètre + + + Périmètre + + + Le périmètre est figé car un dossier en a déjà été produit. + + + Objet de la demande + + + Résultat du périmètre + + + Davantage de dossiers correspondent que ceux listés ici. + + + Code secondaire + + + Batterie impliquée + + + Produit chimique libéré + + + Système d'extinction de cuisine + + + Tuyauterie gaz inox annelée + + + Risque électrique + + + Détails de l'incendie + + + Système d'alarme incendie + + + Système d'extinction + + + Situation dangereuse + + + Analyse de la situation dangereuse + + + Prise en charge du patient + + + Oxygène médical à domicile + + + Cette section ne s'applique plus aux types d'incident retenus. + + + Autre système d'alarme + + + Analyse de l'incendie extérieur + + + Lieu de l'incendie extérieur + + + Risque lié à la production d'électricité + + + Produit impliqué + + + Détecteur de fumée + + + Lieu de l'incendie de bâtiment + + + Origine de l'incendie de bâtiment + + + Envoyé + + + Jeu de valeurs + + + obsolètes + + + Démarrer l'analyse + + + États + + + Laissé vide, le délai légal du service le détermine. + + + Échéance légale + + + Étages hors sol + + + Étages en sous-sol + + + Déposer + + + Déposés + + + Recommandée + + + Remplacée + + + Intitulé + + + État d'occupation + + + Véhicules + + + Vérifier la somme de contrôle + + + Numéro de série (VIN) + + + Occupé au moment des faits + + + Année + + + Année de construction + + + Autre véhicule + + + De nouvelles informations d'identification sont requises lorsque le type d'octroi NERIS change. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index 36704af2..b805460c 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -343,4 +343,625 @@ Coda di invio Impostazioni NERIS salvate. Invio in coda per NERIS. + + Accettato + + + Tutte le definizioni + + + Analisi in attesa di invio + + + Questa analisi è finalizzata e verrà inviata non appena l'intervento stesso raggiungerà NERIS. + + + Sezioni dell'analisi + + + Si applica + + + Assegnato a + + + Autovettura + + + In attesa di revisione + + + Documenti inviati in revisione e non ancora approvati + + + Torna al rapporto d'intervento + + + Tipo di carrozzeria + + + Chiamata + + + Acquisisci + + + Motivo dell'acquisizione di questa prova + + + Acquisito da + + + Acquisito il + + + Feriti e salvataggi + + + Ferito + + + Attività al momento + + + Causa del ferimento + + + Momento in cui è avvenuto + + + Civile + + + Chiudi la richiesta + + + Chiusa il + + + Sezioni condizionali + + + Tipo di costruzione + + + Danno al contenuto + + + Valore del contenuto + + + Copertura della corrispondenza + + + Ogni tipo di chiamata locale senza codice NERIS dovrà essere classificato a mano durante l'intervento. + + + Valuta + + + Grado di danno + + + Alcuni conteggi non sono stati calcolati: + + + Definizione + + + Dettaglio + + + Richiesta di accesso agli atti + + + La richiesta di accesso è stata chiusa. + + + La richiesta di accesso è stata registrata. + + + Una richiesta di accesso ha un termine di legge, perciò viene tracciata qui e non evasa in modo estemporaneo. + + + Questa richiesta ha superato il termine di legge. + + + È stata preparata una versione con omissis. + + + Richieste di accesso + + + Per chiudere una richiesta di accesso è necessaria una motivazione. + + + Il materiale è stato rilasciato al richiedente. + + + L'ambito è stato salvato. + + + Accesso agli atti + + + Motivazione dell'esito + + + Bozze + + + Servizio al momento + + + Modifica la corrispondenza + + + Modifica l'analisi dell'intervento + + + Errore + + + Danno stimato + + + Valore stimato + + + Prove + + + La prova è stata acquisita. + + + Qualifiche al momento dell'intervento + + + Messaggi dell'intervento acquisiti + + + Ogni prova è un'istantanea circoscritta, acquisita una sola volta, con checksum e mai modificata; una correzione è una nuova acquisizione. + + + Materiali e sostanze controllate utilizzati + + + Prontezza al momento della chiamata + + + Per inserire una prova in un atto ufficiale è necessaria una motivazione. + + + Decisione di invio + + + Fonte + + + Posizioni delle squadre + + + Quella fonte di prova non aveva nulla da acquisire per questo documento. + + + Elemento esposto + + + Elementi esposti + + + Mortale + + + La finalizzazione scrive la revisione immutabile. L'invio attende l'identificativo NERIS dell'intervento se non è ancora presente. + + + Finalizzato il + + + Propagazione dell'incendio + + + Vigile del fuoco + + + Genere + + + Causa generale + + + Analisi dell'intervento + + + L'indagine su incendio e materiali pericolosi viene inviata separatamente dall'intervento, una volta che il destinatario lo possiede già. + + + Rapporti incompleti + + + Rapporti d'intervento non ancora finalizzati + + + Includi le voci del registro storico + + + Incompleto + + + Ferito + + + Tipi di indagine + + + Elementi + + + Giurisdizione + + + Tipologia + + + Targa + + + Codice locale + + + Registra una richiesta di accesso + + + Marca + + + Manifesto + + + corrispondenti + + + corrispondenze + + + Modello + + + Anno del modello + + + Identificativo NERIS dell'analisi + + + Codice NERIS + + + Non risultano richieste di accesso registrate. + + + Non è stata acquisita alcuna prova per questo rapporto. + + + Non è stato ancora prodotto nulla. + + + Nessuna sezione condizionale si applica finché non viene scelto un tipo di intervento. + + + Aperte + + + Apri l'analisi + + + Apri le richieste di accesso + + + Apri la coda degli interventi + + + Apri la coda dei documenti + + + Documenti operativi + + + In ritardo + + + Obblighi oltre la scadenza + + + Tipo di persona + + + Membro + + + Motorizzazione + + + Preparato il + + + Presente nel documento + + + Codice principale + + + Produci + + + Il materiale prodotto è un nuovo documento immutabile; una modifica successiva non può cambiare ciò che è stato rilasciato. + + + Rilasciabile + + + Il checksum corrisponde ancora; il materiale non è stato alterato. + + + Il checksum non corrisponde a quanto memorizzato. + + + Materiali prodotti + + + Versione del contratto + + + Immobili + + + Quantità + + + Metti in coda l'invio + + + In coda + + + Etnia + + + Grado + + + Ricevuta il + + + Documenti + + + Cruscotto dei documenti + + + Il modulo documenti non è ancora attivato per questo comando. + + + Profilo di omissis + + + Respinti + + + Respinti dal destinatario e non ancora corretti + + + Rilascia + + + Rilasciata il + + + Richiesta + + + Riferimento + + + Richiedente + + + Recapiti + + + L'identità del richiedente è riservata e non viene mostrata qui. + + + Nome del richiedente + + + Organizzazione + + + Obbligatoria + + + Salvataggio + + + Quota del salvataggio + + + Modalità di salvataggio + + + Percorso del salvataggio + + + Tipo di salvataggio + + + Risorsa + + + Risorse + + + conservate solo nel documento del comando, mai inviate a NERIS + + + Alcuni campi sono riservati e non le vengono mostrati. + + + Restituiti per correzione + + + Salva l'ambito + + + Ambito + + + L'ambito è congelato perché è già stato prodotto del materiale su di esso. + + + Oggetto della richiesta + + + Risultato dell'ambito + + + Corrispondono più documenti di quelli elencati qui. + + + Codice secondario + + + Batteria coinvolta + + + Sostanza rilasciata + + + Impianto di spegnimento in cucina + + + Tubazione gas in acciaio inox corrugato + + + Rischio elettrico + + + Dettagli dell'incendio + + + Impianto di allarme antincendio + + + Impianto di spegnimento + + + Situazione pericolosa + + + Analisi della situazione pericolosa + + + Assistenza al paziente + + + Ossigeno medicale domiciliare + + + Questa sezione non si applica più ai tipi di intervento scelti. + + + Altro allarme + + + Analisi dell'incendio all'aperto + + + Luogo dell'incendio all'aperto + + + Rischio da generazione elettrica + + + Prodotto coinvolto + + + Rilevatore di fumo + + + Luogo dell'incendio in edificio + + + Origine dell'incendio in edificio + + + Inviato + + + Insieme di valori + + + obsoleti + + + Avvia l'analisi + + + Stati + + + Se lasciato vuoto, lo imposta il termine di legge del comando. + + + Termine di legge + + + Piani fuori terra + + + Piani interrati + + + Invia + + + Inviati + + + Consigliata + + + Sostituita + + + Titolo + + + Stato di occupazione + + + Veicoli + + + Verifica il checksum + + + Numero di telaio + + + Occupato al momento + + + Anno + + + Anno di costruzione + + + Altro veicolo + + + Quando cambia il tipo di concessione NERIS sono necessarie nuove credenziali. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index b4300514..5d6ca794 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -343,4 +343,625 @@ Kolejka wysyłek Zapisano ustawienia NERIS. Wysyłka do NERIS w kolejce. + + Zaakceptowano + + + Wszystkie definicje + + + Analizy oczekujące na złożenie + + + Ta analiza jest sfinalizowana i zostanie złożona, gdy samo zdarzenie trafi do NERIS. + + + Sekcje analizy + + + Dotyczy + + + Przypisano do + + + Samochód osobowy + + + Oczekuje na przegląd + + + Dokumenty przekazane do przeglądu i jeszcze niezatwierdzone + + + Powrót do raportu zdarzenia + + + Typ nadwozia + + + Zgłoszenie + + + Zarejestruj + + + Powód zarejestrowania tego dowodu + + + Zarejestrował + + + Zarejestrowano + + + Poszkodowani i ratowani + + + Poszkodowany + + + Czynność w chwili zdarzenia + + + Przyczyna obrażeń + + + Moment wystąpienia + + + Osoba cywilna + + + Zamknij wniosek + + + Zamknięto + + + Sekcje warunkowe + + + Rodzaj konstrukcji + + + Strata zawartości + + + Wartość zawartości + + + Pokrycie mapowania kodów + + + Każdy lokalny typ zgłoszenia bez kodu NERIS trzeba będzie sklasyfikować ręcznie podczas akcji. + + + Waluta + + + Stopień zniszczenia + + + Nie udało się obliczyć niektórych liczników: + + + Definicja + + + Szczegół + + + Wniosek o udostępnienie akt + + + Wniosek o udostępnienie akt został zamknięty. + + + Wniosek o udostępnienie akt został zarejestrowany. + + + Wniosek o udostępnienie akt ma ustawowy termin, dlatego jest tu śledzony, a nie załatwiany doraźnie. + + + Ten wniosek przekroczył ustawowy termin. + + + Przygotowano wersję z utajnionymi danymi. + + + Wnioski o udostępnienie akt + + + Do zamknięcia wniosku wymagane jest uzasadnienie. + + + Materiały przekazano wnioskodawcy. + + + Zakres został zapisany. + + + Udostępnianie akt + + + Uzasadnienie rozstrzygnięcia + + + Wersje robocze + + + Rodzaj służby + + + Edytuj mapowanie kodów + + + Edytuj analizę zdarzenia + + + Błąd + + + Szacowana strata + + + Szacowana wartość + + + Materiał dowodowy + + + Dowód został zarejestrowany. + + + Uprawnienia w chwili zdarzenia + + + Przeniesione wiadomości ze zdarzenia + + + Każdy dowód to ograniczona migawka wykonana raz, z sumą kontrolną i nigdy niezmieniana; poprawka to nowa rejestracja. + + + Zużyte materiały i środki kontrolowane + + + Gotowość w chwili zgłoszenia + + + Dołączenie dowodu do dokumentu urzędowego wymaga uzasadnienia. + + + Decyzja o zadysponowaniu + + + Źródło + + + Pozycje jednostek + + + To źródło dowodów nie miało nic do zarejestrowania dla tego dokumentu. + + + Obiekt narażony + + + Obiekty narażone + + + Śmiertelne + + + Sfinalizowanie zapisuje niezmienną wersję. Złożenie czeka na identyfikator NERIS zdarzenia, jeśli jeszcze go nie ma. + + + Sfinalizowano + + + Rozprzestrzenienie ognia + + + Strażak + + + Płeć + + + Przyczyna ogólna + + + Analiza zdarzenia + + + Dochodzenie pożarowe i ratownictwa chemicznego składa się osobno od zdarzenia, gdy odbiorca już je posiada. + + + Niekompletne raporty + + + Raporty zdarzeń jeszcze niesfinalizowane + + + Uwzględnij wpisy z dawnego dziennika + + + Niekompletne + + + Ranny + + + Rodzaje dochodzenia + + + Pozycje + + + Jurysdykcja + + + Rodzaj + + + Numer rejestracyjny + + + Kod lokalny + + + Zarejestruj wniosek o akta + + + Marka + + + Wykaz + + + zmapowane + + + dopasowane + + + Model + + + Rocznik + + + Identyfikator analizy NERIS + + + Kod NERIS + + + Nie zarejestrowano żadnych wniosków o akta. + + + Do tego raportu nie zarejestrowano żadnych dowodów. + + + Nic jeszcze nie przygotowano. + + + Żadna sekcja warunkowa nie obowiązuje, dopóki nie wybrano typu zdarzenia. + + + Otwarte + + + Otwórz analizę + + + Otwórz wnioski o akta + + + Otwórz kolejkę zdarzeń + + + Otwórz kolejkę dokumentów + + + Dokumentacja operacyjna + + + Po terminie + + + Obowiązki po terminie + + + Rodzaj osoby + + + Członek + + + Napęd + + + Przygotowano + + + W dokumencie + + + Kod główny + + + Przygotuj materiały + + + Przygotowane materiały to nowy, niezmienny dokument; późniejsza zmiana nie zmieni tego, co wydano. + + + Do wydania + + + Suma kontrolna nadal się zgadza; materiały nie zostały zmienione. + + + Suma kontrolna nie zgadza się z zapisaną. + + + Wydane materiały + + + Wersja kontraktu + + + Nieruchomości + + + Ilość + + + Zakolejkuj złożenie + + + W kolejce + + + Pochodzenie etniczne + + + Stopień + + + Wpłynął + + + Dokumenty + + + Pulpit dokumentacji + + + Moduł dokumentacji nie jest jeszcze aktywny dla tej jednostki. + + + Profil utajniania + + + Odrzucone + + + Odrzucone przez odbiorcę i jeszcze niepoprawione + + + Wydaj + + + Wydano + + + Wniosek + + + Sygnatura + + + Wnioskodawca + + + Dane kontaktowe + + + Tożsamość wnioskodawcy jest chroniona i nie jest tu pokazywana. + + + Imię i nazwisko wnioskodawcy + + + Organizacja + + + Wymagana + + + Ratowanie + + + Poziom ratowania + + + Sposób ratowania + + + Droga ratowania + + + Rodzaj ratowania + + + Zasób + + + Zasoby + + + przechowywane tylko w dokumentacji jednostki, nigdy nie wysyłane do NERIS + + + Niektóre pola są chronione i nie są tu pokazywane. + + + Zwrócone do poprawy + + + Zapisz zakres + + + Zakres + + + Zakres jest zamrożony, bo przygotowano już na jego podstawie materiały. + + + Czego dotyczy wniosek + + + Wynik zakresu + + + Pasuje więcej dokumentów, niż tu wymieniono. + + + Kod dodatkowy + + + Akumulator, którego dotyczy zdarzenie + + + Uwolniona substancja + + + System gaśniczy w kuchni + + + Karbowana rura gazowa ze stali nierdzewnej + + + Zagrożenie elektryczne + + + Dane pożaru + + + System sygnalizacji pożarowej + + + System gaśniczy + + + Sytuacja niebezpieczna + + + Analiza sytuacji niebezpiecznej + + + Opieka nad pacjentem + + + Domowy tlen medyczny + + + Ta sekcja nie dotyczy już wybranych typów zdarzenia. + + + Inny alarm + + + Analiza pożaru na zewnątrz + + + Miejsce pożaru na zewnątrz + + + Zagrożenie od wytwarzania energii + + + Wyrób, którego dotyczy zdarzenie + + + Czujka dymu + + + Miejsce pożaru w budynku + + + Miejsce powstania pożaru budynku + + + Wysłano + + + Zbiór wartości + + + nieaktualne + + + Rozpocznij analizę + + + Stany + + + Pozostawione puste, ustala je ustawowy termin jednostki. + + + Termin ustawowy + + + Kondygnacje nadziemne + + + Kondygnacje podziemne + + + Złóż + + + Złożone + + + Zalecana + + + Zastąpiony + + + Tytuł + + + Stan użytkowania + + + Pojazdy + + + Sprawdź sumę kontrolną + + + Numer VIN nadwozia + + + Zajęty w chwili zdarzenia + + + Rok + + + Rok budowy + + + Inny pojazd + + + Zmiana typu uprawnienia NERIS wymaga podania nowych poświadczeń. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index c3ecbd1c..a24a9126 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -343,4 +343,625 @@ Inlämningskö NERIS-inställningar sparade. Inlämning köad för NERIS. + + Accepterad + + + Alla definitioner + + + Analyser som väntar på inlämning + + + Analysen är slutförd och skickas så snart själva händelsen når NERIS. + + + Analysavsnitt + + + Gäller + + + Tilldelad till + + + Personbil + + + Väntar på granskning + + + Poster som skickats för granskning och ännu inte godkänts + + + Tillbaka till händelserapporten + + + Karosserityp + + + Larm + + + Fånga + + + Skäl till att detta underlag fångas + + + Fångad av + + + Fångad + + + Skadade och räddade + + + Skadad + + + Aktivitet vid tillfället + + + Skadeorsak + + + När det inträffade + + + Civilperson + + + Avsluta begäran + + + Avslutad + + + Villkorade avsnitt + + + Byggnadstyp + + + Skada på lösöre + + + Värde på lösöre + + + Täckning för kodmappning + + + Varje lokal larmtyp utan NERIS-kod måste klassificeras för hand under insatsen. + + + Valuta + + + Skadegrad + + + Vissa värden kunde inte tas fram: + + + Definition + + + Detalj + + + Begäran om allmän handling + + + Begäran om allmän handling har avslutats. + + + Begäran om allmän handling har registrerats. + + + En begäran om allmän handling har en lagstadgad frist och följs därför upp här i stället för att besvaras ad hoc. + + + Denna begäran har passerat sin lagstadgade frist. + + + En maskad leverans har tagits fram. + + + Begäranden om allmän handling + + + Ett skäl krävs för att avsluta en begäran. + + + Leveransen har lämnats ut till den som begärt den. + + + Omfattningen har sparats. + + + Allmänna handlingar + + + Skäl till beslutet + + + Utkast + + + Tjänstgöring vid tillfället + + + Redigera kodmappningen + + + Redigera händelseanalysen + + + Fel + + + Uppskattad skada + + + Uppskattat värde + + + Underlag + + + Underlaget har fångats. + + + Behörigheter vid händelsetillfället + + + Överförda händelsemeddelanden + + + Varje underlag är en avgränsad ögonblicksbild som tas en gång, får en kontrollsumma och aldrig ändras; en rättelse är en ny fångst. + + + Förbrukat material och narkotikaklassade preparat + + + Beredskap vid larmtillfället + + + Ett skäl krävs för att föra in underlag i en officiell handling. + + + Utlarmningsbeslut + + + Källa + + + Enheternas positioner + + + Den underlagskällan hade inget att fånga för denna post. + + + Utsatt objekt + + + Utsatta objekt + + + Dödlig + + + Slutförandet skriver den oföränderliga versionen. Inlämningen väntar på händelsens NERIS-id om det saknas. + + + Slutförd + + + Brandspridning + + + Brandman + + + Kön + + + Allmän orsak + + + Händelseanalys + + + Brand- och farligt gods-utredningen lämnas in separat från händelsen, när mottagaren redan har den. + + + Ofullständiga rapporter + + + Händelserapporter som ännu inte slutförts + + + Inkludera äldre loggposter + + + Ofullständig + + + Skadad + + + Utredningstyper + + + Poster + + + Jurisdiktion + + + Slag + + + Registreringsnummer + + + Lokal kod + + + Registrera en begäran + + + Märke + + + Innehållsförteckning + + + mappade + + + träffar + + + Modell + + + Årsmodell + + + NERIS analys-id + + + NERIS-kod + + + Inga begäranden är registrerade. + + + Inget underlag har fångats för denna rapport. + + + Inget har tagits fram ännu. + + + Inga villkorade avsnitt gäller förrän en händelsetyp valts. + + + Öppna + + + Öppna analysen + + + Öppna begärandena + + + Öppna händelsekön + + + Öppna postkön + + + Operativa poster + + + Försenad + + + Skyldigheter som passerat sitt datum + + + Persontyp + + + Medlem + + + Drivlina + + + Framtagen + + + I posten + + + Primär kod + + + Ta fram leverans + + + En leverans är en ny oföränderlig handling; en senare ändring kan inte påverka det som lämnats ut. + + + Kan lämnas ut + + + Kontrollsumman stämmer fortfarande; leveransen har inte ändrats. + + + Kontrollsumman stämmer inte med det lagrade värdet. + + + Leveranser + + + Kontraktsversion + + + Fastigheter + + + Mängd + + + Köa inlämningen + + + Köad + + + Etnicitet + + + Grad + + + Mottagen + + + Poster + + + Översikt för poster + + + Postmodulen är ännu inte aktiverad för denna kår. + + + Maskeringsprofil + + + Avvisade + + + Avvisade av mottagaren och ännu inte rättade + + + Lämna ut + + + Utlämnad + + + Begäran + + + Referens + + + Sökande + + + Kontaktuppgifter + + + Vem som begärt uppgifterna är skyddat och visas inte här. + + + Sökandens namn + + + Organisation + + + Obligatorisk + + + Räddning + + + Räddningens nivå + + + Räddningssätt + + + Räddningsväg + + + Räddningstyp + + + Resurs + + + Resurser + + + sparas endast i kårens post, skickas aldrig till NERIS + + + Vissa fält är skyddade och visas inte för dig. + + + Återsända för rättelse + + + Spara omfattningen + + + Omfattning + + + Omfattningen är låst eftersom en leverans redan tagits fram utifrån den. + + + Vad som begärts + + + Vad omfattningen ger + + + Fler poster matchar än de som listas här. + + + Sekundär kod + + + Inblandat batteri + + + Utsläppt kemikalie + + + Släcksystem för kök + + + Korrugerad rostfri gasslang + + + Elrisk + + + Branduppgifter + + + Brandlarmsystem + + + Släcksystem + + + Farlig situation + + + Analys av farlig situation + + + Patientvård + + + Medicinsk syrgas i hemmet + + + Detta avsnitt gäller inte längre för de valda händelsetyperna. + + + Annat larm + + + Analys av utomhusbrand + + + Brandplats utomhus + + + Risk från elproduktion + + + Inblandad produkt + + + Brandvarnare + + + Brandplats i byggnad + + + Startpunkt för byggnadsbranden + + + Skickad + + + Värdemängd + + + föråldrade + + + Starta analysen + + + Statusar + + + Lämnas det tomt sätts det av kårens lagstadgade frist. + + + Lagstadgad frist + + + Våningar ovan mark + + + Våningar under mark + + + Lämna in + + + Inlämnade + + + Rekommenderad + + + Ersatt + + + Rubrik + + + Nyttjandestatus + + + Fordon + + + Verifiera kontrollsumman + + + Chassinummer + + + Upptaget vid tillfället + + + År + + + Byggår + + + Annat fordon + + + Nya inloggningsuppgifter krävs när NERIS-behörighetstypen ändras. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index 375ce00d..49a0186a 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -343,4 +343,625 @@ Черга подань Налаштування NERIS збережено. Подання поставлено в чергу для NERIS. + + Прийнято + + + Усі визначення + + + Аналізи, що очікують подання + + + Цей аналіз завершено; його буде подано, щойно сам виклик потрапить до NERIS. + + + Розділи аналізу + + + Застосовується + + + Призначено + + + Легковий автомобіль + + + Очікує на перевірку + + + Записи, подані на перевірку й ще не затверджені + + + Назад до звіту про виклик + + + Тип кузова + + + Виклик + + + Зафіксувати + + + Причина фіксації цього доказу + + + Зафіксував + + + Зафіксовано + + + Постраждалі та врятовані + + + Постраждалий + + + Дія на той момент + + + Причина травми + + + Коли це сталося + + + Цивільна особа + + + Закрити запит + + + Закрито + + + Умовні розділи + + + Тип конструкції + + + Втрати вмісту + + + Вартість вмісту + + + Покриття зіставлення кодів + + + Кожен місцевий тип виклику без коду NERIS доведеться класифікувати вручну під час події. + + + Валюта + + + Ступінь пошкодження + + + Деякі підрахунки не вдалося виконати: + + + Визначення + + + Подробиця + + + Запит на доступ до документів + + + Запит на доступ до документів закрито. + + + Запит на доступ до документів зареєстровано. + + + Запит на доступ до документів має встановлений законом строк, тому його відстежують тут, а не опрацьовують принагідно. + + + Цей запит прострочив встановлений законом строк. + + + Підготовлено відредаговану добірку. + + + Запити на доступ до документів + + + Щоб закрити запит, потрібно вказати причину. + + + Добірку передано заявникові. + + + Обсяг збережено. + + + Доступ до документів + + + Підстава рішення + + + Чернетки + + + Вид служби на той момент + + + Редагувати зіставлення кодів + + + Редагувати аналіз виклику + + + Помилка + + + Оцінені збитки + + + Оцінена вартість + + + Докази + + + Доказ зафіксовано. + + + Кваліфікації на час виклику + + + Долучені повідомлення з виклику + + + Кожен доказ — обмежений знімок, зроблений один раз, з контрольною сумою і без змін; виправлення — це нова фіксація. + + + Використані матеріали та підконтрольні речовини + + + Готовність на момент виклику + + + Щоб долучити доказ до офіційного запису, потрібно вказати причину. + + + Рішення про висилання + + + Джерело + + + Координати підрозділів + + + Це джерело доказів не мало чого зафіксувати для цього запису. + + + Об'єкт впливу + + + Об'єкти впливу + + + Смертельний + + + Завершення записує незмінну версію. Подання чекає на ідентифікатор NERIS виклику, якщо його ще немає. + + + Завершено + + + Поширення вогню + + + Пожежний + + + Стать + + + Загальна причина + + + Аналіз виклику + + + Розслідування пожежі та небезпечних речовин подають окремо від виклику, коли отримувач уже має його. + + + Незавершені звіти + + + Звіти про виклики, ще не завершені + + + Включати застарілі записи журналу + + + Незавершені + + + Травмовано + + + Види розслідування + + + Записи + + + Юрисдикція + + + Вид + + + Номерний знак + + + Локальний код + + + Зареєструвати запит + + + Марка + + + Опис вмісту + + + зіставлено + + + знайдено + + + Модель + + + Модельний рік + + + Ідентифікатор аналізу NERIS + + + Код NERIS + + + Запитів на доступ не зареєстровано. + + + До цього звіту не зафіксовано жодних доказів. + + + Ще нічого не підготовлено. + + + Умовні розділи не застосовуються, доки не обрано тип виклику. + + + Відкриті + + + Відкрити аналіз + + + Відкрити запити на доступ + + + Відкрити чергу викликів + + + Відкрити чергу записів + + + Оперативні записи + + + Прострочені + + + Обов'язки, строк яких минув + + + Тип особи + + + Член підрозділу + + + Силова установка + + + Підготовлено + + + У записі + + + Основний код + + + Підготувати добірку + + + Добірка — це новий незмінний документ; пізніші зміни не змінять того, що було видано. + + + Можна видати + + + Контрольна сума збігається; добірку не змінювали. + + + Контрольна сума не збігається зі збереженою. + + + Підготовлені добірки + + + Версія контракту + + + Об'єкти нерухомості + + + Кількість + + + Поставити подання в чергу + + + У черзі + + + Етнічна належність + + + Звання + + + Отримано + + + Записи + + + Панель записів + + + Модуль записів ще не активовано для цього підрозділу. + + + Профіль редагування + + + Відхилені + + + Відхилені отримувачем і ще не виправлені + + + Видати + + + Видано + + + Запит + + + Реєстраційний номер + + + Заявник + + + Контактні дані + + + Особу заявника захищено, і тут вона не показується. + + + Ім'я заявника + + + Організація + + + Обов'язковий + + + Рятування + + + Рівень рятування + + + Спосіб рятування + + + Шлях рятування + + + Тип рятування + + + Ресурс + + + Ресурси + + + зберігається лише в записі підрозділу, ніколи не надсилається до NERIS + + + Деякі поля обмежені й вам не показуються. + + + Повернуті на виправлення + + + Зберегти обсяг + + + Обсяг + + + Обсяг зафіксовано, бо на його основі вже підготовлено добірку. + + + Що саме запитано + + + Що охоплює обсяг + + + Відповідає більше записів, ніж перелічено тут. + + + Додатковий код + + + Задіяна батарея + + + Викинута хімічна речовина + + + Кухонна система пожежогасіння + + + Гофрована газова труба з нержавіючої сталі + + + Електрична небезпека + + + Дані про пожежу + + + Система пожежної сигналізації + + + Система пожежогасіння + + + Небезпечна ситуація + + + Аналіз небезпечної ситуації + + + Допомога пацієнту + + + Домашній медичний кисень + + + Цей розділ більше не стосується обраних типів виклику. + + + Інша сигналізація + + + Аналіз пожежі на відкритій місцевості + + + Місце пожежі на відкритій місцевості + + + Небезпека від генерації електроенергії + + + Задіяний виріб + + + Димовий сповіщувач + + + Місце пожежі в будівлі + + + Осередок пожежі в будівлі + + + Надіслано + + + Набір значень + + + застарілі + + + Розпочати аналіз + + + Статуси + + + Якщо залишити порожнім, дату визначить встановлений законом строк підрозділу. + + + Встановлений законом строк + + + Поверхів над рівнем землі + + + Поверхів нижче рівня землі + + + Подати + + + Подані + + + Рекомендований + + + Замінено + + + Назва + + + Стан заселеності + + + Транспортні засоби + + + Перевірити контрольну суму + + + Ідентифікаційний номер кузова + + + Був зайнятий на той момент + + + Рік + + + Рік побудови + + + Інший транспортний засіб + + + Після зміни типу надання доступу NERIS потрібні нові облікові дані. + diff --git a/Core/Resgrid.Model/Providers/INerisProviders.cs b/Core/Resgrid.Model/Providers/INerisProviders.cs index 5f0ffdc6..baf1422e 100644 --- a/Core/Resgrid.Model/Providers/INerisProviders.cs +++ b/Core/Resgrid.Model/Providers/INerisProviders.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -43,6 +43,9 @@ public interface INerisMappingService { /// The exact IncidentPayload JSON the destination receives (the immutable submission artifact). string BuildIncidentPayloadJson(NerisIncidentSnapshot snapshot, RmsNerisProfile profile); + + /// The exact IncidentAnalysisPayload JSON for the separate fire/hazmat analysis filing (RMS-3). + string BuildIncidentAnalysisPayloadJson(NerisIncidentAnalysisSnapshot snapshot, RmsNerisProfile profile); } /// Local (offline) validation against the pinned contract's requiredness, value sets and time sequence; remote validation through the client. @@ -50,6 +53,15 @@ public interface INerisValidationService { List ValidateLocal(NerisIncidentSnapshot snapshot, RmsNerisProfile profile); + /// Local validation of the separate incident-analysis filing (RMS-3); never blocks the incident. + List ValidateAnalysisLocal(NerisIncidentAnalysisSnapshot snapshot, RmsNerisProfile profile); + + /// + /// The conditional sections a set of incident types demands or suggests (RMS-3 progressive section rules). + /// Exposed so the authoring surfaces render exactly what validation will enforce. + /// + IReadOnlyList GetSectionRequirements(IEnumerable incidentTypeCodes); + Task> ValidateRemoteAsync(RmsNerisProfile profile, string payloadJson, CancellationToken cancellationToken = default); } @@ -63,6 +75,13 @@ public interface INerisApiClient Task UpdateIncidentAsync(RmsNerisProfile profile, NerisCredential credential, string nerisIncidentId, string payloadJson, CancellationToken cancellationToken = default); Task GetStatusAsync(RmsNerisProfile profile, NerisCredential credential, string nerisIncidentId, CancellationToken cancellationToken = default); + + // Incident analysis (RMS-3): a second filing against an incident the destination already holds. + Task CreateIncidentAnalysisAsync(RmsNerisProfile profile, NerisCredential credential, string nerisIncidentId, string payloadJson, CancellationToken cancellationToken = default); + + Task UpdateIncidentAnalysisAsync(RmsNerisProfile profile, NerisCredential credential, string nerisAnalysisId, string payloadJson, CancellationToken cancellationToken = default); + + Task GetIncidentAnalysisStatusAsync(RmsNerisProfile profile, NerisCredential credential, string nerisAnalysisId, CancellationToken cancellationToken = default); } /// @@ -75,5 +94,14 @@ public interface INerisSubmissionService Task DeliverAsync(RmsNerisProfile profile, RmsSubmission submission, string existingNerisIncidentId, CancellationToken cancellationToken = default); Task CheckStatusAsync(RmsNerisProfile profile, string nerisIncidentId, CancellationToken cancellationToken = default); + + /// + /// Delivers the incident-analysis filing (RMS-3). is the incident the + /// analysis files against and is required for the first delivery; + /// switches the call to an update once the destination holds the analysis. + /// + Task DeliverAnalysisAsync(RmsNerisProfile profile, RmsSubmission submission, string nerisIncidentId, string existingNerisAnalysisId, CancellationToken cancellationToken = default); + + Task CheckAnalysisStatusAsync(RmsNerisProfile profile, string nerisAnalysisId, CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Records/IncidentReportContracts.cs b/Core/Resgrid.Model/Records/IncidentReportContracts.cs index cffe2943..f8aa8c33 100644 --- a/Core/Resgrid.Model/Records/IncidentReportContracts.cs +++ b/Core/Resgrid.Model/Records/IncidentReportContracts.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace Resgrid.Model @@ -14,6 +14,14 @@ public class IncidentReportAggregate public List Tactics { get; set; } = new List(); public RmsNarrative Narrative { get; set; } public List Facts { get; set; } = new List(); + /// RMS-3 conditional sections (fire, hazsit, alarms, suppression, emerging hazards, medical). + public List Modules { get; set; } = new List(); + /// RMS-3 non-unit resources used on the incident. + public List Resources { get; set; } = new List(); + /// RMS-3 restricted class: civilian and responder casualties and rescues. + public List Casualties { get; set; } = new List(); + /// RMS-3: property other than the incident property that the incident damaged. + public List Exposures { get; set; } = new List(); public List Issues { get; set; } = new List(); public List Submissions { get; set; } = new List(); public List Signatures { get; set; } = new List(); @@ -117,6 +125,159 @@ public class IncidentReportDraftInput public string OutcomeNarrative { get; set; } public string SupplementalJson { get; set; } public RmsOriginClient OriginClient { get; set; } = RmsOriginClient.Web; + + // RMS-3 conditional sections. Null means "leave this section alone"; an empty list clears it. Draft saves + // from a client that does not render a section must not silently delete the section an officer authored on + // the Web, which is why absence and emptiness are different here and nowhere else in this input. + public List Modules { get; set; } + public List Resources { get; set; } + public List Casualties { get; set; } + public List Exposures { get; set; } + } + + /// One conditional section instance being saved; is the contract-shaped body. + public class IncidentModuleInput + { + public RmsIncidentModuleKind Kind { get; set; } + public string PrimaryCode { get; set; } + public string SecondaryCode { get; set; } + public decimal? Quantity { get; set; } + public string QuantityUnit { get; set; } + public DateTime? OccurredOn { get; set; } + public string DetailJson { get; set; } + } + + public class IncidentResourceInput + { + public string ResourceCode { get; set; } + public int? Quantity { get; set; } + public string Detail { get; set; } + } + + /// + /// A casualty or rescue. Restricted fields are only accepted from a caller holding RecordRestricted_View; the + /// service drops them otherwise rather than failing the save, so a reviewer without the restricted grant can + /// still correct the unrestricted half of a report. + /// + public class IncidentCasualtyRescueInput + { + public RmsCasualtyRescueKind Kind { get; set; } + public string PersonType { get; set; } + public string PersonnelUserId { get; set; } + public string Rank { get; set; } + public decimal? YearsOfService { get; set; } + public string JobClassification { get; set; } + public string BirthMonthYear { get; set; } + public string Gender { get; set; } + public string Race { get; set; } + public bool? WasInjured { get; set; } + public string CasualtyCause { get; set; } + public string CasualtyAction { get; set; } + public string CasualtyTimeline { get; set; } + public string DutyType { get; set; } + public List Ppe { get; set; } = new List(); + public string InjuryDetailJson { get; set; } + public bool WasFatal { get; set; } + public string RescueType { get; set; } + public List RescueActions { get; set; } = new List(); + public List RescueImpediments { get; set; } = new List(); + public string RescueMode { get; set; } + public string RescuePath { get; set; } + public string RescueElevation { get; set; } + public string PresenceKnown { get; set; } + public DateTime? OccurredOn { get; set; } + public string DetailJson { get; set; } + } + + public class IncidentExposureInput + { + public string LocationKind { get; set; } + public string ItemType { get; set; } + public string DamageType { get; set; } + public string LocationUse { get; set; } + public bool? PeoplePresent { get; set; } + public int? DisplacementCount { get; set; } + public List DisplacementCauses { get; set; } = new List(); + public string AddressText { get; set; } + public string Street { get; set; } + public string Municipality { get; set; } + public string State { get; set; } + public string PostalCode { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public string CurrencyCode { get; set; } + public string DetailJson { get; set; } + } + + /// The incident-analysis filing as its authoring surface and the mapper read it (RMS-3). + public class IncidentAnalysisAggregate + { + public RmsIncidentAnalysis Analysis { get; set; } + /// The incident this analysis files against; needed for the destination id and the base block. + public RmsIncidentReport Report { get; set; } + public List Modules { get; set; } = new List(); + public List Properties { get; set; } = new List(); + public List Vehicles { get; set; } = new List(); + public List Submissions { get; set; } = new List(); + public List Revisions { get; set; } = new List(); + + public RmsIncidentAnalysisState State => (RmsIncidentAnalysisState)(Analysis?.State ?? 0); + + /// The analysis can only be filed once the incident itself exists at the destination. + public bool IncidentIsFiled => !string.IsNullOrWhiteSpace(Report?.NerisIncidentId); + } + + /// A draft save of the incident-analysis filing; every list replaces the draft rows wholesale. + public class IncidentAnalysisDraftInput + { + public string GeneralCause { get; set; } + public List InvestigationTypes { get; set; } = new List(); + public string CurrencyCode { get; set; } + public List Modules { get; set; } + public List Properties { get; set; } + public List Vehicles { get; set; } + public RmsOriginClient OriginClient { get; set; } = RmsOriginClient.Web; + } + + public class IncidentPropertyInput + { + public string LocationUse { get; set; } + public string ConstructionType { get; set; } + public string Foundation { get; set; } + public string ExteriorFinish { get; set; } + public string RoofMaterial { get; set; } + public int? StoriesAboveGrade { get; set; } + public int? StoriesBelowGrade { get; set; } + public int? YearBuilt { get; set; } + public string Vacancy { get; set; } + public string DamageType { get; set; } + public string FireSpread { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public decimal? ContentsValue { get; set; } + public decimal? ContentsLoss { get; set; } + public string DetailJson { get; set; } + } + + /// VIN and plate are restricted; the service drops them from a caller without RecordRestricted_View. + public class IncidentVehicleInput + { + public string VehicleKind { get; set; } + public string Make { get; set; } + public string Model { get; set; } + public int? ModelYear { get; set; } + public string BodyStyle { get; set; } + public string Powertrain { get; set; } + public string DamageType { get; set; } + public string Vin { get; set; } + public string LicensePlate { get; set; } + public string LicenseState { get; set; } + public bool WasOccupied { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public string DetailJson { get; set; } } /// Stable provenance keys for the prefilled facts (RmsSourceFact.FactKey). diff --git a/Core/Resgrid.Model/Records/NerisContracts.cs b/Core/Resgrid.Model/Records/NerisContracts.cs index 289eb703..3fa46b8b 100644 --- a/Core/Resgrid.Model/Records/NerisContracts.cs +++ b/Core/Resgrid.Model/Records/NerisContracts.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace Resgrid.Model @@ -22,6 +22,56 @@ public class NerisIncidentSnapshot public List DispatchComments { get; set; } = new List(); /// NERIS special modifiers (MCI, declared disaster ...), value-set codes. public List SpecialModifiers { get; set; } = new List(); + + /// RMS-3 conditional sections that ride the incident payload. + public List Modules { get; set; } = new List(); + + /// RMS-3 non-unit resources used on the incident. + public List Resources { get; set; } = new List(); + + /// RMS-3 casualties and rescues. Restricted, but they are reported facts and do enter the payload. + public List Casualties { get; set; } = new List(); + + /// RMS-3 exposures — property other than the incident property that the incident damaged. + public List Exposures { get; set; } = new List(); + } + + /// + /// Everything the incident-analysis mapping needs, read once and handed to the pure mapper (RMS-3). Carries the + /// incident's NERIS id because the analysis is posted against the incident, not against the department. + /// + public class NerisIncidentAnalysisSnapshot + { + public RmsIncidentAnalysis Analysis { get; set; } + public RmsIncidentReport Report { get; set; } + public List Modules { get; set; } = new List(); + public List Properties { get; set; } = new List(); + public List Vehicles { get; set; } = new List(); + } + + /// + /// One conditional section the selected incident types demand or suggest (RMS plan section 4.2, RMS-3). The + /// provider owns the rule; this is the shape the service and the clients read it in, so a client renders the + /// same progressive requirements the server validates against instead of keeping its own copy of the rules. + /// + public class NerisSectionRequirement + { + public RmsIncidentModuleKind Kind { get; set; } + + /// True blocks finalization; false surfaces as a warning the author may answer or ignore. + public bool Required { get; set; } + + /// Why the section applies, shown beside it in the authoring surface. + public string Reason { get; set; } + + /// + /// Value set the section's headline code must belong to, or null when the section has no coded headline. + /// Carried here so an authoring surface builds its dropdown from the same rule validation enforces. + /// + public string PrimaryCodeSet { get; set; } + + /// Value set the section's second reportable code must belong to, or null when it has none. + public string SecondaryCodeSet { get; set; } } public class NerisDispatchComment diff --git a/Core/Resgrid.Model/Records/RecordsApiContracts.cs b/Core/Resgrid.Model/Records/RecordsApiContracts.cs index b208705f..f3931795 100644 --- a/Core/Resgrid.Model/Records/RecordsApiContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsApiContracts.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; @@ -259,7 +259,13 @@ public interface IRecordAttachmentUploadService /// Scoped idempotency for v4 Records commands: the same (department, user, key) replays the first outcome instead of re-running the transition. public interface IRecordsApiIdempotencyService { - Task TryGetRecordIdAsync(int departmentId, string userId, string idempotencyKey); - Task RememberAsync(int departmentId, string userId, string idempotencyKey, string recordId); + /// + /// The record a previous call under this key produced, or null. scopes the key to + /// one operation: a client that reuses a key across two different commands on the same record must not have + /// the second one silently replayed as a success. + /// + Task TryGetRecordIdAsync(int departmentId, string userId, string idempotencyKey, string command); + + Task RememberAsync(int departmentId, string userId, string idempotencyKey, string command, string recordId); } } diff --git a/Core/Resgrid.Model/Records/RmsCasualtyExposure.cs b/Core/Resgrid.Model/Records/RmsCasualtyExposure.cs new file mode 100644 index 00000000..ff7b5020 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsCasualtyExposure.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// Who the casualty or rescue row is about, matching the contract's CasualtyRescuePayload.type. + public static class RmsCasualtyPersonTypes + { + /// A firefighter — responder injury and exposure reporting. + public const string Firefighter = "FF"; + + /// A civilian. + public const string Civilian = "NONFF"; + } + + /// Which half of the contract's casualty_rescues entry this row carries. + public enum RmsCasualtyRescueKind + { + /// An injury or a documented non-injury. + Casualty = 1, + + /// A rescue, with or without removal. + Rescue = 2 + } + + /// + /// One civilian or responder casualty or rescue (RMS plan section 4.2, RMS-3; registry M0168; NERIS + /// incident.casualty_rescues[]). + /// + /// This is a restricted class end to end: demographics, injury detail and the person's identity require + /// RecordRestricted_View in detail, revision, diff, print and export, the row carries the inert + /// Protected Data envelope (plan 5.9.1), and its retention default is permanent (plan section 4.9). The + /// department's own personnel link is kept separately from the reported demographics so a responder injury + /// can be tied to a member without putting a member id into the destination payload. + /// + /// + public class RmsCasualtyRescue : IEntity + { + public string RmsCasualtyRescueId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// The owning incident report. + public string RecordId { get; set; } + + public string RevisionId { get; set; } + + /// . + public int Kind { get; set; } + + /// : FF or NONFF. + public string PersonType { get; set; } + + /// Restricted: the department member when the person is one of ours; never sent to the destination. + public string PersonnelUserId { get; set; } + + /// Restricted: rank of the firefighter, as reported. + public string Rank { get; set; } + + public decimal? YearsOfService { get; set; } + + /// NERIS job_classification code. + public string JobClassification { get; set; } + + /// Restricted: NERIS birth_month_year, "YYYY-MM"; never a full date of birth. + public string BirthMonthYear { get; set; } + + /// Restricted: NERIS gender code. + public string Gender { get; set; } + + /// Restricted: NERIS race code. + public string Race { get; set; } + + // Casualty half ------------------------------------------------------------------------------------ + + /// False records a documented non-injury (the contract's noinjury branch). + public bool? WasInjured { get; set; } + + /// NERIS casualty_cause code. + public string CasualtyCause { get; set; } + + /// NERIS casualty_action code — what the person was doing. + public string CasualtyAction { get; set; } + + /// NERIS casualty_timeline code — when in the incident it happened. + public string CasualtyTimeline { get; set; } + + /// NERIS duty code for a responder. + public string DutyType { get; set; } + + /// Comma-separated NERIS casualty_ppe codes in use at the time. + public string PpeCsv { get; set; } + + /// Restricted: injury narrative and body-area detail, contract-shaped. + public string InjuryDetailJson { get; set; } + + /// True when the casualty was fatal; drives permanent retention and the restricted class. + public bool WasFatal { get; set; } + + // Rescue half -------------------------------------------------------------------------------------- + + /// NERIS rescue type: RESCUED_BY_FIREFIGHTER, RESCUED_BY_FF_RIT, EVAC_ASSISTED_BY_FIREFIGHTER. + public string RescueType { get; set; } + + /// Comma-separated NERIS rescue_action codes. + public string RescueActionsCsv { get; set; } + + /// Comma-separated NERIS rescue_impediment codes. + public string RescueImpedimentsCsv { get; set; } + + /// NERIS rescue_mode code. + public string RescueMode { get; set; } + + /// NERIS rescue_path code. + public string RescuePath { get; set; } + + /// NERIS rescue_elevation code. + public string RescueElevation { get; set; } + + /// NERIS rescue_presence_known code — whether the person was known to be present. + public string PresenceKnown { get; set; } + + /// Contract-shaped remainder of the entry. + public string DetailJson { get; set; } + + public DateTime? OccurredOn { get; set; } + + /// Inert Protected Data envelope (plan section 5.9.1); null until the department enrolls. + public string ProtectedEnvelope { get; set; } + + public bool IsProtected { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public int Ordinal { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public object IdValue { get => RmsCasualtyRescueId; set => RmsCasualtyRescueId = (string)value; } + public string TableName => "RmsCasualtyRescues"; + public string IdName => "RmsCasualtyRescueId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// One exposure — property other than the incident property that the incident damaged (registry M0168; NERIS + /// incident.exposures[]). Damage rating, displacement and the exposure's own location are reported and + /// summed, so they are columns; the contract's internal/external discrimination and any remainder ride + /// . + /// + public class RmsExposure : IEntity + { + public string RmsExposureId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// The owning incident report. + public string RecordId { get; set; } + + public string RevisionId { get; set; } + + /// INTERNAL when the exposure is inside the incident property, EXTERNAL when it is separate. + public string LocationKind { get; set; } + + /// NERIS exposure_item code — structure, object, outdoor environment. + public string ItemType { get; set; } + + /// NERIS exposure_damage rating; required by the contract. + public string DamageType { get; set; } + + /// NERIS location_use code of the exposed property. + public string LocationUse { get; set; } + + public bool? PeoplePresent { get; set; } + + public int? DisplacementCount { get; set; } + + /// Comma-separated NERIS displace_cause codes. + public string DisplacementCausesCsv { get; set; } + + public string AddressText { get; set; } + + public string Street { get; set; } + + public string Municipality { get; set; } + + public string State { get; set; } + + public string PostalCode { get; set; } + + public decimal? Latitude { get; set; } + + public decimal? Longitude { get; set; } + + public decimal? EstimatedValue { get; set; } + + public decimal? EstimatedLoss { get; set; } + + public string CurrencyCode { get; set; } + + /// Contract-shaped remainder of the exposure entry. + public string DetailJson { get; set; } + + /// Inert Protected Data envelope (plan section 5.9.1); null until the department enrolls. + public string ProtectedEnvelope { get; set; } + + public bool IsProtected { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public int Ordinal { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public object IdValue { get => RmsExposureId; set => RmsExposureId = (string)value; } + public string TableName => "RmsExposures"; + public string IdName => "RmsExposureId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsDisclosure.cs b/Core/Resgrid.Model/Records/RmsDisclosure.cs new file mode 100644 index 00000000..81692c1b --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsDisclosure.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Where a public-records request stands (RMS plan section 4.7, RMS-3). Persisted as an integer; append-only. + /// + public enum RmsDisclosureState + { + /// Logged with a requester and a statutory clock running. + Received = 0, + + /// The scope query is being settled with the requester. + Scoping = 1, + + /// The result set is being reviewed and redacted. + InReview = 2, + + /// A redacted production exists but has not been released. + Produced = 3, + + /// Released to the requester; the produced set is frozen. + Released = 4, + + /// Refused under an exemption, with the reason recorded. + Denied = 5, + + /// The requester withdrew it. + Withdrawn = 6, + + /// Closed without release for any other recorded reason. + Closed = 7 + } + + /// Named redaction profiles; a profile decides which classifications are withheld. + public static class RmsRedactionProfiles + { + /// Withholds restricted content only; the ordinary public-records answer. + public const string Standard = "Standard"; + + /// Withholds restricted content and every participant identity. + public const string NoPersonalIdentifiers = "NoPersonalIdentifiers"; + + /// Releases everything the department holds; used for an internal or litigation production. + public const string FullDisclosure = "FullDisclosure"; + } + + /// + /// A public-records or access-to-information request (RMS plan section 4.7, registry M0171). + /// + /// For a public agency this is a statutory obligation with a clock, which is why it is a record with a due + /// date rather than an ad-hoc export. The jurisdiction profile matters because U.S. state public-records law + /// and Canadian access-to-information regimes differ in what must be released and how quickly. + /// + /// + /// The requester's identity is restricted: in most jurisdictions who asked is not itself public, and it must + /// not leak into the produced packet. + /// + /// + public class RmsDisclosureRequest : IEntity + { + public string RmsDisclosureRequestId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// Department-facing reference, assigned on create. + public string RequestNumber { get; set; } + + /// Restricted: who asked. + public string RequesterName { get; set; } + + /// Restricted: the organisation they asked on behalf of. + public string RequesterOrganization { get; set; } + + /// Restricted: contact details, kept only to answer the request. + public string RequesterContact { get; set; } + + public DateTime ReceivedOn { get; set; } + + /// Computed from the department's statutory clock (setting 77) unless an administrator overrides it. + public DateTime? StatutoryDueOn { get; set; } + + /// Which regime applies, e.g. US-IL or CA-ON; drives the clock and the exemptions. + public string JurisdictionProfile { get; set; } + + /// What the requester asked for, in their words. + public string ScopeNarrative { get; set; } + + /// The bounded RmsRecordQuery the scope resolves to; the same query the Records queue runs. + public string ScopeQueryJson { get; set; } + + /// . + public int State { get; set; } + + public string AssignedToUserId { get; set; } + + /// Redaction profile applied to productions unless one names its own. + public string RedactionProfile { get; set; } + + public DateTime? ClosedOn { get; set; } + + public string ClosedByUserId { get; set; } + + /// Why it closed the way it did — the exemption relied on, or the requester's withdrawal. + public string DispositionReason { get; set; } + + public DateTime CreatedOn { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime ModifiedOn { get; set; } + + public string ModifiedByUserId { get; set; } + + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + /// Past its statutory due date and not yet resolved. + public bool IsOverdue => StatutoryDueOn.HasValue && ClosedOn == null && StatutoryDueOn.Value < DateTime.UtcNow; + + public object IdValue { get => RmsDisclosureRequestId; set => RmsDisclosureRequestId = (string)value; } + public string TableName => "RmsDisclosureRequests"; + public string IdName => "RmsDisclosureRequestId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "IsOverdue" }; + } + + /// + /// One immutable produced set for a request (RMS plan section 4.7, registry M0171). + /// + /// A production is a new artifact, never a mutation of the source revisions — that is the whole point. It + /// freezes exactly which record and revision IDs were released, under which redaction profile, with a + /// checksum, so a later amendment to any of those records cannot silently change what the department is on + /// the hook for having released. + /// + /// + public class RmsDisclosureProduction : IEntity + { + public string RmsDisclosureProductionId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public string DisclosureRequestId { get; set; } + + /// 1 for the first production, incrementing for supplemental ones. + public int ProductionNumber { get; set; } + + /// actually applied. + public string RedactionProfile { get; set; } + + /// + /// The produced-set snapshot: the record and revision IDs released with their checksums at the moment of + /// production. What makes "an amendment did not change what we released" provable. + /// + public string ProducedSetJson { get; set; } + + /// The redacted content itself, with its manifest. + public string ArtifactJson { get; set; } + + /// Lower-case hex SHA-256 of . + public string Checksum { get; set; } + + public long ByteSize { get; set; } + + public int RecordCount { get; set; } + + /// Which fields were withheld and under what heading; the redaction log the requester may see. + public string WithheldFieldsJson { get; set; } + + public int WithheldFieldCount { get; set; } + + public string PreparedByUserId { get; set; } + + public DateTime PreparedOn { get; set; } + + public string ReleasedByUserId { get; set; } + + public DateTime? ReleasedOn { get; set; } + + public string ProtectedEnvelope { get; set; } + + public bool IsProtected { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public bool IsReleased => ReleasedOn.HasValue; + + public object IdValue { get => RmsDisclosureProductionId; set => RmsDisclosureProductionId = (string)value; } + public string TableName => "RmsDisclosureProductions"; + public string IdName => "RmsDisclosureProductionId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "IsReleased" }; + } + + /// One record in a disclosure scope preview, before anything is produced. + public class RmsDisclosureScopeItem + { + public string RecordId { get; set; } + public string RecordNumber { get; set; } + public string DefinitionKey { get; set; } + public string Summary { get; set; } + public DateTime? OccurredOn { get; set; } + public string CurrentRevisionId { get; set; } + /// False when the record is not finalized; a draft is not a public record and is never produced. + public bool Producible { get; set; } + public string NotProducibleReason { get; set; } + } + + /// What a scope query resolves to, with the counts an officer needs before producing anything. + public class RmsDisclosureScopePreview + { + public int MatchedCount { get; set; } + public int ProducibleCount { get; set; } + public int WithheldWholeRecordCount { get; set; } + public bool Truncated { get; set; } + public List Items { get; set; } = new List(); + } + + /// One withheld field in a production's redaction log. + public class RmsRedactionEntry + { + public string RecordId { get; set; } + public string Section { get; set; } + public string Field { get; set; } + /// Why it was withheld — the classification or profile rule relied on. + public string Basis { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs b/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs new file mode 100644 index 00000000..598358f8 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// What a Record is late for (RMS plan section 4.7, RMS-3). Persisted as an integer; append-only. + /// + public enum RmsRecordObligation + { + /// Submitted for review and not reviewed by its due time (notification 32, trigger 112). + Review = 1, + + /// Returned for correction and not corrected — the record is sitting with its author. + Correction = 2, + + /// Rejected by the reporting destination and not corrected and resubmitted. + Submission = 3 + } + + /// + /// Where an obligation stands. The evaluation emits only on the transition into , which + /// is what makes "at most once per record and due-state transition" true. + /// + public enum RmsDueState + { + NotDue = 0, + DueSoon = 1, + Overdue = 2, + /// The obligation was met (reviewed, corrected, resubmitted) or no longer applies. + Cleared = 3 + } + + /// + /// The persisted due state of one obligation on one Record (registry M0170, RMS-3). + /// + /// The plan is explicit that the "at most once per record/due-state transition" guarantee is carried by this + /// row and never inferred from the last worker run: a missed run must not skip an emission and a repeated run + /// must not double-emit. The worker compares the state it computes now against + /// and only emits when they differ. + /// + /// + public class RmsRecordDueState : IEntity + { + public string RmsRecordDueStateId { get; set; } + + public int DepartmentId { get; set; } + + /// The operational Record or incident report the obligation is on. + public string RecordId { get; set; } + + /// . + public int RecordKind { get; set; } + + /// . + public int Obligation { get; set; } + + /// When the obligation falls due; a change to it re-arms emission for the new deadline. + public DateTime? DueOn { get; set; } + + /// last emitted for this obligation and . + public int LastEmittedState { get; set; } + + public DateTime? LastEmittedOn { get; set; } + + /// Who the obligation currently rests with, snapshotted so the notification has a target. + public string ResponsibleUserId { get; set; } + + /// How many times this obligation has gone overdue across its lifetime; reporting only. + public int OverdueCount { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public object IdValue { get => RmsRecordDueStateId; set => RmsRecordDueStateId = (string)value; } + public string TableName => "RmsRecordDueStates"; + public string IdName => "RmsRecordDueStateId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Why a hold exists; shown to administrators and carried into the audit when a purge is refused. + public static class RmsLegalHoldReasons + { + public const string Litigation = "Litigation"; + public const string Investigation = "Investigation"; + public const string PublicRecordsRequest = "PublicRecordsRequest"; + public const string Other = "Other"; + } + + /// + /// A legal hold that suspends retention for a Record, a definition, or a whole department date range + /// (registry M0170, RMS-3). A hold never deletes and never edits; its only effect is that the retention sweep + /// refuses to purge what it covers, and that refusal is audited rather than silent. + /// + public class RmsRecordLegalHold : IEntity + { + public string RmsRecordLegalHoldId { get; set; } + + public int DepartmentId { get; set; } + + /// The specific Record held, or null when the hold is a definition/date-range hold. + public string RecordId { get; set; } + + /// The definition held, or null when the hold names a single Record or the whole department. + public string DefinitionKey { get; set; } + + /// Inclusive start of the held period, matched against the Record's started/created time. + public DateTime? PeriodStart { get; set; } + + /// Inclusive end of the held period. + public DateTime? PeriodEnd { get; set; } + + /// . + public string Reason { get; set; } + + public string ReferenceNumber { get; set; } + + public string Notes { get; set; } + + public string PlacedByUserId { get; set; } + + public DateTime PlacedOn { get; set; } + + public string ReleasedByUserId { get; set; } + + public DateTime? ReleasedOn { get; set; } + + public string ReleaseNotes { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + /// Active while it has not been released. + public bool IsActive => !ReleasedOn.HasValue; + + /// Whether this hold covers a Record of a definition started at a time. + public bool Covers(string recordId, string definitionKey, DateTime? startedOn) + { + if (!IsActive) + return false; + + if (!string.IsNullOrWhiteSpace(RecordId)) + return string.Equals(RecordId, recordId, StringComparison.Ordinal); + + if (!string.IsNullOrWhiteSpace(DefinitionKey) && !string.Equals(DefinitionKey, definitionKey, StringComparison.Ordinal)) + return false; + + // A hold with no period covers every date; one with a period needs a date to compare against, and a + // Record with no date is held rather than purged — the safe reading when the evidence is incomplete. + if (!PeriodStart.HasValue && !PeriodEnd.HasValue) + return true; + if (!startedOn.HasValue) + return true; + + if (PeriodStart.HasValue && startedOn.Value < PeriodStart.Value) + return false; + if (PeriodEnd.HasValue && startedOn.Value > PeriodEnd.Value) + return false; + + return true; + } + + public object IdValue { get => RmsRecordLegalHoldId; set => RmsRecordLegalHoldId = (string)value; } + public string TableName => "RmsRecordLegalHolds"; + public string IdName => "RmsRecordLegalHoldId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "IsActive" }; + } + + /// Result of one due-state evaluation sweep (worker 42). + public class RecordsDueStateSweepResult + { + public int DepartmentsEvaluated { get; set; } + public int RecordsEvaluated { get; set; } + public int BecameOverdue { get; set; } + public int Cleared { get; set; } + public int NotificationsSent { get; set; } + public int Errors { get; set; } + public string Message { get; set; } + } + + /// Result of one retention and purge sweep (worker 43). + public class RecordsRetentionSweepResult + { + public int DepartmentsEvaluated { get; set; } + public int RecordsEvaluated { get; set; } + public int RecordsPurged { get; set; } + public int AttachmentsPurged { get; set; } + public int HeldByLegalHold { get; set; } + public int AttachmentsRescanned { get; set; } + public int AttachmentsRejectedOnRescan { get; set; } + public int Errors { get; set; } + public string Message { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RmsEnums.cs b/Core/Resgrid.Model/Records/RmsEnums.cs index d968eff1..091a2a42 100644 --- a/Core/Resgrid.Model/Records/RmsEnums.cs +++ b/Core/Resgrid.Model/Records/RmsEnums.cs @@ -13,7 +13,10 @@ public enum RmsRecordKind Operational = 1, /// RmsIncidentReport: the NERIS incident report aggregate (RMS-2). - IncidentReport = 2 + IncidentReport = 2, + + /// RmsIncidentAnalysis: the separate NERIS fire/hazmat analysis filing for an incident (RMS-3). + IncidentAnalysis = 3 } /// diff --git a/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs b/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs new file mode 100644 index 00000000..074748fb --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// The six evidence sources RMS-3 ships, none optional (RMS plan sections 4.5 and RMS-3). Persisted as an + /// integer; append-only. + /// + /// Every one of these captures an authorized, bounded, server-authored snapshot with provenance. None of them + /// hydrates or retains a live source: not a whole chat channel, not a unit's tracking history, not a source + /// record that can later change or expire underneath the filing. + /// + /// + public enum RmsEvidenceKind + { + /// Apparatus/equipment readiness at the time of the call — a checklist/work-order manifest. + ReadinessPacket = 1, + + /// The recorded dispatch decision: card and version, alarm level, mode, selected resources, shortfall. + RunCardActivation = 2, + + /// Bounded unit tracking fixes over a coverage window, captured before source retention purges them. + TrackingFix = 3, + + /// Selected incident-chat messages promoted into the record by an authorized member. + ChatPromotion = 4, + + /// Supplies and controlled-substance usage from the inventory ledger. + InventoryUsage = 5, + + /// Participant certification/qualification validity at the incident time. + CertificationSnapshot = 6 + } + + /// + /// How far an artifact's content may travel. Classification is decided at capture and never widened later, + /// so an artifact captured as restricted stays restricted through print, export and disclosure. + /// + public enum RmsEvidenceClassification + { + /// Ordinary operational content; visible to anyone who can view the Record. + Unrestricted = 0, + + /// Needs RecordRestricted_View wherever it is rendered. + Restricted = 1, + + /// Protected-data candidate; the envelope columns carry it once a department enrolls (plan 5.9). + Protected = 2 + } + + /// + /// An immutable supporting artifact or manifest attached to a Record (RMS plan section 5.2, registry M0169). + /// + /// The point of storing an artifact rather than a link is that a link can change or expire: a readiness report + /// regenerated next year does not say what it said on the night of the fire. So the content is captured once, + /// checksummed, classified and retained with the Record — and never updated. A correction is a new artifact, + /// which is why there is no update path and why exists instead. + /// + /// + /// The per-source rows the artifact was built from ride inside . That is deliberate: + /// a manifest is the thing being attested to, and splitting it across a child table would let the two drift + /// while the checksum still claimed the artifact was intact. + /// + /// + public class RmsEvidenceArtifact : IEntity + { + public string RmsEvidenceArtifactId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// The Record or incident report the artifact supports. + public string RecordId { get; set; } + + /// . + public int RecordKind { get; set; } + + /// + /// The revision the artifact was captured into, or null while it supports the working draft. A finalize + /// stamps the revision, which is what makes "the evidence as it stood at revision 3" answerable. + /// + public string RevisionId { get; set; } + + /// . + public int Kind { get; set; } + + /// Human title shown in the evidence list, server-authored. + public string Title { get; set; } + + /// Why the capture happened; required, and written to the access audit. + public string CaptureReason { get; set; } + + public string SourceSubsystem { get; set; } + + public string SourceEntityType { get; set; } + + /// Opaque, source-qualified identifier of the primary source row; never parsed for meaning. + public string SourceEntityId { get; set; } + + public string IdentifierScheme { get; set; } + + /// Version or schema of the source at capture time, where the source carries one. + public string SourceVersion { get; set; } + + /// Start of the period the artifact covers (a tracking window, a readiness period). + public DateTime? CoverageStart { get; set; } + + public DateTime? CoverageEnd { get; set; } + + /// The bounded, server-authored snapshot and its per-source manifest. The artifact itself. + public string ManifestJson { get; set; } + + /// Lower-case hex SHA-256 of ; what an auditor re-computes. + public string Checksum { get; set; } + + public long ByteSize { get; set; } + + /// How many source rows the manifest covers; shown without opening the artifact. + public int SourceItemCount { get; set; } + + /// Reserved for an external blob location; null while the manifest is stored in-row. + public string StorageReference { get; set; } + + /// , decided at capture and never widened. + public int Classification { get; set; } + + /// + /// Retention in years for this artifact when it must outlive the Record's own policy; null inherits the + /// Record, and 0 is permanent. Legal holds are evaluated against the Record and cover its artifacts. + /// + public int? RetentionYears { get; set; } + + public string CapturedByUserId { get; set; } + + public DateTime CapturedOn { get; set; } + + /// the capture came from. + public int OriginClient { get; set; } + + /// Set when a later capture replaces this one; the original still stands and is still readable. + public string SupersededByArtifactId { get; set; } + + public DateTime? SupersededOn { get; set; } + + /// Inert Protected Data envelope (plan section 5.9.1); null until the department enrolls. + public string ProtectedEnvelope { get; set; } + + public bool IsProtected { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + /// A superseded artifact is history: readable, never the current evidence of its kind. + public bool IsCurrent => !SupersededOn.HasValue && !DeletedOn.HasValue; + + public object IdValue { get => RmsEvidenceArtifactId; set => RmsEvidenceArtifactId = (string)value; } + public string TableName => "RmsEvidenceArtifacts"; + public string IdName => "RmsEvidenceArtifactId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "IsCurrent" }; + } + + /// What a caller asks an evidence adapter to capture. The adapter decides what it can honour. + public class RecordEvidenceCaptureRequest + { + public int DepartmentId { get; set; } + + public string RecordId { get; set; } + + public RmsRecordKind RecordKind { get; set; } = RmsRecordKind.Operational; + + public RmsEvidenceKind Kind { get; set; } + + /// Required: why this evidence is being put into an official record. + public string CaptureReason { get; set; } + + /// The Call the Record hangs off, where the source is call-scoped. + public int? CallId { get; set; } + + /// Bounded window for time-series sources; the adapter clamps it to its own maximum. + public DateTime? CoverageStart { get; set; } + + public DateTime? CoverageEnd { get; set; } + + /// Explicit source ids the caller selected — chat messages, units, members. + public List SourceIds { get; set; } = new List(); + + public List UnitIds { get; set; } = new List(); + + public List UserIds { get; set; } = new List(); + + public string CapturedByUserId { get; set; } + + public RmsOriginClient OriginClient { get; set; } = RmsOriginClient.Web; + } + + /// What an adapter produced, before the service persists it. + public class RecordEvidenceCapture + { + public bool Available { get; set; } = true; + + /// Why nothing was captured; shown to the author rather than failing silently. + public string UnavailableReason { get; set; } + + public string Title { get; set; } + + public string SourceSubsystem { get; set; } + + public string SourceEntityType { get; set; } + + public string SourceEntityId { get; set; } + + public string IdentifierScheme { get; set; } + + public string SourceVersion { get; set; } + + public DateTime? CoverageStart { get; set; } + + public DateTime? CoverageEnd { get; set; } + + /// The manifest object; the service serializes it deterministically and checksums the result. + public object Manifest { get; set; } + + public int SourceItemCount { get; set; } + + public RmsEvidenceClassification Classification { get; set; } = RmsEvidenceClassification.Unrestricted; + + public int? RetentionYears { get; set; } + + public static RecordEvidenceCapture Unavailable(string reason) + { + return new RecordEvidenceCapture { Available = false, UnavailableReason = reason }; + } + } +} diff --git a/Core/Resgrid.Model/Records/RmsIncidentModules.cs b/Core/Resgrid.Model/Records/RmsIncidentModules.cs new file mode 100644 index 00000000..3bbfa603 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsIncidentModules.cs @@ -0,0 +1,495 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// The conditional sections a NERIS incident carries beyond the always-present base (RMS plan section 4.2, + /// RMS-3; registry M0167). Each value names exactly one payload location in the pinned contract, so a module + /// row is never a Resgrid invention — holds the mapping and the pinned + /// schema name that is validated against. + /// + /// Wildland/WUI and weather have no dedicated section in contract 1.4.78: outdoor fire carries acres burned + /// and fuel/spread facts inside , and weather is a destination-side lookup with no + /// submittable payload. The plan's list is "as required by the pinned NERIS profile", so they are represented + /// where the contract puts them rather than as tables the destination would reject. + /// + /// Persisted as an integer; append-only. + /// + public enum RmsIncidentModuleKind + { + /// incident.fire_detail — water supply, investigation need, suppression appliances. + Fire = 1, + + /// incident.fire_detail.location_detail (STRUCTURE) — floor/room of origin, arrival condition, building damage. + StructureFireLocation = 2, + + /// incident.fire_detail.location_detail (OUTSIDE) — acres burned and outdoor cause. + OutsideFireLocation = 3, + + /// incident.hazsit_detail — evacuated count and hazmat disposition. + Hazsit = 4, + + /// incident.hazsit_detail.chemicals[] — one released chemical. + Chemical = 5, + + /// incident.medical_details[] — patient care evaluation, status and transport disposition. + Medical = 6, + + /// incident.smoke_alarm — presence, type, operation and failure reason. + SmokeAlarm = 7, + + /// incident.fire_alarm — presence, type, operation and failure reason. + FireAlarm = 8, + + /// incident.other_alarm — CO/heat/gas alarm presence and operation. + OtherAlarm = 9, + + /// incident.fire_suppression — automatic suppression presence, type and effectiveness. + FireSuppression = 10, + + /// incident.cooking_fire_suppression — kitchen suppression presence and effectiveness. + CookingFireSuppression = 11, + + /// incident.electric_hazards[] — one electrical/battery hazard. + ElectricHazard = 12, + + /// incident.powergen_hazards[] — one PV or other power-generation hazard. + PowergenHazard = 13, + + /// incident.csst_hazard — corrugated stainless steel tubing involvement. + CsstHazard = 14, + + /// incident.medical_oxygen_hazard — home medical oxygen involvement. + MedicalOxygenHazard = 15, + + /// incident_analysis.structure_fire_origin — origin, item first ignited, human factors. + StructureFireOrigin = 20, + + /// incident_analysis.outside_fire — outdoor fire analysis. + OutsideFire = 21, + + /// incident_analysis.hazsit — hazmat analysis with release factors. + HazsitAnalysis = 22, + + /// incident_analysis.products[] — one product involved in ignition or spread. + Product = 23, + + /// incident_analysis.batteries[] — one battery involved. + Battery = 24 + } + + /// Where a module lands in the pinned contract, and which aggregate owns it. + public sealed class RmsIncidentModuleDescriptor + { + public RmsIncidentModuleDescriptor(RmsIncidentModuleKind kind, string payloadPath, string schemaName, bool isCollection, bool belongsToAnalysis) + { + Kind = kind; + PayloadPath = payloadPath; + SchemaName = schemaName; + IsCollection = isCollection; + BelongsToAnalysis = belongsToAnalysis; + } + + public RmsIncidentModuleKind Kind { get; } + + /// Dotted path inside the owning payload, e.g. fire_detail.location_detail. + public string PayloadPath { get; } + + /// The pinned contract schema DetailJson conforms to. + public string SchemaName { get; } + + /// True when the path is an array and several rows of this kind may exist. + public bool IsCollection { get; } + + /// True when the module rides the incident-analysis payload rather than the incident payload. + public bool BelongsToAnalysis { get; } + } + + /// + /// The one place a module kind is bound to the pinned contract. Mapping, validation, print and export all read + /// it, so a contract upgrade changes this table and nothing else. + /// + public static class RmsIncidentModuleCatalog + { + private static readonly Dictionary Descriptors = Build(); + + private static Dictionary Build() + { + var map = new Dictionary(); + void Add(RmsIncidentModuleKind kind, string path, string schema, bool collection, bool analysis = false) + => map[kind] = new RmsIncidentModuleDescriptor(kind, path, schema, collection, analysis); + + Add(RmsIncidentModuleKind.Fire, "fire_detail", "FirePayload", false); + Add(RmsIncidentModuleKind.StructureFireLocation, "fire_detail.location_detail", "StructureFireLocationDetailPayload", false); + Add(RmsIncidentModuleKind.OutsideFireLocation, "fire_detail.location_detail", "OutsideFireLocationDetailPayload", false); + Add(RmsIncidentModuleKind.Hazsit, "hazsit_detail", "HazsitPayload", false); + Add(RmsIncidentModuleKind.Chemical, "hazsit_detail.chemicals", "ChemicalPayload", true); + Add(RmsIncidentModuleKind.Medical, "medical_details", "MedicalPayload", true); + Add(RmsIncidentModuleKind.SmokeAlarm, "smoke_alarm", "SmokeAlarmPayload", false); + Add(RmsIncidentModuleKind.FireAlarm, "fire_alarm", "FireAlarmPayload", false); + Add(RmsIncidentModuleKind.OtherAlarm, "other_alarm", "OtherAlarmPayload", false); + Add(RmsIncidentModuleKind.FireSuppression, "fire_suppression", "FireSuppressionPayload", false); + Add(RmsIncidentModuleKind.CookingFireSuppression, "cooking_fire_suppression", "CookingFireSuppressionPayload", false); + Add(RmsIncidentModuleKind.ElectricHazard, "electric_hazards", "ElectricHazardPayload", true); + Add(RmsIncidentModuleKind.PowergenHazard, "powergen_hazards", "PowergenHazardPayload", true); + Add(RmsIncidentModuleKind.CsstHazard, "csst_hazard", "CsstHazardPayload", false); + Add(RmsIncidentModuleKind.MedicalOxygenHazard, "medical_oxygen_hazard", "MedicalOxygenHazardPayload", false); + + Add(RmsIncidentModuleKind.StructureFireOrigin, "structure_fire_origin", "StructureFireOriginPayload", false, true); + Add(RmsIncidentModuleKind.OutsideFire, "outside_fire", "OutsideFirePayload", false, true); + Add(RmsIncidentModuleKind.HazsitAnalysis, "hazsit", "HazsitWithReleaseFactorsPayload", false, true); + Add(RmsIncidentModuleKind.Product, "products", "ProductPayload", true, true); + Add(RmsIncidentModuleKind.Battery, "batteries", "BatteryPayload", true, true); + + return map; + } + + public static RmsIncidentModuleDescriptor Get(RmsIncidentModuleKind kind) + { + return Descriptors.TryGetValue(kind, out var descriptor) ? descriptor : null; + } + + public static IReadOnlyCollection All => Descriptors.Values; + + /// Modules that ride the incident payload (everything submitted with the incident itself). + public static IEnumerable IncidentModules() + { + foreach (var descriptor in Descriptors.Values) + { + if (!descriptor.BelongsToAnalysis) + yield return descriptor; + } + } + + /// Modules that ride the incident-analysis payload (the separate fire/hazmat analysis filing). + public static IEnumerable AnalysisModules() + { + foreach (var descriptor in Descriptors.Values) + { + if (descriptor.BelongsToAnalysis) + yield return descriptor; + } + } + } + + /// + /// One conditional section instance on an incident report or its analysis (registry M0167). The stable, + /// reportable facts are columns so queues, dashboards and reports never parse JSON; the section's full + /// contract-shaped body is , validated against in the pinned + /// contract before it is stored. Draft rows have a null ; a revision copies them. + /// + public class RmsIncidentModule : IEntity + { + public string RmsIncidentModuleId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// The owning aggregate: an incident report id, or an incident analysis id for analysis modules. + public string RecordId { get; set; } + + /// of the owner, carried for export and audit clarity. + public int RecordKind { get; set; } + + public string RevisionId { get; set; } + + /// . + public int ModuleKind { get; set; } + + /// The pinned contract schema was validated against. + public string SchemaName { get; set; } + + /// Pinned NERIS contract version this section was authored against. + public string ProfileVersion { get; set; } + + /// The section's headline value-set code (fire cause, hazard disposition, alarm type, ...). + public string PrimaryCode { get; set; } + + /// A second reportable code where the section has one (damage rating, operation outcome, ...). + public string SecondaryCode { get; set; } + + /// The section's single reportable magnitude: acres burned, evacuated count, released amount. + public decimal? Quantity { get; set; } + + /// Unit of where the contract carries one (a NERIS hazard_unit code). + public string QuantityUnit { get; set; } + + public DateTime? OccurredOn { get; set; } + + /// The contract-shaped section body; the only place section detail lives. + public string DetailJson { get; set; } + + /// Inert Protected Data envelope (plan section 5.9.1); null until the department enrolls. + public string ProtectedEnvelope { get; set; } + + public bool IsProtected { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public int Ordinal { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime ModifiedOn { get; set; } + + public long RowVersion { get; set; } + + public object IdValue { get => RmsIncidentModuleId; set => RmsIncidentModuleId = (string)value; } + public string TableName => "RmsIncidentModules"; + public string IdName => "RmsIncidentModuleId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// A non-unit resource used on the incident (plan section 4.2 "resources"). Units are + /// ; this is everything else a department wants on the record — foam, a borrowed + /// tender, a contractor. + /// + /// Contract 1.4.78 has no incident-level resources field, so these rows are a department record only: they + /// print and export, and they are never mapped into a submission payload. The code is the department's own, + /// not a NERIS value-set member, and nothing validates it against one. + /// + /// + public class RmsIncidentResource : IEntity + { + public string RmsIncidentResourceId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + public string RecordId { get; set; } + public string RevisionId { get; set; } + /// NERIS resource value-set code. + public string ResourceCode { get; set; } + public int? Quantity { get; set; } + public string Detail { get; set; } + public int Ordinal { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + public object IdValue { get => RmsIncidentResourceId; set => RmsIncidentResourceId = (string)value; } + public string TableName => "RmsIncidentResources"; + public string IdName => "RmsIncidentResourceId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Lifecycle of the separate incident-analysis filing; deliberately narrower than the incident's. + public enum RmsIncidentAnalysisState + { + /// Being authored; never submitted from here. + Draft = 0, + + /// Finalized with the incident and eligible for submission once the incident exists at the destination. + Finalized = 1, + + /// Queued or in flight to the destination. + Submitted = 2, + + /// The destination accepted the analysis. + Accepted = 3, + + /// The destination rejected it; correctable in place like a rejected incident. + Rejected = 4, + + /// Withdrawn; retained as history, never submitted again. + Voided = 5 + } + + /// + /// The NERIS incident analysis (registry M0167): the fire/hazmat investigation filing that the contract posts + /// to /incident_analysis/{neris_id_incident} after the incident itself exists. It is a second + /// submittable artifact for the same incident, not a section of it, so it carries its own state, revisions, + /// submissions and idempotency key — and it can never be submitted before the incident has a NERIS id. + /// + public class RmsIncidentAnalysis : IEntity + { + public string RmsIncidentAnalysisId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// The incident report this analysis belongs to (one per report). + public string IncidentReportId { get; set; } + + public string ReportingEntityId { get; set; } + + /// Pinned NERIS contract version the analysis was authored against. + public string ProfileVersion { get; set; } + + /// . + public int State { get; set; } + + /// General fire cause (NERIS fire_cause_general), the analysis's headline determination. + public string GeneralCause { get; set; } + + /// Investigation types the department applied (comma-separated NERIS fire_invest codes). + public string InvestigationTypesCsv { get; set; } + + /// Estimated total loss in whole currency units, department-reported. + public decimal? EstimatedLossTotal { get; set; } + + /// Estimated pre-incident value in whole currency units. + public decimal? EstimatedValueTotal { get; set; } + + public string CurrencyCode { get; set; } + + public string AuthorUserId { get; set; } + + public string OwnerUserId { get; set; } + + public DateTime? FinalizedOn { get; set; } + + public string FinalizedByUserId { get; set; } + + public string CurrentRevisionId { get; set; } + + public int RevisionCount { get; set; } + + /// NERIS-assigned analysis id once the first create succeeded. + public string NerisAnalysisId { get; set; } + + public string LastSubmissionId { get; set; } + + /// of the latest analysis submission. + public int? LastSubmissionState { get; set; } + + public DateTime? LastSubmittedOn { get; set; } + + public DateTime? AcceptedOn { get; set; } + + public DateTime? RejectedOn { get; set; } + + /// Normalized, non-sensitive summary of the last rejection (codes and field paths only). + public string RejectionSummary { get; set; } + + public DateTime? VoidedOn { get; set; } + + public string VoidedByUserId { get; set; } + + public string VoidReasonCode { get; set; } + + public string VoidReasonText { get; set; } + + public DateTime CreatedOn { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime ModifiedOn { get; set; } + + public string ModifiedByUserId { get; set; } + + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + public object IdValue { get => RmsIncidentAnalysisId; set => RmsIncidentAnalysisId = (string)value; } + public string TableName => "RmsIncidentAnalyses"; + public string IdName => "RmsIncidentAnalysisId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// One property involved in the incident (incident_analysis.properties[]): use, damage, value and loss. + /// Typed rather than a module row because loss and value are summed by department reporting. + /// + public class RmsIncidentProperty : IEntity + { + public string RmsIncidentPropertyId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + /// The owning incident analysis. + public string RecordId { get; set; } + public string RevisionId { get; set; } + /// NERIS location_use code of the property. + public string LocationUse { get; set; } + /// NERIS construction type where the property is a structure. + public string ConstructionType { get; set; } + public string Foundation { get; set; } + public string ExteriorFinish { get; set; } + public string RoofMaterial { get; set; } + public int? StoriesAboveGrade { get; set; } + public int? StoriesBelowGrade { get; set; } + public int? YearBuilt { get; set; } + /// NERIS vacancy code. + public string Vacancy { get; set; } + /// NERIS fire_bldg_damage / exposure_damage rating. + public string DamageType { get; set; } + /// NERIS fire_spread code — how far the fire travelled. + public string FireSpread { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public decimal? ContentsValue { get; set; } + public decimal? ContentsLoss { get; set; } + public string CurrencyCode { get; set; } + /// Contract-shaped remainder of the property payload. + public string DetailJson { get; set; } + public string ProtectedEnvelope { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public int Ordinal { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + public object IdValue { get => RmsIncidentPropertyId; set => RmsIncidentPropertyId = (string)value; } + public string TableName => "RmsIncidentProperties"; + public string IdName => "RmsIncidentPropertyId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// One vehicle involved in the incident (incident_analysis.vehicles[]). VIN, plate and owner identity are + /// personally identifying, so the row carries the inert Protected Data envelope and is classified restricted + /// for per-field authorization inside detail, print, diff and export. + /// + public class RmsIncidentVehicle : IEntity + { + public string RmsIncidentVehicleId { get; set; } + public int DepartmentId { get; set; } + public string ProtectionId { get; set; } + /// The owning incident analysis. + public string RecordId { get; set; } + public string RevisionId { get; set; } + /// AUTOMOBILE for the automobile payload, OTHER for the other-vehicle payload. + public string VehicleKind { get; set; } + /// NERIS auto_make code. + public string Make { get; set; } + public string Model { get; set; } + public int? ModelYear { get; set; } + /// NERIS auto_body_style code. + public string BodyStyle { get; set; } + /// NERIS powertrain code. + public string Powertrain { get; set; } + /// NERIS vehicle_damage rating. + public string DamageType { get; set; } + /// Restricted: vehicle identification number. + public string Vin { get; set; } + /// Restricted: registration plate. + public string LicensePlate { get; set; } + public string LicenseState { get; set; } + public bool WasOccupied { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public string CurrencyCode { get; set; } + public string DetailJson { get; set; } + public string ProtectedEnvelope { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public int Ordinal { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + + public object IdValue { get => RmsIncidentVehicleId; set => RmsIncidentVehicleId = (string)value; } + public string TableName => "RmsIncidentVehicles"; + public string IdName => "RmsIncidentVehicleId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsIncidentReport.cs b/Core/Resgrid.Model/Records/RmsIncidentReport.cs index 2aacdcd9..91d3b6ac 100644 --- a/Core/Resgrid.Model/Records/RmsIncidentReport.cs +++ b/Core/Resgrid.Model/Records/RmsIncidentReport.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using ProtoBuf; @@ -219,6 +219,10 @@ public class RmsIncidentReport : IEntity [ProtoMember(52)] public DateTime? DeletedOn { get; set; } + /// When the retention sweep purged the content (RMS-3, worker 43); the row itself survives as a tombstone. + [ProtoMember(65)] + public DateTime? PurgedOn { get; set; } + public string RecordId => RmsIncidentReportId; public RmsRecordKind RecordKind => RmsRecordKind.IncidentReport; diff --git a/Core/Resgrid.Model/Records/RmsOperationalRecord.cs b/Core/Resgrid.Model/Records/RmsOperationalRecord.cs index 22e61047..04ae647f 100644 --- a/Core/Resgrid.Model/Records/RmsOperationalRecord.cs +++ b/Core/Resgrid.Model/Records/RmsOperationalRecord.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using Newtonsoft.Json; @@ -125,6 +125,9 @@ public class RmsOperationalRecord : IEntity /// Optimistic-concurrency counter (ETag). Incremented by the service on every draft save. public long RowVersion { get; set; } + /// When the retention sweep purged the content (RMS-3, worker 43); the row itself survives as a tombstone. + public DateTime? PurgedOn { get; set; } + public DateTime? DeletedOn { get; set; } [NotMapped] diff --git a/Core/Resgrid.Model/Records/RmsSubmission.cs b/Core/Resgrid.Model/Records/RmsSubmission.cs index 523175cb..dbdc62ee 100644 --- a/Core/Resgrid.Model/Records/RmsSubmission.cs +++ b/Core/Resgrid.Model/Records/RmsSubmission.cs @@ -20,6 +20,13 @@ public enum RmsSubmissionState public static class RmsSubmissionDestinations { public const string Neris = "NERIS"; + + /// + /// The separate incident-analysis filing (RMS-3). It is its own destination string so an incident and its + /// analysis never collide on an idempotency key, and so worker 41 can tell which endpoint a claimed row + /// belongs to without reading the payload. + /// + public const string NerisIncidentAnalysis = "NERIS_ANALYSIS"; } /// diff --git a/Core/Resgrid.Model/Records/SystemPrincipalRecordGrant.cs b/Core/Resgrid.Model/Records/SystemPrincipalRecordGrant.cs new file mode 100644 index 00000000..ab4333a1 --- /dev/null +++ b/Core/Resgrid.Model/Records/SystemPrincipalRecordGrant.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + /// + /// One explicitly configured Record grant for a system principal (Identifier Allocation Registry + /// section 4.4). System principals — the SMTP relay key and the client_credentials service accounts — + /// are not people: they have no Permission rows, no roles and no group membership, so the + /// grant-everything fall-through that departments rely on must never reach them. A grant names a + /// department, a purpose that is written to the access audit on every read, and either department-wide + /// scope or the exact group ids the purpose covers, which is how "subject to the same group scope as a + /// user principal" is expressed for a principal that has no groups of its own. + /// + /// A grant conveys Record_View and nothing else. There is no configuration that produces a + /// mutating or restricted Record policy for a system principal; that is enforced at the two claim + /// issuance sites and asserted by SystemApiKeyRecordPolicyTests. + /// + /// + public sealed class SystemPrincipalRecordGrant + { + public const string DepartmentWideToken = "DepartmentWide"; + public const string GroupsToken = "Groups"; + + public SystemPrincipalRecordGrant(int departmentId, string purpose, bool departmentWide, IEnumerable groupIds) + { + DepartmentId = departmentId; + Purpose = purpose; + DepartmentWide = departmentWide; + GroupIds = (groupIds ?? Enumerable.Empty()).Distinct().OrderBy(i => i).ToList(); + } + + public int DepartmentId { get; } + + /// Why this principal may read Records; written to RmsRecordAccessAudit.Purpose. + public string Purpose { get; } + + /// When true the principal sees the whole department; when false it sees only. + public bool DepartmentWide { get; } + + /// The groups the purpose covers. Empty under a non-department-wide grant means the principal sees nothing. + public IReadOnlyList GroupIds { get; } + + /// The visible-group filter to apply, or null for "the whole department" — the same shape + /// IRecordsAuthorizationService.GetVisibleGroupIdsAsync returns for a user. + public List VisibleGroupIds() => DepartmentWide ? null : GroupIds.ToList(); + + private static string _parsedFrom; + private static IReadOnlyList _parsed = Array.Empty(); + private static readonly object _parseLock = new object(); + + /// Every configured grant. Parsed once per distinct configuration string. + public static IReadOnlyList All() + { + var raw = Config.RecordsSystemAccessConfig.Grants ?? string.Empty; + + lock (_parseLock) + { + if (!string.Equals(raw, _parsedFrom, StringComparison.Ordinal)) + { + _parsed = Parse(raw); + _parsedFrom = raw; + } + + return _parsed; + } + } + + /// Whether any system principal has been granted Record access at all. + public static bool AnyConfigured() => All().Count > 0; + + /// The grant covering a department, or null when the principal has none there. + public static SystemPrincipalRecordGrant For(int departmentId) + { + if (departmentId <= 0) + return null; + + return All().FirstOrDefault(g => g.DepartmentId == departmentId); + } + + /// + /// Parses the configured grant string. A malformed entry is skipped rather than widened: a grant that + /// cannot be read is not a grant, so the principal is denied. + /// + public static IReadOnlyList Parse(string raw) + { + var grants = new List(); + + if (string.IsNullOrWhiteSpace(raw)) + return grants; + + foreach (var entry in raw.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var parts = entry.Split('|'); + if (parts.Length != 3) + continue; + + if (!int.TryParse(parts[0].Trim(), out var departmentId) || departmentId <= 0) + continue; + + var purpose = parts[1].Trim(); + if (string.IsNullOrWhiteSpace(purpose)) + continue; + + var scope = parts[2].Trim(); + + if (string.Equals(scope, DepartmentWideToken, StringComparison.OrdinalIgnoreCase)) + { + grants.Add(new SystemPrincipalRecordGrant(departmentId, purpose, true, null)); + continue; + } + + if (!scope.StartsWith(GroupsToken + ":", StringComparison.OrdinalIgnoreCase)) + continue; + + var groupIds = scope.Substring(GroupsToken.Length + 1) + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(v => int.TryParse(v.Trim(), out var id) ? id : 0) + .Where(id => id > 0) + .ToList(); + + grants.Add(new SystemPrincipalRecordGrant(departmentId, purpose, false, groupIds)); + } + + // A department named twice is ambiguous; the first entry wins so the configuration is deterministic. + return grants.GroupBy(g => g.DepartmentId).Select(g => g.First()).ToList(); + } + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs index ae9c2a20..d63c677a 100644 --- a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -13,6 +13,15 @@ public sealed class RmsIncidentReportQuery public int? CallId { get; set; } public string OwnerUserId { get; set; } public int? StationGroupId { get; set; } + + /// + /// When non-null, restricts to reports whose group scope intersects these ids or whose author, owner or + /// reviewer is — the same rule CanUserViewRecordAsync applies per record. + /// Filtering here rather than after paging is what keeps CountAsync and the page links honest. + /// + public IList VisibleGroupIds { get; set; } + + public string ViewerUserId { get; set; } public int Skip { get; set; } public int Take { get; set; } = 50; } @@ -30,6 +39,8 @@ public interface IRmsIncidentReportsRepository : IRepository Task> GetYearsAsync(int departmentId); Task GetMaxRecordNumberSequenceAsync(int departmentId, string numberPrefix); Task TryBumpRowVersionAsync(int departmentId, string reportId, long expectedRowVersion, CancellationToken cancellationToken = default); + /// Retention candidates (RMS-3, worker 43): live, closed reports finalized before the cutoff, oldest first. + Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take); } /// Shared contract of every per-report child row set: a working draft (RevisionId null) and immutable revision copies. @@ -47,6 +58,30 @@ public interface IRmsAidsRepository : IRmsIncidentChildRepository { } public interface IRmsLocationsRepository : IRmsIncidentChildRepository { } public interface IRmsNarrativesRepository : IRmsIncidentChildRepository { } + // RMS-3 conditional sections (registry M0167) and the restricted casualty/exposure classes (M0168). Each is + // the same draft/revision child shape, so nothing about revisioning, export or print needs a special case. + public interface IRmsIncidentModulesRepository : IRmsIncidentChildRepository + { + /// The draft rows of one module kind, in ordinal order; the authoring surface edits a kind at a time. + Task> GetForRecordByKindAsync(int departmentId, string recordId, string revisionId, RmsIncidentModuleKind kind); + } + + public interface IRmsIncidentResourcesRepository : IRmsIncidentChildRepository { } + public interface IRmsCasualtyRescuesRepository : IRmsIncidentChildRepository { } + public interface IRmsExposuresRepository : IRmsIncidentChildRepository { } + public interface IRmsIncidentPropertiesRepository : IRmsIncidentChildRepository { } + public interface IRmsIncidentVehiclesRepository : IRmsIncidentChildRepository { } + + public interface IRmsIncidentAnalysesRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string analysisId); + /// The analysis for an incident report; one per report, null when none has been started. + Task GetForReportAsync(int departmentId, string incidentReportId); + /// Analyses finalized but not yet filed because their incident had no NERIS id at the time. + Task> GetAwaitingIncidentAsync(int departmentId, int take); + Task CountByStateAsync(int departmentId, RmsIncidentAnalysisState state); + } + public interface IRmsValidationIssuesRepository : IRepository { Task> GetForRecordAsync(int departmentId, string recordId); @@ -61,7 +96,8 @@ public interface IRmsSubmissionsRepository : IRepository Task GetByIdempotencyKeyAsync(string idempotencyKey); /// Leases due Queued/AwaitingDestination submissions across departments for one worker sweep (plan 5.3: no transaction across the destination call). Task> ClaimDueBatchAsync(string leaseOwner, TimeSpan leaseDuration, int batchSize, DateTime utcNow, CancellationToken cancellationToken = default); - Task CountByStateAsync(int state); + /// Queue depth for one department; the settings screen must never show another department's rows. + Task CountByStateAsync(int departmentId, int state); /// Marks every non-terminal submission of the record Superseded (a new revision issued a new idempotency key). Task SupersedeOpenForRecordAsync(int departmentId, string recordId, string exceptSubmissionId, DateTime utcNow, CancellationToken cancellationToken = default); } diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs index df50e01c..38f3f5a8 100644 --- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -46,6 +46,8 @@ public interface IRmsOperationalRecordsRepository : IRepository> GetOpenAsync(int departmentId); /// Live Finalized/Amended Records whose FinalizedOn is at or after the instant. Task> GetFinalizedSinceAsync(int departmentId, DateTime sinceUtc); + /// Retention candidates (RMS-3, worker 43): live, closed Records finalized before the cutoff, oldest first. + Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take); /// Live Records with no RmsRecordGroupScope row: they stay department-wide under group scoping (plan 5.7.1). Task CountWithoutGroupScopeAsync(int departmentId); Task> GetYearsAsync(int departmentId); @@ -91,6 +93,12 @@ public interface IRmsRecordAttachmentsRepository : IRepository> GetMetadataForRecordAsync(int departmentId, string recordId); /// Loads Data; authorized per Record by the caller on every request. Task GetByIdForDepartmentAsync(int departmentId, string attachmentId); + /// + /// Attachments still sitting at — stored because the scanner + /// was unreachable and the department chose availability over fail-closed. Worker 43 rescans them; without + /// this sweep a Pending attachment would stay unscanned forever (RMS-1 gap closed in RMS-3). + /// + Task> GetPendingScanAsync(int departmentId, int take); } public interface IRmsExternalReferencesRepository : IRepository @@ -142,7 +150,12 @@ public interface IRmsRecordSearchProjectionsRepository : IRepository CountAsync(int departmentId, RmsRecordQuery query); Task> GetYearsAsync(int departmentId); /// Projections (including soft-deleted ones) modified after , oldest first; the search index catch-up feed. - Task> GetModifiedSinceAsync(int departmentId, DateTime? since, int take); + /// + /// Rows after the (ModifiedOn, id) cursor, oldest first. The id is part of the cursor because several rows + /// routinely share a modified timestamp: a timestamp-only cursor skips whatever sat after the page break + /// inside that group, and an offline client never learns those rows changed. + /// + Task> GetModifiedSinceAsync(int departmentId, DateTime? since, int take, string sinceId = null); /// Live projections by record id, for post-retrieval loading of search hits. Task> GetByIdsAsync(int departmentId, IEnumerable recordIds); } @@ -166,4 +179,66 @@ public interface IRmsRecordSharesRepository : IRepository { Task> GetForRecordAsync(int departmentId, string recordId); } + + /// + /// Persisted due state per (Record, obligation) — registry M0170, RMS-3. The uniqueness of that pair is what + /// makes worker 42's "at most once per due-state transition" guarantee hold across missed and repeated runs. + /// + /// + /// Immutable evidence artifacts (registry M0169, RMS-3). There is no update path by design: a correction + /// supersedes an artifact rather than editing it, so the checksum of what was attested to stays true. + /// + public interface IRmsEvidenceArtifactsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string artifactId); + /// null returns the working-draft artifacts. + Task> GetForRecordAsync(int departmentId, string recordId, string revisionId, bool includeSuperseded); + /// The current artifact of one kind on the draft, or null; capture supersedes it. + Task GetCurrentDraftOfKindAsync(int departmentId, string recordId, RmsEvidenceKind kind, string sourceEntityId); + /// Stamps every unbound draft artifact with the revision being written at finalize. + Task BindDraftToRevisionAsync(int departmentId, string recordId, string revisionId, DateTime utcNow, CancellationToken cancellationToken = default); + Task CountForRecordAsync(int departmentId, string recordId); + } + + public interface IRmsRecordDueStatesRepository : IRepository + { + Task GetAsync(int departmentId, string recordId, RmsRecordObligation obligation); + Task> GetForRecordAsync(int departmentId, string recordId); + /// Open obligations (not yet cleared) for a department, oldest deadline first. + Task> GetOpenForDepartmentAsync(int departmentId, int take); + /// Count of obligations currently sitting overdue; the accountability view and dashboards read it. + Task CountOverdueAsync(int departmentId); + Task ClearForRecordAsync(int departmentId, string recordId, DateTime utcNow, CancellationToken cancellationToken = default); + } + + /// Public-records requests (registry M0171, RMS-3); the statutory clock is what these are read by. + public interface IRmsDisclosureRequestsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string requestId); + Task> GetForDepartmentAsync(int departmentId, IEnumerable states, int skip, int take); + Task CountByStateAsync(int departmentId, RmsDisclosureState state); + /// Open requests past their statutory due date; the number an officer is accountable for. + Task CountOverdueAsync(int departmentId, DateTime utcNow); + Task GetMaxRequestNumberSequenceAsync(int departmentId, string numberPrefix); + } + + /// + /// Immutable produced sets (registry M0171, RMS-3). A production is never edited: a supplemental release is a + /// new production, so what was handed over stays exactly what was handed over. + /// + public interface IRmsDisclosureProductionsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string productionId); + Task> GetForRequestAsync(int departmentId, string requestId); + Task GetMaxProductionNumberAsync(int departmentId, string requestId); + } + + public interface IRmsRecordLegalHoldsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string holdId); + /// Every hold still in force for the department; the retention sweep loads them once per pass. + Task> GetActiveForDepartmentAsync(int departmentId); + Task> GetForRecordAsync(int departmentId, string recordId); + Task> GetAllForDepartmentAsync(int departmentId); + } } diff --git a/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs b/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs new file mode 100644 index 00000000..a1a1dcce --- /dev/null +++ b/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// The NERIS incident-analysis filing (RMS-3, registry M0167): the fire/hazmat investigation the contract + /// posts to /incident_analysis/{neris_id_incident} after the incident itself exists. + /// + /// It is a second submittable artifact for the same incident, not a section of it, which is the whole reason + /// it has its own service: an analysis that will not validate must never block the incident report, and an + /// incident that is still in flight must not lose its analysis. Finalizing an analysis before its incident is + /// filed is allowed and expected — the submission simply waits for the incident's NERIS id. + /// + /// + public interface IIncidentAnalysisService + { + /// Starts (or returns) the analysis for an incident report; one per report. + Task StartForReportAsync(int departmentId, string userId, string incidentReportId, RmsOriginClient origin = RmsOriginClient.Web, CancellationToken cancellationToken = default); + + Task GetAsync(int departmentId, string analysisId, bool includeHistory = false); + + Task GetForReportAsync(int departmentId, string incidentReportId, bool includeHistory = false); + + /// + /// ETag-guarded draft save. false keeps the stored VIN and plate on + /// each vehicle row rather than accepting or erasing them. + /// + Task SaveDraftAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, IncidentAnalysisDraftInput input, bool canWriteRestricted = true, CancellationToken cancellationToken = default); + + /// Local validation against the pinned contract; never touches the incident's own issue list. + Task> ValidateAsync(int departmentId, string analysisId, CancellationToken cancellationToken = default); + + /// Writes the immutable revision and queues the filing when the incident is already at the destination. + Task FinalizeAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, CancellationToken cancellationToken = default); + + /// Queues (or re-queues) the current revision; used when the incident was filed after the analysis was finalized. + Task QueueSubmissionAsync(int departmentId, string userId, string analysisId, CancellationToken cancellationToken = default); + + /// Queues every finalized analysis whose incident has since been filed; driven by worker 41. + Task QueueAwaitingIncidentAsync(int departmentId, CancellationToken cancellationToken = default); + + Task VoidAsync(int departmentId, string userId, string analysisId, string reasonCode, string reasonText, CancellationToken cancellationToken = default); + + /// The snapshot the mapper consumes, from the draft rows or from a revision's copies. + Task BuildSnapshotAsync(int departmentId, string analysisId, string revisionId = null); + } +} diff --git a/Core/Resgrid.Model/Services/IIncidentReportsService.cs b/Core/Resgrid.Model/Services/IIncidentReportsService.cs index 7348ccd7..bf25691f 100644 --- a/Core/Resgrid.Model/Services/IIncidentReportsService.cs +++ b/Core/Resgrid.Model/Services/IIncidentReportsService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Resgrid.Model.Repositories; @@ -19,7 +19,12 @@ public interface IIncidentReportsService Task GetForCallAsync(int departmentId, int callId); - Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, CancellationToken cancellationToken = default); + /// + /// ETag-guarded draft save. false keeps the restricted halves of the + /// casualty rows as they stand instead of accepting or erasing them, so a reviewer without + /// RecordRestricted_View can still correct the rest of the report (RMS-3). + /// + Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, bool canWriteRestricted = true, CancellationToken cancellationToken = default); /// Runs local validation (and the destination's validate endpoint when asked and configured) and stores the issues on the report. Task> ValidateAsync(int departmentId, string reportId, bool includeDestination, CancellationToken cancellationToken = default); @@ -51,6 +56,12 @@ public interface IIncidentReportsService Task> GetYearsAsync(int departmentId); + /// + /// The conditional sections this report's selected incident types demand or suggest (RMS-3). The authoring + /// surfaces render exactly this, so a client never keeps its own copy of the progressive rules. + /// + Task> GetSectionRequirementsAsync(int departmentId, string reportId); + /// The snapshot the mapper consumes, from the draft rows or from a revision's copies. Task BuildSnapshotAsync(int departmentId, string reportId, string revisionId = null); diff --git a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs index 3acdfdf8..7396060b 100644 --- a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs +++ b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs @@ -25,6 +25,14 @@ public interface IRecordsAuthorizationService /// Per-Record check applied on every detail, attachment and deep-link read. Task CanUserViewRecordAsync(string userId, string recordId, int departmentId); + /// + /// Per-Record check for a system principal reading under a configured grant (Identifier Allocation + /// Registry section 4.4). A system principal is nobody: none of the always-visible author/owner/ + /// reviewer/participant cases apply to it, so a non-department-wide grant sees exactly the groups it + /// names and nothing else. + /// + Task CanSystemPrincipalViewRecordAsync(SystemPrincipalRecordGrant grant, string recordId); + /// What GroupScoped would hide, by group, before an administrator confirms it (plan 5.7.1 "Turning it on"). Task PreviewGroupScopingAsync(int departmentId); } diff --git a/Core/Resgrid.Model/Services/IRecordsDashboardService.cs b/Core/Resgrid.Model/Services/IRecordsDashboardService.cs new file mode 100644 index 00000000..5b266a3c --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsDashboardService.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// The Records work queues an officer opens the module to look at (RMS plan RMS-3): incomplete, awaiting + /// review, rejected, accepted, overdue — plus the statutory disclosure clock and the crosswalk coverage that + /// decides whether a filing will map cleanly at all. + /// + /// Every count is department-scoped and group-scope-aware, so a dashboard never tells a member that records + /// exist which their queue will not show them. + /// + /// + public interface IRecordsDashboardService + { + Task GetAsync(int departmentId, string userId, CancellationToken cancellationToken = default); + + /// + /// NERIS crosswalk coverage (RMS plan RMS-3, "crosswalk reporting"): which of the department's own call + /// types map to a NERIS incident type and which do not. An unmapped type is a filing that will need manual + /// classification on the night, so this is a gap report, not a statistic. + /// + Task GetCrosswalkCoverageAsync(int departmentId, CancellationToken cancellationToken = default); + } + + /// Queue counts across both Records aggregates plus the obligations and requests that carry a clock. + public class RecordsDashboard + { + public DateTime GeneratedOn { get; set; } = DateTime.UtcNow; + + /// Operational Records still being authored. + public int OperationalDrafts { get; set; } + + public int OperationalAwaitingReview { get; set; } + + public int OperationalReturned { get; set; } + + /// Incident reports not yet finalized — the "incomplete" queue. + public int IncidentIncomplete { get; set; } + + public int IncidentAwaitingReview { get; set; } + + /// Finalized and queued or in flight to the destination. + public int IncidentSubmitted { get; set; } + + public int IncidentAccepted { get; set; } + + /// Rejected by the destination and not yet corrected; the queue that costs a department money. + public int IncidentRejected { get; set; } + + /// Obligations currently sitting overdue (worker 42's persisted due states). + public int Overdue { get; set; } + + /// Incident analyses finalized but not yet filed, usually waiting on their incident. + public int AnalysesAwaitingFiling { get; set; } + + public int DisclosuresOpen { get; set; } + + /// Open public-records requests past their statutory due date. + public int DisclosuresOverdue { get; set; } + + /// Set when a count could not be produced; the dashboard degrades rather than failing whole. + public List Warnings { get; set; } = new List(); + } + + /// One local code and whether it maps to a NERIS value. + public class NerisCrosswalkCoverageItem + { + public string SetKey { get; set; } + public string LocalCode { get; set; } + public string NerisCode { get; set; } + public bool Mapped => !string.IsNullOrWhiteSpace(NerisCode); + } + + /// Crosswalk gap report for a department. + public class NerisCrosswalkCoverage + { + public string ContractVersion { get; set; } + public int TotalLocalCodes { get; set; } + public int MappedCount { get; set; } + public int UnmappedCount { get; set; } + + /// Mapped codes whose NERIS value is not in the pinned contract any more; a silent submission failure waiting to happen. + public int StaleMappingCount { get; set; } + + public List Items { get; set; } = new List(); + + public List Warnings { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs b/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs new file mode 100644 index 00000000..05f80857 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Threading; +using Resgrid.Model.Repositories; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Public-records and access-to-information workflow (RMS plan section 4.7, registry M0171, RMS-3). + /// + /// Section 5.8 asks for public-records export and redaction as a control. For a public agency this is a + /// statutory obligation with a clock, so it is a tracked request rather than an export button: log the + /// request, resolve its scope through the same Records authorization path everything else uses, redact + /// against the classification catalog into a new immutable artifact, and release with a produced-set + /// snapshot so a later amendment cannot silently change what was handed over. + /// + /// + /// Callers must hold RecordDisclosure_Update; this service assumes the policy check already happened + /// at the controller and enforces the workflow rules, not the permission. + /// + /// + public interface IRecordsDisclosureService + { + Task CreateRequestAsync(int departmentId, string userId, RmsDisclosureRequest request, CancellationToken cancellationToken = default); + + Task GetAsync(int departmentId, string requestId); + + Task> QueryAsync(int departmentId, IEnumerable states, int skip = 0, int take = 50); + + /// Saves the scope query and narrative; refused once a production exists, because the scope is what was produced against. + Task SaveScopeAsync(int departmentId, string userId, string requestId, string scopeNarrative, RmsRecordQuery scope, string redactionProfile, CancellationToken cancellationToken = default); + + /// + /// What the scope resolves to right now, through the same authorization and group-scope path as the + /// Records queue. Drafts are listed but never producible: an unfinished report is not a public record. + /// + Task PreviewScopeAsync(int departmentId, string userId, string requestId, int take = 200); + + /// + /// Builds a new immutable production: redacted content, the produced-set snapshot, a redaction log and a + /// checksum. Never mutates a source revision. + /// + Task ProduceAsync(int departmentId, string userId, string requestId, string redactionProfile = null, CancellationToken cancellationToken = default); + + /// Releases a prepared production to the requester and closes the statutory clock. + Task ReleaseAsync(int departmentId, string userId, string productionId, CancellationToken cancellationToken = default); + + Task> GetProductionsAsync(int departmentId, string requestId); + + /// Closes a request without release — denied under an exemption, or withdrawn. A reason is required. + Task CloseAsync(int departmentId, string userId, string requestId, RmsDisclosureState disposition, string reason, CancellationToken cancellationToken = default); + + /// Re-computes a production's checksum from its stored artifact; false means it was tampered with. + Task VerifyProductionAsync(int departmentId, string productionId); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsDueStateService.cs b/Core/Resgrid.Model/Services/IRecordsDueStateService.cs new file mode 100644 index 00000000..6350c8b8 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsDueStateService.cs @@ -0,0 +1,42 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Worker command 42 (RmsDueStateEvaluationCommand, RMS plan section 4.7 / RMS-3): once a day per activated + /// department, work out what every open Record is late for, and emit trigger 112 and notification 32 exactly + /// on the transition into overdue. + /// + /// The guarantee the plan asks for — at most once per record and due-state transition — is carried by the + /// persisted row, never inferred from when the worker last ran. A run that is + /// missed emits late rather than not at all; a run that repeats emits nothing the first one already did. + /// + /// + public interface IRecordsDueStateService + { + Task SweepAsync(CancellationToken cancellationToken = default); + + /// Evaluates one department; public so a single department can be driven and tested directly. + Task EvaluateDepartmentAsync(int departmentId, CancellationToken cancellationToken = default); + + /// Clears every open obligation on a Record — called when it is finalized, voided or cancelled. + Task ClearForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default); + } + + /// + /// Worker command 43 (RmsRetentionAndPurgeCommand, RMS plan RMS-3): retention, legal hold and attachment + /// purge, plus the rescan pass for attachments the scanner could not reach when they were uploaded. + /// + /// A purge leaves a content-free tombstone (plan section 4.9): the Record row, its number and its lifecycle + /// history survive so a reference to it still resolves, and only the content goes. Nothing under an active + /// legal hold is purged, and the refusal is audited rather than silent. + /// + /// + public interface IRecordsRetentionService + { + Task SweepAsync(CancellationToken cancellationToken = default); + + Task ProcessDepartmentAsync(int departmentId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs b/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs new file mode 100644 index 00000000..ef3ca295 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// One evidence source's capture rule (RMS plan section 4.5, RMS-3). An adapter reads its own subsystem, + /// decides what an authorized snapshot of it looks like, and hands back a bounded manifest. It never writes, + /// never persists, and never returns a live handle to the source — owns + /// serialization, checksum, classification, retention and audit so those cannot vary per source. + /// + public interface IRecordEvidenceAdapter + { + RmsEvidenceKind Kind { get; } + + /// + /// False when the source subsystem is not present in this build or not configured for the department. + /// The adapter still ships — the plan requires all six — and says why it has nothing rather than + /// pretending the evidence does not exist. + /// + Task IsAvailableAsync(int departmentId); + + Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default); + } + + /// + /// Capture and read of immutable evidence artifacts (RMS plan sections 4.5, 5.2; registry M0169). + /// + /// Every capture proves the same five things regardless of source, which is why they run through one service: + /// the caller was authorized, the provenance is recorded, the classification is decided at capture, the + /// content is checksummed, and the retention rule is attached. An artifact is never updated; a correction + /// supersedes it and both remain readable. + /// + /// + public interface IRecordsEvidenceService + { + /// Which of the six sources can actually produce evidence for this department right now. + Task> GetSourceStatesAsync(int departmentId); + + /// + /// Captures one artifact. Throws when the Record is missing or terminal, when the reason is absent, or + /// when the caller lacks the restricted grant for a source that classifies restricted. + /// + Task CaptureAsync(RecordEvidenceCaptureRequest request, bool canCaptureRestricted = true, CancellationToken cancellationToken = default); + + /// Artifacts on the working draft, or on a revision when one is named. + Task> GetForRecordAsync(int departmentId, string recordId, string revisionId = null, bool includeSuperseded = false); + + Task GetAsync(int departmentId, string artifactId); + + /// Binds the draft's artifacts to a revision at finalize; artifacts already bound are untouched. + Task BindToRevisionAsync(int departmentId, string recordId, string revisionId, CancellationToken cancellationToken = default); + + /// Re-computes the checksum from the stored manifest; false means the artifact was tampered with. + Task VerifyAsync(int departmentId, string artifactId); + } + + /// Whether one evidence source can produce anything for a department, and why not when it cannot. + public class RecordEvidenceSourceState + { + public RmsEvidenceKind Kind { get; set; } + public bool Available { get; set; } + public string Reason { get; set; } + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsNotificationService.cs b/Core/Resgrid.Model/Services/IRecordsNotificationService.cs index 4a775380..284e93e6 100644 --- a/Core/Resgrid.Model/Services/IRecordsNotificationService.cs +++ b/Core/Resgrid.Model/Services/IRecordsNotificationService.cs @@ -1,4 +1,4 @@ -using System.Threading; +using System.Threading; using System.Threading.Tasks; namespace Resgrid.Model.Services @@ -16,5 +16,12 @@ public interface IRecordsNotificationService /// EventTypes 33: the destination rejected the author's incident report; codes and field paths only, plus a link. Task NotifySubmissionRejectedAsync(int departmentId, string reportId, CancellationToken cancellationToken = default); + + /// + /// EventTypes 32 (RMS-3, worker 42): an obligation on a Record has gone overdue. Targeted at whoever the + /// obligation rests with — the reviewer for a review, the author for a correction or a resubmission — and + /// carries the reference, the obligation and how late it is. Never record content. + /// + Task NotifyObligationOverdueAsync(int departmentId, string recordId, RmsRecordObligation obligation, CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Services/IRecordsService.cs b/Core/Resgrid.Model/Services/IRecordsService.cs index 0c290551..e5bce371 100644 --- a/Core/Resgrid.Model/Services/IRecordsService.cs +++ b/Core/Resgrid.Model/Services/IRecordsService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -61,7 +61,7 @@ public interface IRecordsService Task> GetYearsAsync(int departmentId); /// Delta cursor for clients (plan section 5.3): projections modified after , oldest first, including tombstones (cancelled, voided, deleted). - Task> GetChangesSinceAsync(int departmentId, DateTime? since, int take); + Task> GetChangesSinceAsync(int departmentId, DateTime? since, int take, string sinceId = null); Task AddAttachmentAsync(int departmentId, string userId, string recordId, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 2b9c507c..ffc14a3a 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -159,6 +159,17 @@ private static List GetCommon() => new TemplateVariableDescriptor("submission.completed_on", "When the submission reached a final state (UTC)", "datetime", false), }; + // obligation.* rides only on trigger 112 (RMS-3): what the record is late for and by how long. Nothing about + // the record's content — a workflow that chases an overdue report never needs to read one. + private static readonly List ObligationVariables = new List + { + new TemplateVariableDescriptor("obligation.type", "What the record is late for (Review, Correction, Submission)", "string", false), + new TemplateVariableDescriptor("obligation.due_on", "When the obligation fell due (UTC)", "datetime", false), + new TemplateVariableDescriptor("obligation.overdue_hours", "Hours past due at evaluation time", "int", false), + new TemplateVariableDescriptor("obligation.responsible_user_id", "Who the obligation rests with", "string", false), + new TemplateVariableDescriptor("obligation.overdue_count", "How many times this obligation has gone overdue", "int", false), + }; + // review.* rides only on the two review-path triggers: review bookkeeping, never record content. private static readonly List ReviewVariables = new List { @@ -669,6 +680,13 @@ public static IReadOnlyList GetVariableCatalog(Workf list.AddRange(RecordChangeVariables); list.AddRange(SubmissionVariables); break; + + case WorkflowTriggerEventType.RecordOverdue: + list.AddRange(RecordEventVariables); + list.AddRange(RecordVariables); + list.Add(new TemplateVariableDescriptor("record.kind", "Record kind (Operational or IncidentReport)", "string", false)); + list.AddRange(ObligationVariables); + break; } return list.AsReadOnly(); diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 27eb4d66..6ac02931 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -99,7 +99,14 @@ public enum WorkflowTriggerEventType RecordSubmissionRejected = 110, /// Delivery exhausted its retries or requires operator attention. - RecordSubmissionFailed = 111 + RecordSubmissionFailed = 111, + + // RMS-3 obligation trigger (plan section 4.7). Emitted by the due-state evaluation worker (42) on the + // transition into overdue only, from a persisted RmsRecordDueState row, so a missed or repeated worker run + // can neither double-emit nor silently skip. + + /// A Record passed the due time of a review, correction or resubmission obligation. + RecordOverdue = 112 } public static class WorkflowTriggerEventTypes diff --git a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs new file mode 100644 index 00000000..f26ca240 --- /dev/null +++ b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records.Evidence +{ + /// + /// The six evidence adapters RMS-3 ships, none optional (RMS plan section 4.5). + /// + /// They live together because the interesting thing about them is that they are consistent: each reads one + /// subsystem, bounds what it takes, records where it came from, and hands back a manifest. + /// owns everything after that — serialization, checksum, the restricted + /// grant check, retention and audit — so no adapter can be quietly weaker than its siblings. + /// + /// + /// None of them hydrates a live source. That is the constraint the plan puts on this whole area: RMS captures + /// an authorized snapshot with provenance, and never becomes a second copy of Chat, Tracking or Inventory. + /// + /// + internal static class EvidenceLimits + { + /// Widest coverage window any time-series adapter will honour. + public static readonly TimeSpan MaxCoverage = TimeSpan.FromHours(24); + + /// Most source rows a single artifact may carry; a manifest is evidence, not an export. + public const int MaxItems = 500; + + public static (DateTime start, DateTime end) ClampWindow(DateTime? start, DateTime? end) + { + var to = end ?? DateTime.UtcNow; + var from = start ?? to - MaxCoverage; + if (to < from) + (from, to) = (to, from); + if (to - from > MaxCoverage) + from = to - MaxCoverage; + return (from, to); + } + } + + /// + /// Apparatus and equipment readiness at the time of the call — a checklist/work-order manifest with source + /// completion IDs, coverage period and checksum (plan section 4.5). + /// + /// The checklists and maintenance module this reads from is planned but not built. The adapter ships anyway, + /// because the plan requires all six and because "unavailable" and "there was no readiness evidence" are + /// different answers: an author who sees the source reported as absent knows not to draw a conclusion from + /// the empty list. When the module lands, only changes. + /// + /// + public class ReadinessPacketEvidenceAdapter : IRecordEvidenceAdapter + { + public const string SourceSubsystem = "Checklists"; + public const string UnavailableReason = "The checklists and maintenance module is not present in this build."; + + public RmsEvidenceKind Kind => RmsEvidenceKind.ReadinessPacket; + + public Task IsAvailableAsync(int departmentId) => Task.FromResult(false); + + public Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) + { + return Task.FromResult(RecordEvidenceCapture.Unavailable(UnavailableReason)); + } + } + + /// + /// The recorded dispatch decision for a Call: card and version, alarm level, mode, the resource summary the + /// card produced, and any shortfall (plan section 4.5). + /// + /// Only what Run Cards actually audited is captured. The plan is explicit that RMS does not infer acceptance + /// where Run Cards did not record it, so nothing here reconstructs whether a recommendation was followed. + /// + /// + public class RunCardActivationEvidenceAdapter : IRecordEvidenceAdapter + { + public const string SourceSubsystem = "RunCards"; + public const string IdentifierScheme = "resgrid:runcardactivation"; + + private readonly IRunCardActivationsRepository _activations; + + public RunCardActivationEvidenceAdapter(IRunCardActivationsRepository activations) + { + _activations = activations; + } + + public RmsEvidenceKind Kind => RmsEvidenceKind.RunCardActivation; + + public Task IsAvailableAsync(int departmentId) => Task.FromResult(true); + + public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) + { + if (!request.CallId.HasValue || request.CallId.Value <= 0) + return RecordEvidenceCapture.Unavailable("Run card evidence needs the Call the record hangs off."); + + var activations = (await _activations.GetActivationsByCallIdAsync(request.CallId.Value))? + .Where(a => a != null && a.DepartmentId == request.DepartmentId) + .OrderBy(a => a.CreatedOn) + .Take(EvidenceLimits.MaxItems) + .ToList() ?? new List(); + + if (activations.Count == 0) + return RecordEvidenceCapture.Unavailable("No run card activation was recorded for this call."); + + var items = activations.Select(a => new + { + activation_id = a.RunCardActivationId, + run_card_id = a.RunCardId, + alarm_level = a.AlarmLevel, + mode = a.ModeUsed, + auto_dispatched = a.WasAutoDispatched, + activated_on = a.CreatedOn, + activated_by_user_id = a.CreatedByUserId, + // The card's own recorded outcome, verbatim: what it selected and any shortfall it reported. + result = TryParse(a.ResultJson) + }).ToList(); + + return new RecordEvidenceCapture + { + Title = $"Run card activation for call {request.CallId.Value}", + SourceSubsystem = SourceSubsystem, + SourceEntityType = nameof(RunCardActivation), + SourceEntityId = string.Join(",", activations.Select(a => a.RunCardActivationId)), + IdentifierScheme = IdentifierScheme, + CoverageStart = activations.First().CreatedOn, + CoverageEnd = activations.Last().CreatedOn, + SourceItemCount = items.Count, + Classification = RmsEvidenceClassification.Unrestricted, + Manifest = new { call_id = request.CallId.Value, activations = items } + }; + } + + private static JToken TryParse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try { return JToken.Parse(json); } + catch (JsonReaderException) { return null; } + } + } + + /// + /// Bounded unit tracking evidence (plan section 4.5): stable fix ID, fix time, accuracy and the derived + /// staleness at capture. + /// + /// Full tracks are never copied. The window is clamped and the fixes are sampled across it — the + /// point of this evidence is to show where a unit was at the moments that matter, captured before source + /// retention purges them, not to mirror the tracking store into the Records tables. + /// + /// + public class TrackingFixEvidenceAdapter : IRecordEvidenceAdapter + { + public const string SourceSubsystem = "UnitTracking"; + public const string IdentifierScheme = "resgrid:unitlocation"; + + /// Sample points per unit across the coverage window; the bound that keeps this evidence, not an export. + public const int SamplesPerUnit = 24; + + private readonly IUnitLocationRepository _locations; + + public TrackingFixEvidenceAdapter(IUnitLocationRepository locations) + { + _locations = locations; + } + + public RmsEvidenceKind Kind => RmsEvidenceKind.TrackingFix; + + public Task IsAvailableAsync(int departmentId) => Task.FromResult(true); + + public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) + { + var units = (request.UnitIds ?? new List()).Where(u => u > 0).Distinct().ToList(); + if (units.Count == 0) + return RecordEvidenceCapture.Unavailable("Tracking evidence needs at least one unit."); + + var (from, to) = EvidenceLimits.ClampWindow(request.CoverageStart, request.CoverageEnd); + var capturedOn = DateTime.UtcNow; + var step = (to - from).TotalSeconds / Math.Max(1, SamplesPerUnit - 1); + var manifestUnits = new List(); + var total = 0; + + foreach (var unitId in units) + { + cancellationToken.ThrowIfCancellationRequested(); + var seen = new HashSet(); + var fixes = new List(); + + for (var i = 0; i < SamplesPerUnit && total < EvidenceLimits.MaxItems; i++) + { + var at = from.AddSeconds(step * i); + var location = await _locations.GetLastUnitLocationByUnitIdTimestampAsync(unitId, at); + + // Nothing before the sample point, or the same fix the previous sample already returned: + // the unit had not moved, and repeating the row would inflate the manifest without adding fact. + if (location == null || location.Timestamp < from || !seen.Add(location.UnitLocationId)) + continue; + + fixes.Add(new + { + fix_id = location.UnitLocationId, + fix_on = location.Timestamp, + latitude = location.Latitude, + longitude = location.Longitude, + accuracy = location.Accuracy, + altitude = location.Altitude, + speed = location.Speed, + heading = location.Heading, + // Staleness is computed once, at capture: how old the fix already was when it was taken as + // evidence. Recomputing it later against "now" would make old evidence look worse each year. + staleness_seconds = (int)Math.Max(0, (capturedOn - location.Timestamp).TotalSeconds), + timestamp_source = "Device" + }); + total++; + } + + if (fixes.Count > 0) + manifestUnits.Add(new { unit_id = unitId, fixes }); + } + + if (total == 0) + return RecordEvidenceCapture.Unavailable("No tracking fixes were recorded for those units in that window."); + + return new RecordEvidenceCapture + { + Title = $"Tracking fixes for {units.Count} unit(s)", + SourceSubsystem = SourceSubsystem, + SourceEntityType = nameof(UnitLocation), + SourceEntityId = string.Join(",", units), + IdentifierScheme = IdentifierScheme, + CoverageStart = from, + CoverageEnd = to, + SourceItemCount = total, + Classification = RmsEvidenceClassification.Unrestricted, + Manifest = new { captured_on = capturedOn, sampled = true, samples_per_unit = SamplesPerUnit, units = manifestUnits } + }; + } + } + + /// + /// Authorized promotion of selected incident-chat messages into the record (plan section 4.5): message, + /// channel and call IDs, sequence, author and time, edit/moderation state, checksum and capture reason. + /// + /// Only the messages the member explicitly selected are promoted, and only from a channel bound to the + /// record's Call. RMS does not hydrate or retain an entire chat channel, so there is no "promote the channel" + /// path here — that would turn a record attachment into a second chat store. + /// + /// + /// Chat is operational conversation about a live incident, so the result is classified restricted: promoting + /// it into a record that a wider audience can read must not widen who can read the conversation. + /// + /// + public class ChatPromotionEvidenceAdapter : IRecordEvidenceAdapter + { + public const string SourceSubsystem = "Chat"; + public const string IdentifierScheme = "resgrid:chatmessage"; + + private readonly IChatMessageRepository _messages; + private readonly IChatChannelRepository _channels; + + public ChatPromotionEvidenceAdapter(IChatMessageRepository messages, IChatChannelRepository channels) + { + _messages = messages; + _channels = channels; + } + + public RmsEvidenceKind Kind => RmsEvidenceKind.ChatPromotion; + + public Task IsAvailableAsync(int departmentId) => Task.FromResult(true); + + public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) + { + var ids = (request.SourceIds ?? new List()).Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.Ordinal).Take(EvidenceLimits.MaxItems).ToList(); + if (ids.Count == 0) + return RecordEvidenceCapture.Unavailable("Chat evidence needs the messages the member selected."); + + // Channels bound to this record's Call. A message from anywhere else is not incident chat, and + // promoting it would pull an unrelated conversation into an official record. + var allowedChannels = new HashSet(StringComparer.Ordinal); + if (request.CallId.HasValue && request.CallId.Value > 0) + { + foreach (var channel in (await _channels.GetByCallIdAsync(request.CallId.Value))?.Where(c => c != null && c.DepartmentId == request.DepartmentId) ?? Enumerable.Empty()) + allowedChannels.Add(channel.ChatChannelId); + } + + if (allowedChannels.Count == 0) + return RecordEvidenceCapture.Unavailable("No incident chat channel is bound to this call."); + + var promoted = new List(); + var channelIds = new HashSet(StringComparer.Ordinal); + DateTime? first = null, last = null; + + foreach (var id in ids) + { + cancellationToken.ThrowIfCancellationRequested(); + var message = await _messages.GetByIdAsync(id) as ChatMessage; + if (message == null || message.DepartmentId != request.DepartmentId || !allowedChannels.Contains(message.ChatChannelId)) + continue; + + channelIds.Add(message.ChatChannelId); + if (first == null || message.SentOn < first) first = message.SentOn; + if (last == null || message.SentOn > last) last = message.SentOn; + + promoted.Add(new + { + message_id = message.ChatMessageId, + channel_id = message.ChatChannelId, + sequence = message.MessageSeq, + sender_user_id = message.SenderUserId, + sender_unit_id = message.SenderUnitId, + sender_display_name = message.SenderDisplayName, + sent_on = message.SentOn, + // Edit and moderation state travel with the message: evidence that was edited after the fact + // must say so, or the artifact overstates what was said at the time. + edited_on = message.EditedOn, + body = message.Body, + message_type = message.MessageType, + priority = message.Priority, + thread_root_message_id = message.ThreadRootMessageId + }); + } + + if (promoted.Count == 0) + return RecordEvidenceCapture.Unavailable("None of the selected messages belong to this call's chat."); + + return new RecordEvidenceCapture + { + Title = $"{promoted.Count} promoted chat message(s)", + SourceSubsystem = SourceSubsystem, + SourceEntityType = nameof(ChatMessage), + SourceEntityId = string.Join(",", channelIds), + IdentifierScheme = IdentifierScheme, + CoverageStart = first, + CoverageEnd = last, + SourceItemCount = promoted.Count, + Classification = RmsEvidenceClassification.Restricted, + Manifest = new { call_id = request.CallId, channel_ids = channelIds.ToList(), messages = promoted } + }; + } + } + + /// + /// Supplies and controlled-substance usage from the inventory ledger (plan section 4.5). RMS references the + /// ledger rather than creating another stock system, so this reads the RMS-1 usage adapter and freezes what + /// it returned into a manifest. + /// + /// Controlled substances are the reason this classifies restricted: quantities drawn on a call are + /// accountable data, and an artifact makes them readable long after the ledger row has moved on. + /// + /// + public class InventoryUsageEvidenceAdapter : IRecordEvidenceAdapter + { + public const string SourceSubsystem = "Inventory"; + public const string IdentifierScheme = "resgrid:inventory"; + + private readonly IRmsInventoryUsageAdapter _usage; + + public InventoryUsageEvidenceAdapter(IRmsInventoryUsageAdapter usage) + { + _usage = usage; + } + + public RmsEvidenceKind Kind => RmsEvidenceKind.InventoryUsage; + + public Task IsAvailableAsync(int departmentId) => Task.FromResult(true); + + public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) + { + var usage = (await _usage.GetUsageForRecordAsync(request.DepartmentId, request.RecordId))? + .Take(EvidenceLimits.MaxItems).ToList() ?? new List(); + + if (usage.Count == 0) + return RecordEvidenceCapture.Unavailable("No inventory usage is recorded against this record."); + + var items = usage.Select(u => new + { + reference_id = u.ReferenceId, + source = u.Source, + inventory_id = u.InventoryId, + quantity = u.Quantity, + note = u.Note, + captured_by_user_id = u.CapturedByUserId, + captured_on = u.CapturedOn + }).ToList(); + + return new RecordEvidenceCapture + { + Title = $"{items.Count} inventory usage entr{(items.Count == 1 ? "y" : "ies")}", + SourceSubsystem = SourceSubsystem, + SourceEntityType = nameof(RmsInventoryUsage), + SourceEntityId = request.RecordId, + IdentifierScheme = IdentifierScheme, + CoverageStart = usage.Min(u => u.CapturedOn), + CoverageEnd = usage.Max(u => u.CapturedOn), + SourceItemCount = items.Count, + Classification = RmsEvidenceClassification.Restricted, + Manifest = new { record_id = request.RecordId, usage = items } + }; + } + } + + /// + /// Participant certification and qualification validity at the incident time (plan section 4.5). + /// + /// The plan draws a hard line here: a participant snapshot may carry the certification's type, status and + /// validity, while license numbers and source documents stay protected and Certification-owned. So + /// this manifest records what was valid and when it expired, and never the number on the card or the scanned + /// document behind it. + /// + /// + public class CertificationSnapshotEvidenceAdapter : IRecordEvidenceAdapter + { + public const string SourceSubsystem = "Certifications"; + public const string IdentifierScheme = "resgrid:personnelcertification"; + + private readonly ICertificationService _certifications; + private readonly IRmsRecordParticipantsRepository _participants; + + public CertificationSnapshotEvidenceAdapter(ICertificationService certifications, IRmsRecordParticipantsRepository participants) + { + _certifications = certifications; + _participants = participants; + } + + public RmsEvidenceKind Kind => RmsEvidenceKind.CertificationSnapshot; + + public Task IsAvailableAsync(int departmentId) => Task.FromResult(true); + + public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) + { + var userIds = (request.UserIds ?? new List()).Where(u => !string.IsNullOrWhiteSpace(u)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + // Default to the record's own participants: the people the filing already says were there. + if (userIds.Count == 0 && request.RecordKind == RmsRecordKind.Operational) + { + var participants = await _participants.GetForRecordAsync(request.DepartmentId, request.RecordId, null); + userIds = (participants ?? Enumerable.Empty()) + .Where(p => !string.IsNullOrWhiteSpace(p.UserId)) + .Select(p => p.UserId).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + if (userIds.Count == 0) + return RecordEvidenceCapture.Unavailable("Certification evidence needs at least one participant."); + + // Validity is asserted as at the incident time, not as at capture: a certificate that expired last + // month was still valid on the night, and the record has to be able to say so. + var asOf = request.CoverageEnd ?? request.CoverageStart ?? DateTime.UtcNow; + var people = new List(); + var total = 0; + + foreach (var userId in userIds) + { + cancellationToken.ThrowIfCancellationRequested(); + var certifications = (await _certifications.GetCertificationsByUserIdAsync(userId))? + .Where(c => c != null && c.DepartmentId == request.DepartmentId) + .Take(EvidenceLimits.MaxItems).ToList() ?? new List(); + + if (certifications.Count == 0) + continue; + + people.Add(new + { + user_id = userId, + certifications = certifications.Select(c => new + { + // Type, status and validity only. Number, file name and document bytes stay with + // Certifications, which owns them and their protection. + type = c.Type, + name = c.Name, + area = c.Area, + issued_by = c.IssuedBy, + received_on = c.RecievedOn, + expires_on = c.ExpiresOn, + valid_at_incident = !c.ExpiresOn.HasValue || c.ExpiresOn.Value >= asOf + }).ToList() + }); + total += certifications.Count; + } + + if (total == 0) + return RecordEvidenceCapture.Unavailable("None of those participants hold recorded certifications."); + + return new RecordEvidenceCapture + { + Title = $"Certification snapshot for {people.Count} member(s)", + SourceSubsystem = SourceSubsystem, + SourceEntityType = nameof(PersonnelCertification), + SourceEntityId = request.RecordId, + IdentifierScheme = IdentifierScheme, + CoverageStart = asOf, + CoverageEnd = asOf, + SourceItemCount = total, + Classification = RmsEvidenceClassification.Restricted, + Manifest = new { as_of = asOf, people } + }; + } + } +} diff --git a/Core/Resgrid.Services/Records/IncidentAnalysisService.cs b/Core/Resgrid.Services/Records/IncidentAnalysisService.cs new file mode 100644 index 00000000..3501d037 --- /dev/null +++ b/Core/Resgrid.Services/Records/IncidentAnalysisService.cs @@ -0,0 +1,628 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// The NERIS incident-analysis filing (RMS-3): the fire/hazmat investigation posted to + /// /incident_analysis/{neris_id_incident} once the incident itself exists at the destination. + /// + /// Deliberately separate from . The analysis is a second submittable + /// artifact for the same incident, and keeping it apart is what guarantees the property the plan cares about: + /// an analysis that will not validate can never block the incident report, and an incident still awaiting its + /// destination id can never lose the analysis an investigator already finished. + /// + /// + public class IncidentAnalysisService : IIncidentAnalysisService + { + public const string AnalysisAggregate = "RmsIncidentAnalysis"; + + private readonly IRmsIncidentAnalysesRepository _analyses; + private readonly IRmsIncidentReportsRepository _reports; + private readonly IRmsIncidentModulesRepository _modules; + private readonly IRmsIncidentPropertiesRepository _properties; + private readonly IRmsIncidentVehiclesRepository _vehicles; + private readonly IRmsValidationIssuesRepository _issues; + private readonly IRmsSubmissionsRepository _submissions; + private readonly IRmsRevisionsRepository _revisions; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IUnitOfWork _unitOfWork; + private readonly INerisProfileService _neris; + private readonly INerisMappingService _mapping; + private readonly INerisValidationService _validation; + + public IncidentAnalysisService(IRmsIncidentAnalysesRepository analyses, IRmsIncidentReportsRepository reports, + IRmsIncidentModulesRepository modules, IRmsIncidentPropertiesRepository properties, IRmsIncidentVehiclesRepository vehicles, + IRmsValidationIssuesRepository issues, IRmsSubmissionsRepository submissions, IRmsRevisionsRepository revisions, + IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork, INerisProfileService neris, INerisMappingService mapping, + INerisValidationService validation) + { + _analyses = analyses; + _reports = reports; + _modules = modules; + _properties = properties; + _vehicles = vehicles; + _issues = issues; + _submissions = submissions; + _revisions = revisions; + _audits = audits; + _unitOfWork = unitOfWork; + _neris = neris; + _mapping = mapping; + _validation = validation; + } + + public async Task StartForReportAsync(int departmentId, string userId, string incidentReportId, RmsOriginClient origin = RmsOriginClient.Web, CancellationToken cancellationToken = default) + { + var report = await _reports.GetByIdForDepartmentAsync(departmentId, incidentReportId); + if (report == null || report.DeletedOn.HasValue) + throw new InvalidOperationException("The incident report does not exist."); + + // One analysis per report; a second start returns the first, exactly like starting a report from a Call. + var existing = await _analyses.GetForReportAsync(departmentId, incidentReportId); + if (existing != null) + return await HydrateAsync(existing, report, null, false); + + var now = DateTime.UtcNow; + var analysis = new RmsIncidentAnalysis + { + RmsIncidentAnalysisId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + IncidentReportId = incidentReportId, + ReportingEntityId = report.ReportingEntityId, + ProfileVersion = report.ProfileVersion ?? _neris.ContractVersion, + State = (int)RmsIncidentAnalysisState.Draft, + AuthorUserId = userId, + OwnerUserId = userId, + CreatedOn = now, + CreatedByUserId = userId, + ModifiedOn = now, + ModifiedByUserId = userId, + RowVersion = 1 + }; + + await InTransactionAsync(async () => + { + await _analyses.InsertAsync(analysis, cancellationToken, true); + await AuditAsync(departmentId, userId, analysis.RmsIncidentAnalysisId, null, RmsAccessAuditAction.Change, "Start incident analysis", origin, cancellationToken, new { incidentReportId }); + }); + + return await HydrateAsync(analysis, report, null, false); + } + + public async Task GetAsync(int departmentId, string analysisId, bool includeHistory = false) + { + var analysis = await _analyses.GetByIdForDepartmentAsync(departmentId, analysisId); + if (analysis == null || analysis.DeletedOn.HasValue) + return null; + + var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); + return await HydrateAsync(analysis, report, null, includeHistory); + } + + public async Task GetForReportAsync(int departmentId, string incidentReportId, bool includeHistory = false) + { + var analysis = await _analyses.GetForReportAsync(departmentId, incidentReportId); + if (analysis == null) + return null; + + var report = await _reports.GetByIdForDepartmentAsync(departmentId, incidentReportId); + return await HydrateAsync(analysis, report, null, includeHistory); + } + + public async Task SaveDraftAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, IncidentAnalysisDraftInput input, bool canWriteRestricted = true, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + + var analysis = await LoadAsync(departmentId, analysisId); + if ((RmsIncidentAnalysisState)analysis.State != RmsIncidentAnalysisState.Draft && (RmsIncidentAnalysisState)analysis.State != RmsIncidentAnalysisState.Rejected) + throw new InvalidOperationException("Only a draft or rejected analysis can be edited."); + + var now = DateTime.UtcNow; + await InTransactionAsync(async () => + { + if (analysis.RowVersion != expectedRowVersion) + throw new RecordConcurrencyException(analysisId, expectedRowVersion, analysis.RowVersion); + + analysis.GeneralCause = Trim(input.GeneralCause)?.ToUpperInvariant(); + analysis.InvestigationTypesCsv = JoinCodes(input.InvestigationTypes); + analysis.CurrencyCode = Trim(input.CurrencyCode)?.ToUpperInvariant(); + + var properties = await ReplacePropertiesAsync(analysis, input.Properties, now, cancellationToken); + var vehicles = await ReplaceVehiclesAsync(analysis, input.Vehicles, canWriteRestricted, now, cancellationToken); + await ReplaceModulesAsync(analysis, input.Modules, now, cancellationToken); + + // The analysis's headline totals are the sum of what it enumerates; a department never types them. + analysis.EstimatedValueTotal = Sum(properties.Select(p => p.EstimatedValue), properties.Select(p => p.ContentsValue), vehicles.Select(v => v.EstimatedValue)); + analysis.EstimatedLossTotal = Sum(properties.Select(p => p.EstimatedLoss), properties.Select(p => p.ContentsLoss), vehicles.Select(v => v.EstimatedLoss)); + + analysis.ModifiedOn = now; + analysis.ModifiedByUserId = userId; + analysis.RowVersion += 1; + await _analyses.UpdateAsync(analysis, cancellationToken, true); + await AuditAsync(departmentId, userId, analysisId, null, RmsAccessAuditAction.Change, "Save analysis draft", input.OriginClient, cancellationToken); + }); + + return await GetAsync(departmentId, analysisId, false); + } + + public async Task> ValidateAsync(int departmentId, string analysisId, CancellationToken cancellationToken = default) + { + var snapshot = await BuildSnapshotAsync(departmentId, analysisId); + if (snapshot == null) + return new List(); + + var profile = await _neris.GetProfileAsync(departmentId); + var issues = _validation.ValidateAnalysisLocal(snapshot, profile); + await _issues.ReplaceForRecordAsync(departmentId, analysisId, RmsValidationSource.Local, issues, cancellationToken); + return (await _issues.GetForRecordAsync(departmentId, analysisId))?.ToList() ?? new List(); + } + + public async Task FinalizeAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, CancellationToken cancellationToken = default) + { + var analysis = await LoadAsync(departmentId, analysisId); + var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); + + var issues = await ValidateAsync(departmentId, analysisId, cancellationToken); + // The one rule that is not the destination's: an analysis cannot be filed before its incident. That is + // a wait, not an error, so it is dropped from the blocking set here and enforced at submission instead. + var blocking = issues.Where(i => i.Severity == (int)RmsValidationSeverity.Error && i.RuleKey != "neris.analysis.incident").ToList(); + if (blocking.Count > 0) + throw new IncidentReportValidationException(analysisId, blocking); + + var now = DateTime.UtcNow; + var outboxIds = new List(); + + await InTransactionAsync(async () => + { + if (analysis.RowVersion != expectedRowVersion) + throw new RecordConcurrencyException(analysisId, expectedRowVersion, analysis.RowVersion); + + var aggregate = await HydrateAsync(analysis, report, null, false); + var revision = await WriteRevisionAsync(analysis, aggregate, userId, now, cancellationToken); + + analysis.State = (int)RmsIncidentAnalysisState.Finalized; + analysis.CurrentRevisionId = revision.RmsRevisionId; + analysis.RevisionCount = revision.RevisionNumber; + analysis.FinalizedOn = now; + analysis.FinalizedByUserId = userId; + analysis.ModifiedOn = now; + analysis.ModifiedByUserId = userId; + analysis.RowVersion += 1; + await _analyses.UpdateAsync(analysis, cancellationToken, true); + + // Queue immediately when the incident is already filed; otherwise worker 41 picks it up when it is. + if (report != null && !string.IsNullOrWhiteSpace(report.NerisIncidentId) && await _neris.IsSubmissionEnabledAsync(departmentId)) + await QueueCoreAsync(analysis, report, revision, userId, now, cancellationToken); + + await AuditAsync(departmentId, userId, analysisId, revision.RmsRevisionId, RmsAccessAuditAction.Change, "Finalize incident analysis", RmsOriginClient.Web, cancellationToken, new { revision.Checksum }); + }); + + await Task.CompletedTask; + return await GetAsync(departmentId, analysisId, true); + } + + public async Task QueueSubmissionAsync(int departmentId, string userId, string analysisId, CancellationToken cancellationToken = default) + { + var analysis = await LoadAsync(departmentId, analysisId); + var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); + + if (string.IsNullOrWhiteSpace(analysis.CurrentRevisionId)) + throw new InvalidOperationException("The analysis has not been finalized."); + if (report == null || string.IsNullOrWhiteSpace(report.NerisIncidentId)) + throw new InvalidOperationException("The incident must be filed with NERIS before its analysis can be."); + if (!await _neris.IsSubmissionEnabledAsync(departmentId)) + throw new InvalidOperationException("NERIS submission is not enabled for this department."); + + var now = DateTime.UtcNow; + await InTransactionAsync(async () => + { + var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, analysis.CurrentRevisionId); + await QueueCoreAsync(analysis, report, revision, userId, now, cancellationToken); + analysis.ModifiedOn = now; + analysis.ModifiedByUserId = userId; + analysis.RowVersion += 1; + await _analyses.UpdateAsync(analysis, cancellationToken, true); + }); + + return await GetAsync(departmentId, analysisId, true); + } + + public async Task QueueAwaitingIncidentAsync(int departmentId, CancellationToken cancellationToken = default) + { + if (!await _neris.IsSubmissionEnabledAsync(departmentId)) + return 0; + + var queued = 0; + foreach (var analysis in (await _analyses.GetAwaitingIncidentAsync(departmentId, 100))?.ToList() ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); + if (report == null || string.IsNullOrWhiteSpace(report.NerisIncidentId)) + continue; + + // Already queued or in flight under this revision: leave it to the submission worker. + var key = IdempotencyKey(departmentId, analysis.RmsIncidentAnalysisId, analysis.CurrentRevisionId); + var existing = await _submissions.GetByIdempotencyKeyAsync(key); + if (existing != null && existing.State != (int)RmsSubmissionState.Failed && existing.State != (int)RmsSubmissionState.Superseded) + continue; + + await QueueSubmissionAsync(departmentId, null, analysis.RmsIncidentAnalysisId, cancellationToken); + queued++; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Incident analysis {analysis.RmsIncidentAnalysisId} could not be queued."); + } + } + + return queued; + } + + public async Task VoidAsync(int departmentId, string userId, string analysisId, string reasonCode, string reasonText, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(reasonCode)) + throw new ArgumentException("A void reason is required.", nameof(reasonCode)); + + var analysis = await LoadAsync(departmentId, analysisId); + if ((RmsIncidentAnalysisState)analysis.State == RmsIncidentAnalysisState.Submitted) + throw new InvalidOperationException("The analysis is in flight to the destination and cannot be voided until it settles."); + + var now = DateTime.UtcNow; + await InTransactionAsync(async () => + { + analysis.State = (int)RmsIncidentAnalysisState.Voided; + analysis.VoidedOn = now; + analysis.VoidedByUserId = userId; + analysis.VoidReasonCode = reasonCode; + analysis.VoidReasonText = reasonText; + analysis.ModifiedOn = now; + analysis.ModifiedByUserId = userId; + analysis.RowVersion += 1; + await _analyses.UpdateAsync(analysis, cancellationToken, true); + await _submissions.SupersedeOpenForRecordAsync(departmentId, analysisId, null, now, cancellationToken); + await AuditAsync(departmentId, userId, analysisId, null, RmsAccessAuditAction.Change, "Void incident analysis", RmsOriginClient.Web, cancellationToken, new { reasonCode }); + }); + + return await GetAsync(departmentId, analysisId, true); + } + + public async Task BuildSnapshotAsync(int departmentId, string analysisId, string revisionId = null) + { + var analysis = await _analyses.GetByIdForDepartmentAsync(departmentId, analysisId); + if (analysis == null) + return null; + + var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); + var aggregate = await HydrateAsync(analysis, report, revisionId, false); + return ToSnapshot(aggregate); + } + + public static NerisIncidentAnalysisSnapshot ToSnapshot(IncidentAnalysisAggregate aggregate) + { + return new NerisIncidentAnalysisSnapshot + { + Analysis = aggregate.Analysis, + Report = aggregate.Report, + Modules = aggregate.Modules, + Properties = aggregate.Properties, + Vehicles = aggregate.Vehicles + }; + } + + public static string IdempotencyKey(int departmentId, string analysisId, string revisionId) + { + return "neris-analysis:" + RecordSnapshotSerializer.Checksum($"{departmentId}:{analysisId}:{revisionId}"); + } + + // ── internals ──────────────────────────────────────────────────────────────── + + private async Task LoadAsync(int departmentId, string analysisId) + { + var analysis = await _analyses.GetByIdForDepartmentAsync(departmentId, analysisId); + if (analysis == null || analysis.DeletedOn.HasValue) + throw new InvalidOperationException("The incident analysis does not exist."); + return analysis; + } + + private async Task HydrateAsync(RmsIncidentAnalysis analysis, RmsIncidentReport report, string revisionId, bool includeHistory) + { + var dept = analysis.DepartmentId; + var id = analysis.RmsIncidentAnalysisId; + var aggregate = new IncidentAnalysisAggregate + { + Analysis = analysis, + Report = report, + Modules = (await _modules.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(), + Properties = (await _properties.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(), + Vehicles = (await _vehicles.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List() + }; + + if (includeHistory) + { + aggregate.Submissions = (await _submissions.GetForRecordAsync(dept, id))?.ToList() ?? new List(); + aggregate.Revisions = (await _revisions.GetForRecordAsync(dept, id))?.ToList() ?? new List(); + } + + return aggregate; + } + + private async Task> ReplacePropertiesAsync(RmsIncidentAnalysis analysis, List inputs, DateTime now, CancellationToken cancellationToken) + { + if (inputs == null) + return (await _properties.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, null))?.ToList() ?? new List(); + + await _properties.DeleteDraftForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, cancellationToken); + var result = new List(); + var ordinal = 0; + foreach (var input in inputs) + { + var row = new RmsIncidentProperty + { + RmsIncidentPropertyId = Guid.NewGuid().ToString(), DepartmentId = analysis.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RecordId = analysis.RmsIncidentAnalysisId, + LocationUse = Trim(input.LocationUse)?.ToUpperInvariant(), ConstructionType = Trim(input.ConstructionType)?.ToUpperInvariant(), + Foundation = Trim(input.Foundation)?.ToUpperInvariant(), ExteriorFinish = Trim(input.ExteriorFinish)?.ToUpperInvariant(), + RoofMaterial = Trim(input.RoofMaterial)?.ToUpperInvariant(), StoriesAboveGrade = input.StoriesAboveGrade, StoriesBelowGrade = input.StoriesBelowGrade, + YearBuilt = input.YearBuilt, Vacancy = Trim(input.Vacancy)?.ToUpperInvariant(), DamageType = Trim(input.DamageType)?.ToUpperInvariant(), + FireSpread = Trim(input.FireSpread)?.ToUpperInvariant(), EstimatedValue = input.EstimatedValue, EstimatedLoss = input.EstimatedLoss, + ContentsValue = input.ContentsValue, ContentsLoss = input.ContentsLoss, CurrencyCode = analysis.CurrencyCode, + DetailJson = Trim(input.DetailJson), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + await _properties.InsertAsync(row, cancellationToken, true); + result.Add(row); + } + return result; + } + + private async Task> ReplaceVehiclesAsync(RmsIncidentAnalysis analysis, List inputs, bool canWriteRestricted, DateTime now, CancellationToken cancellationToken) + { + var existing = (await _vehicles.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, null))?.ToList() ?? new List(); + if (inputs == null) + return existing; + + await _vehicles.DeleteDraftForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, cancellationToken); + var result = new List(); + var ordinal = 0; + foreach (var input in inputs) + { + var prior = ordinal < existing.Count ? existing[ordinal] : null; + var row = new RmsIncidentVehicle + { + RmsIncidentVehicleId = Guid.NewGuid().ToString(), DepartmentId = analysis.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RecordId = analysis.RmsIncidentAnalysisId, + VehicleKind = Trim(input.VehicleKind)?.ToUpperInvariant() ?? "AUTOMOBILE", + Make = Trim(input.Make)?.ToUpperInvariant(), Model = Trim(input.Model), ModelYear = input.ModelYear, + BodyStyle = Trim(input.BodyStyle)?.ToUpperInvariant(), Powertrain = Trim(input.Powertrain)?.ToUpperInvariant(), + DamageType = Trim(input.DamageType)?.ToUpperInvariant(), WasOccupied = input.WasOccupied, + EstimatedValue = input.EstimatedValue, EstimatedLoss = input.EstimatedLoss, CurrencyCode = analysis.CurrencyCode, + DetailJson = Trim(input.DetailJson), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + + // VIN, plate and registration state identify a person's vehicle: restricted, so a caller without the + // grant carries the stored values forward instead of writing or erasing them. + row.Vin = canWriteRestricted ? Trim(input.Vin)?.ToUpperInvariant() : prior?.Vin; + row.LicensePlate = canWriteRestricted ? Trim(input.LicensePlate)?.ToUpperInvariant() : prior?.LicensePlate; + row.LicenseState = canWriteRestricted ? Trim(input.LicenseState)?.ToUpperInvariant() : prior?.LicenseState; + + await _vehicles.InsertAsync(row, cancellationToken, true); + result.Add(row); + } + return result; + } + + private async Task> ReplaceModulesAsync(RmsIncidentAnalysis analysis, List inputs, DateTime now, CancellationToken cancellationToken) + { + if (inputs == null) + return (await _modules.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, null))?.ToList() ?? new List(); + + await _modules.DeleteDraftForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, cancellationToken); + var result = new List(); + var ordinal = 0; + foreach (var input in inputs) + { + var descriptor = RmsIncidentModuleCatalog.Get(input.Kind); + // An incident-payload section on the analysis could never be submitted; dropping it is honest. + if (descriptor == null || !descriptor.BelongsToAnalysis) + continue; + + var row = new RmsIncidentModule + { + RmsIncidentModuleId = Guid.NewGuid().ToString(), DepartmentId = analysis.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RecordId = analysis.RmsIncidentAnalysisId, RecordKind = (int)RmsRecordKind.IncidentAnalysis, + ModuleKind = (int)input.Kind, SchemaName = descriptor.SchemaName, ProfileVersion = analysis.ProfileVersion, + PrimaryCode = Trim(input.PrimaryCode)?.ToUpperInvariant(), SecondaryCode = Trim(input.SecondaryCode)?.ToUpperInvariant(), + Quantity = input.Quantity, QuantityUnit = Trim(input.QuantityUnit)?.ToUpperInvariant(), OccurredOn = input.OccurredOn, + DetailJson = Trim(input.DetailJson), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + await _modules.InsertAsync(row, cancellationToken, true); + result.Add(row); + } + return result; + } + + private async Task WriteRevisionAsync(RmsIncidentAnalysis analysis, IncidentAnalysisAggregate draft, string userId, DateTime now, CancellationToken cancellationToken) + { + var snapshotJson = JsonConvert.SerializeObject(new + { + analysis.RmsIncidentAnalysisId, + analysis.IncidentReportId, + analysis.GeneralCause, + analysis.InvestigationTypesCsv, + analysis.EstimatedValueTotal, + analysis.EstimatedLossTotal, + Modules = draft.Modules.OrderBy(m => m.Ordinal).Select(m => new { m.ModuleKind, m.PrimaryCode, m.SecondaryCode, m.Quantity, m.DetailJson }), + Properties = draft.Properties.OrderBy(p => p.Ordinal), + Vehicles = draft.Vehicles.OrderBy(v => v.Ordinal) + }); + + var revision = new RmsRevision + { + RmsRevisionId = Guid.NewGuid().ToString(), + DepartmentId = analysis.DepartmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = analysis.RmsIncidentAnalysisId, + RecordKind = (int)RmsRecordKind.IncidentAnalysis, + RevisionNumber = analysis.RevisionCount + 1, + Transition = analysis.RevisionCount == 0 ? (int)RmsRevisionTransition.Finalized : (int)RmsRevisionTransition.Amended, + PriorRevisionId = analysis.CurrentRevisionId, + DefinitionKey = RmsDefinitionKeys.NerisIncidentReport, + DefinitionVersion = 1, + SnapshotJson = snapshotJson, + Checksum = RecordSnapshotSerializer.Checksum(snapshotJson), + ActorUserId = userId, + OriginClient = (int)RmsOriginClient.Web, + CreatedOn = now + }; + await _revisions.InsertAsync(revision, cancellationToken, true); + + var id = revision.RmsRevisionId; + foreach (var m in draft.Modules) await _modules.InsertAsync(CopyTo(m, x => x.RmsIncidentModuleId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); + foreach (var p in draft.Properties) await _properties.InsertAsync(CopyTo(p, x => x.RmsIncidentPropertyId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); + foreach (var v in draft.Vehicles) await _vehicles.InsertAsync(CopyTo(v, x => x.RmsIncidentVehicleId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); + + return revision; + } + + private async Task QueueCoreAsync(RmsIncidentAnalysis analysis, RmsIncidentReport report, RmsRevision revision, string userId, DateTime now, CancellationToken cancellationToken) + { + var profile = await _neris.GetProfileAsync(analysis.DepartmentId); + var aggregate = await HydrateAsync(analysis, report, revision.RmsRevisionId, false); + var payload = _mapping.BuildIncidentAnalysisPayloadJson(ToSnapshot(aggregate), profile); + var key = IdempotencyKey(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, revision.RmsRevisionId); + + var submission = await _submissions.GetByIdempotencyKeyAsync(key); + if (submission == null) + { + submission = new RmsSubmission + { + RmsSubmissionId = Guid.NewGuid().ToString(), + DepartmentId = analysis.DepartmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = analysis.RmsIncidentAnalysisId, + RecordKind = (int)RmsRecordKind.IncidentAnalysis, + RevisionId = revision.RmsRevisionId, + Destination = RmsSubmissionDestinations.NerisIncidentAnalysis, + DestinationVersion = profile?.ContractVersion ?? _neris.ContractVersion, + IdempotencyKey = key, + MaxAttempts = Math.Max(1, Config.NerisConfig.MaxAttempts), + PayloadJson = payload, + PayloadChecksum = RecordSnapshotSerializer.Checksum(payload), + QueuedOn = now, + CreatedByUserId = userId, + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }; + await _submissions.SupersedeOpenForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, submission.RmsSubmissionId, now, cancellationToken); + await _submissions.InsertAsync(submission, cancellationToken, true); + } + else + { + // Same revision, same key: a retry, never a new payload. + submission.State = (int)RmsSubmissionState.Queued; + submission.Attempts = 0; + submission.NextAttemptOn = null; + submission.LeaseOwner = null; + submission.LeaseExpiresOn = null; + submission.ErrorSummary = null; + submission.QueuedOn = now; + submission.ModifiedOn = now; + submission.RowVersion += 1; + await _submissions.UpdateAsync(submission, cancellationToken, true); + } + + analysis.State = (int)RmsIncidentAnalysisState.Submitted; + analysis.LastSubmissionId = submission.RmsSubmissionId; + analysis.LastSubmissionState = submission.State; + analysis.LastSubmittedOn = now; + + await AuditAsync(analysis.DepartmentId, userId, analysis.RmsIncidentAnalysisId, revision.RmsRevisionId, RmsAccessAuditAction.Submit, + "Queue analysis submission", RmsOriginClient.System, cancellationToken, new { submission.RmsSubmissionId, submission.IdempotencyKey, submission.PayloadChecksum }); + } + + private static T CopyTo(T source, Action assignId, string revisionId, DateTime now) where T : class + { + var copy = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(source)); + assignId(copy); + var type = typeof(T); + type.GetProperty("RevisionId")?.SetValue(copy, revisionId); + type.GetProperty("CreatedOn")?.SetValue(copy, now); + type.GetProperty("ModifiedOn")?.SetValue(copy, now); + type.GetProperty("RowVersion")?.SetValue(copy, 1L); + return copy; + } + + private static decimal? Sum(params IEnumerable[] sets) + { + decimal? total = null; + foreach (var set in sets) + { + foreach (var value in set) + { + if (!value.HasValue) + continue; + total = (total ?? 0m) + value.Value; + } + } + return total; + } + + private static string JoinCodes(List codes) + { + if (codes == null || codes.Count == 0) + return null; + + var cleaned = codes.Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim().ToUpperInvariant()).Distinct().ToList(); + return cleaned.Count == 0 ? null : string.Join(",", cleaned); + } + + private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private async Task InTransactionAsync(Func work) + { + _unitOfWork.CreateOrGetConnection(); + try + { + await work(); + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + + private Task AuditAsync(int departmentId, string userId, string recordId, string revisionId, RmsAccessAuditAction action, string purpose, RmsOriginClient origin, CancellationToken cancellationToken, object detail = null) + { + return _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + RecordId = recordId, + RevisionId = revisionId, + Action = (int)action, + ActorUserId = userId, + Purpose = purpose, + OriginClient = (int)origin, + Successful = true, + OccurredOn = DateTime.UtcNow, + DetailJson = detail == null ? null : JsonConvert.SerializeObject(detail) + }, cancellationToken, true); + } + } +} diff --git a/Core/Resgrid.Services/Records/IncidentReportsService.cs b/Core/Resgrid.Services/Records/IncidentReportsService.cs index 47bc7cd7..dc79b034 100644 --- a/Core/Resgrid.Services/Records/IncidentReportsService.cs +++ b/Core/Resgrid.Services/Records/IncidentReportsService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -39,6 +39,10 @@ public class IncidentReportsService : IIncidentReportsService private readonly IRmsLocationsRepository _locations; private readonly IRmsNarrativesRepository _narratives; private readonly IRmsValidationIssuesRepository _issues; + private readonly IRmsIncidentModulesRepository _modules; + private readonly IRmsIncidentResourcesRepository _resources; + private readonly IRmsCasualtyRescuesRepository _casualties; + private readonly IRmsExposuresRepository _exposures; private readonly IRmsSubmissionsRepository _submissions; private readonly IRmsSignaturesRepository _signatures; private readonly IRmsRevisionsRepository _revisions; @@ -62,6 +66,7 @@ public class IncidentReportsService : IIncidentReportsService public IncidentReportsService(IRmsIncidentReportsRepository reports, IRmsSourceFactsRepository facts, IRmsUnitResponsesRepository units, IRmsIncidentTypesRepository types, IRmsActionTacticsRepository tactics, IRmsAidsRepository aids, IRmsLocationsRepository locations, IRmsNarrativesRepository narratives, IRmsValidationIssuesRepository issues, IRmsSubmissionsRepository submissions, IRmsSignaturesRepository signatures, + IRmsIncidentModulesRepository modules, IRmsIncidentResourcesRepository resources, IRmsCasualtyRescuesRepository casualties, IRmsExposuresRepository exposures, IRmsRevisionsRepository revisions, IRmsAccessAuditsRepository audits, IRmsRecordGroupScopesRepository scopes, IRmsRecordSharesRepository shares, IRmsRecordSearchProjectionsRepository projections, IDomainEventOutboxService outbox, IDepartmentSettingsService settings, IDepartmentGroupsService groups, IUserProfileService profiles, IPersonnelRolesService roles, IUnitsService unitsService, ICallsService calls, @@ -77,6 +82,10 @@ public IncidentReportsService(IRmsIncidentReportsRepository reports, IRmsSourceF _locations = locations; _narratives = narratives; _issues = issues; + _modules = modules; + _resources = resources; + _casualties = casualties; + _exposures = exposures; _submissions = submissions; _signatures = signatures; _revisions = revisions; @@ -108,8 +117,12 @@ public async Task StartFromCallAsync(int departmentId, var profile = await _neris.GetProfileAsync(departmentId); var entity = ReportingEntityFor(departmentId, profile); - // SingleAuthoritative (plan 5.2.1): a second start returns the existing report, never a duplicate. - var existing = await _reports.GetByCallAsync(departmentId, callId, entity); + // SingleAuthoritative (plan 5.2.1): a second start returns the existing report, never a duplicate. The + // entity-scoped lookup is not enough on its own — a report started before the NERIS profile was + // configured carries the placeholder entity, and matching only the current one would start a second + // authoritative report for the same call the moment the department sets its entity id. + var existing = await _reports.GetByCallAsync(departmentId, callId, entity) + ?? (await _reports.GetByCallAnyEntityAsync(departmentId, callId))?.FirstOrDefault(r => !r.DeletedOn.HasValue); if (existing != null && !existing.DeletedOn.HasValue) return await GetAsync(departmentId, existing.RmsIncidentReportId, false); @@ -238,6 +251,12 @@ public async Task GetForCallAsync(int departmentId, int return report == null ? null : await HydrateAsync(report, null, false); } + public async Task> GetSectionRequirementsAsync(int departmentId, string reportId) + { + var types = (await _types.GetForRecordAsync(departmentId, reportId, null))?.ToList() ?? new List(); + return _validation.GetSectionRequirements(types.Select(t => t.TypeCode)).ToList(); + } + public async Task BuildSnapshotAsync(int departmentId, string reportId, string revisionId = null) { var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId); @@ -262,7 +281,11 @@ public static NerisIncidentSnapshot ToSnapshot(IncidentReportAggregate aggregate Facts = aggregate.Facts, DispatchComments = aggregate.Facts.Where(f => f.FactKey != null && f.FactKey.StartsWith(DispatchCommentFactPrefix, StringComparison.Ordinal)) .OrderBy(f => f.SourceTime).Select(f => new NerisDispatchComment { Timestamp = f.SourceTime, Comment = f.CurrentValue ?? f.SourceValue }).ToList(), - SpecialModifiers = SplitCsv(aggregate.Report.SpecialModifiersCsv) + SpecialModifiers = SplitCsv(aggregate.Report.SpecialModifiersCsv), + Modules = aggregate.Modules, + Resources = aggregate.Resources, + Casualties = aggregate.Casualties, + Exposures = aggregate.Exposures }; } @@ -270,7 +293,7 @@ public static NerisIncidentSnapshot ToSnapshot(IncidentReportAggregate aggregate #region Draft / validate - public async Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, CancellationToken cancellationToken = default) + public async Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, bool canWriteRestricted = true, CancellationToken cancellationToken = default) { if (input == null) throw new ArgumentNullException(nameof(input)); var report = await LoadAsync(departmentId, reportId); @@ -289,6 +312,10 @@ await InTransactionAsync(async () => var aids = await ReplaceAidsAsync(report, input.Aids, now, cancellationToken); var tactics = await ReplaceTacticsAsync(report, input.Tactics, now, cancellationToken); var narrative = await ReplaceNarrativeAsync(report, input, now, cancellationToken); + var modules = await ReplaceModulesAsync(report, input.Modules, report.ProfileVersion ?? _neris.ContractVersion, now, cancellationToken); + var resources = await ReplaceResourcesAsync(report, input.Resources, now, cancellationToken); + var casualties = await ReplaceCasualtiesAsync(report, input.Casualties, canWriteRestricted, now, cancellationToken); + var exposures = await ReplaceExposuresAsync(report, input.Exposures, now, cancellationToken); foreach (var fact in facts.Where(f => f.CorrectedOn == now)) await _facts.UpdateAsync(fact, cancellationToken, true); @@ -297,7 +324,11 @@ await InTransactionAsync(async () => report.ModifiedByUserId = userId; await _reports.UpdateAsync(report, cancellationToken, true); - var aggregate = new IncidentReportAggregate { Report = report, Location = location, Types = types, Units = units, Aids = aids, Tactics = tactics, Narrative = narrative, Facts = facts }; + var aggregate = new IncidentReportAggregate + { + Report = report, Location = location, Types = types, Units = units, Aids = aids, Tactics = tactics, Narrative = narrative, Facts = facts, + Modules = modules, Resources = resources, Casualties = casualties, Exposures = exposures + }; await RecomputeGroupScopeAsync(aggregate, null, cancellationToken); await UpsertProjectionAsync(aggregate, cancellationToken); await AuditAsync(departmentId, userId, reportId, null, RmsAccessAuditAction.Change, "Save draft", input.OriginClient, cancellationToken); @@ -901,21 +932,26 @@ private async Task ReplaceLocationAsync(RmsIncidentReport report, I private async Task> ReplaceTypesAsync(RmsIncidentReport report, List inputs, DateTime now, CancellationToken cancellationToken) { await _types.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); + var accepted = (inputs ?? new List()).Where(i => !string.IsNullOrWhiteSpace(i.TypeCode)).ToList(); + + // Decide the primary before anything is written. Promoting the first row after the insert only fixed the + // in-memory copy the save returned: the stored rows all kept IsPrimary = false, and the next hydrate + // failed validation on a report the author had just been told was fine. + var promoteFirst = accepted.Count > 0 && !accepted.Any(i => i.IsPrimary); + var result = new List(); var ordinal = 0; - foreach (var input in (inputs ?? new List()).Where(i => !string.IsNullOrWhiteSpace(i.TypeCode))) + foreach (var input in accepted) { var row = new RmsIncidentType { RmsIncidentTypeId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId, - TypeCode = input.TypeCode.Trim(), IsPrimary = input.IsPrimary, LocalCode = ordinal == 0 ? report.DispatchIncidentCode : null, ValueSetVersion = _neris.ContractVersion, - Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + TypeCode = input.TypeCode.Trim(), IsPrimary = input.IsPrimary || (promoteFirst && ordinal == 0), LocalCode = ordinal == 0 ? report.DispatchIncidentCode : null, + ValueSetVersion = _neris.ContractVersion, Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; await _types.InsertAsync(row, cancellationToken, true); result.Add(row); } - if (result.Count > 0 && !result.Any(t => t.IsPrimary)) - result[0].IsPrimary = true; return result; } @@ -988,6 +1024,172 @@ private async Task> ReplaceAidsAsync(RmsIncidentReport report, List return result; } + /// + /// RMS-3 conditional sections. A null list leaves the stored sections alone — a client that cannot render + /// a section must not delete what an officer authored on the Web — while an empty list clears them. + /// + private async Task> ReplaceModulesAsync(RmsIncidentReport report, List inputs, string profileVersion, DateTime now, CancellationToken cancellationToken) + { + if (inputs == null) + return (await _modules.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.ToList() ?? new List(); + + await _modules.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); + var result = new List(); + var ordinal = 0; + foreach (var input in inputs) + { + var descriptor = RmsIncidentModuleCatalog.Get(input.Kind); + // An unknown or analysis-only section on the incident is dropped rather than stored: it could never + // be submitted, and keeping it would make the report look complete when it is not. + if (descriptor == null || descriptor.BelongsToAnalysis) + continue; + + var row = new RmsIncidentModule + { + RmsIncidentModuleId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RecordId = report.RmsIncidentReportId, RecordKind = (int)RmsRecordKind.IncidentReport, + ModuleKind = (int)input.Kind, SchemaName = descriptor.SchemaName, ProfileVersion = profileVersion, + PrimaryCode = Trim(input.PrimaryCode)?.ToUpperInvariant(), SecondaryCode = Trim(input.SecondaryCode)?.ToUpperInvariant(), + Quantity = input.Quantity, QuantityUnit = Trim(input.QuantityUnit)?.ToUpperInvariant(), OccurredOn = input.OccurredOn, + DetailJson = Trim(input.DetailJson), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + await _modules.InsertAsync(row, cancellationToken, true); + result.Add(row); + } + return result; + } + + private async Task> ReplaceResourcesAsync(RmsIncidentReport report, List inputs, DateTime now, CancellationToken cancellationToken) + { + if (inputs == null) + return (await _resources.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.ToList() ?? new List(); + + await _resources.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); + var result = new List(); + var ordinal = 0; + foreach (var input in inputs.Where(i => !string.IsNullOrWhiteSpace(i.ResourceCode))) + { + var row = new RmsIncidentResource + { + RmsIncidentResourceId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RecordId = report.RmsIncidentReportId, ResourceCode = input.ResourceCode.Trim().ToUpperInvariant(), + Quantity = input.Quantity, Detail = Trim(input.Detail), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + await _resources.InsertAsync(row, cancellationToken, true); + result.Add(row); + } + return result; + } + + /// + /// Casualties and rescues. false keeps the unrestricted half of each + /// entry and drops demographics, the personnel link and the injury detail, so a reviewer without the + /// restricted grant can still correct a report without silently erasing what they cannot see — the stored + /// restricted values are carried forward from the matching existing row instead. + /// + private async Task> ReplaceCasualtiesAsync(RmsIncidentReport report, List inputs, bool canWriteRestricted, DateTime now, CancellationToken cancellationToken) + { + var existing = (await _casualties.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.ToList() ?? new List(); + if (inputs == null) + return existing; + + await _casualties.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); + var result = new List(); + var ordinal = 0; + foreach (var input in inputs) + { + var prior = ordinal < existing.Count ? existing[ordinal] : null; + var row = new RmsCasualtyRescue + { + RmsCasualtyRescueId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RecordId = report.RmsIncidentReportId, Kind = (int)input.Kind, + PersonType = Trim(input.PersonType)?.ToUpperInvariant() ?? RmsCasualtyPersonTypes.Civilian, + WasInjured = input.WasInjured, + CasualtyCause = Trim(input.CasualtyCause)?.ToUpperInvariant(), + CasualtyAction = Trim(input.CasualtyAction)?.ToUpperInvariant(), + CasualtyTimeline = Trim(input.CasualtyTimeline)?.ToUpperInvariant(), + DutyType = Trim(input.DutyType)?.ToUpperInvariant(), + JobClassification = Trim(input.JobClassification)?.ToUpperInvariant(), + PpeCsv = JoinCodes(input.Ppe), + WasFatal = input.WasFatal, + RescueType = Trim(input.RescueType)?.ToUpperInvariant(), + RescueActionsCsv = JoinCodes(input.RescueActions), + RescueImpedimentsCsv = JoinCodes(input.RescueImpediments), + RescueMode = Trim(input.RescueMode)?.ToUpperInvariant(), + RescuePath = Trim(input.RescuePath)?.ToUpperInvariant(), + RescueElevation = Trim(input.RescueElevation)?.ToUpperInvariant(), + PresenceKnown = Trim(input.PresenceKnown)?.ToUpperInvariant(), + YearsOfService = input.YearsOfService, + DetailJson = Trim(input.DetailJson), + OccurredOn = input.OccurredOn, + Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + + if (canWriteRestricted) + { + row.PersonnelUserId = Trim(input.PersonnelUserId); + row.Rank = Trim(input.Rank); + row.BirthMonthYear = Trim(input.BirthMonthYear); + row.Gender = Trim(input.Gender)?.ToUpperInvariant(); + row.Race = Trim(input.Race)?.ToUpperInvariant(); + row.InjuryDetailJson = Trim(input.InjuryDetailJson); + } + else + { + row.PersonnelUserId = prior?.PersonnelUserId; + row.Rank = prior?.Rank; + row.BirthMonthYear = prior?.BirthMonthYear; + row.Gender = prior?.Gender; + row.Race = prior?.Race; + row.InjuryDetailJson = prior?.InjuryDetailJson; + } + + await _casualties.InsertAsync(row, cancellationToken, true); + result.Add(row); + } + return result; + } + + private async Task> ReplaceExposuresAsync(RmsIncidentReport report, List inputs, DateTime now, CancellationToken cancellationToken) + { + if (inputs == null) + return (await _exposures.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.ToList() ?? new List(); + + await _exposures.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); + var result = new List(); + var ordinal = 0; + foreach (var input in inputs) + { + var row = new RmsExposure + { + RmsExposureId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RecordId = report.RmsIncidentReportId, + LocationKind = Trim(input.LocationKind)?.ToUpperInvariant(), ItemType = Trim(input.ItemType)?.ToUpperInvariant(), + DamageType = Trim(input.DamageType)?.ToUpperInvariant(), LocationUse = Trim(input.LocationUse)?.ToUpperInvariant(), + PeoplePresent = input.PeoplePresent, DisplacementCount = input.DisplacementCount, + DisplacementCausesCsv = JoinCodes(input.DisplacementCauses), + AddressText = Trim(input.AddressText), Street = Trim(input.Street), Municipality = Trim(input.Municipality), + State = Trim(input.State)?.ToUpperInvariant(), PostalCode = Trim(input.PostalCode), + Latitude = input.Latitude, Longitude = input.Longitude, + EstimatedValue = input.EstimatedValue, EstimatedLoss = input.EstimatedLoss, CurrencyCode = Trim(input.CurrencyCode)?.ToUpperInvariant(), + DetailJson = Trim(input.DetailJson), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + await _exposures.InsertAsync(row, cancellationToken, true); + result.Add(row); + } + return result; + } + + /// Upper-cased, de-duplicated value-set codes as the comma-separated form the columns store. + private static string JoinCodes(List codes) + { + if (codes == null || codes.Count == 0) + return null; + + var cleaned = codes.Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim().ToUpperInvariant()).Distinct().ToList(); + return cleaned.Count == 0 ? null : string.Join(",", cleaned); + } + private async Task> ReplaceTacticsAsync(RmsIncidentReport report, List inputs, DateTime now, CancellationToken cancellationToken) { await _tactics.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); @@ -1079,6 +1281,10 @@ private async Task WriteRevisionAsync(RmsIncidentReport report, Inc foreach (var t in draft.Tactics) await _tactics.InsertAsync(Copy(t, x => x.RmsActionTacticId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); if (draft.Narrative != null) await _narratives.InsertAsync(Copy(draft.Narrative, n => n.RmsNarrativeId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); foreach (var f in draft.Facts) await _facts.InsertAsync(Copy(f, x => x.RmsSourceFactId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); + foreach (var m in draft.Modules) await _modules.InsertAsync(Copy(m, x => x.RmsIncidentModuleId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); + foreach (var r in draft.Resources) await _resources.InsertAsync(Copy(r, x => x.RmsIncidentResourceId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); + foreach (var c in draft.Casualties) await _casualties.InsertAsync(Copy(c, x => x.RmsCasualtyRescueId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); + foreach (var e in draft.Exposures) await _exposures.InsertAsync(Copy(e, x => x.RmsExposureId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); return revision; } @@ -1154,6 +1360,10 @@ private async Task HydrateAsync(RmsIncidentReport repor Tactics = (await _tactics.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(), Narrative = (await _narratives.GetForRecordAsync(dept, id, revisionId))?.FirstOrDefault(), Facts = (await _facts.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(), + Modules = (await _modules.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(), + Resources = (await _resources.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(), + Casualties = (await _casualties.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(), + Exposures = (await _exposures.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(), Issues = (await _issues.GetForRecordAsync(dept, id))?.ToList() ?? new List(), GroupScope = (await _scopes.GetForRecordAsync(dept, id))?.ToList() ?? new List() }; diff --git a/Core/Resgrid.Services/Records/RecordsApiSupport.cs b/Core/Resgrid.Services/Records/RecordsApiSupport.cs index 62dc91f3..d709594e 100644 --- a/Core/Resgrid.Services/Records/RecordsApiSupport.cs +++ b/Core/Resgrid.Services/Records/RecordsApiSupport.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -32,8 +32,17 @@ public RecordsApiStateStore(ICacheProvider cache) private bool SafeConnected() { - try { return _cache.IsConnected(); } - catch { return false; } + try + { + return _cache.IsConnected(); + } + catch (Exception ex) + { + // Falling back to process-local state is correct, but the reason the cache is unreachable has to be + // recorded: without it the only symptom is idempotency keys that stop working across instances. + Logging.LogException(ex, "Records API state store could not reach the cache; using process-local state."); + return false; + } } public async Task GetAsync(string key) @@ -93,20 +102,25 @@ public RecordsApiIdempotencyService(IRecordsApiStateStore store) _store = store; } - public static string Key(int departmentId, string userId, string idempotencyKey) => $"RecordsApiCmd_{departmentId}_{userId}_{idempotencyKey}"; + /// + /// The command is part of the key. A client that reuses one key for SubmitForReview and then Finalize would + /// otherwise match on the record alone and be told the second command succeeded without it ever running. + /// + public static string Key(int departmentId, string userId, string idempotencyKey, string command) + => $"RecordsApiCmd_{departmentId}_{userId}_{(string.IsNullOrWhiteSpace(command) ? "any" : command.Trim())}_{idempotencyKey}"; - public Task TryGetRecordIdAsync(int departmentId, string userId, string idempotencyKey) + public Task TryGetRecordIdAsync(int departmentId, string userId, string idempotencyKey, string command) { if (string.IsNullOrWhiteSpace(idempotencyKey) || string.IsNullOrWhiteSpace(userId)) return Task.FromResult(null); - return _store.GetAsync(Key(departmentId, userId, idempotencyKey.Trim())); + return _store.GetAsync(Key(departmentId, userId, idempotencyKey.Trim(), command)); } - public Task RememberAsync(int departmentId, string userId, string idempotencyKey, string recordId) + public Task RememberAsync(int departmentId, string userId, string idempotencyKey, string command, string recordId) { if (string.IsNullOrWhiteSpace(idempotencyKey) || string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(recordId)) return Task.CompletedTask; - return _store.SetAsync(Key(departmentId, userId, idempotencyKey.Trim()), recordId, Retention); + return _store.SetAsync(Key(departmentId, userId, idempotencyKey.Trim(), command), recordId, Retention); } } diff --git a/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs b/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs index e1ce7fa1..139eaf71 100644 --- a/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs +++ b/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs @@ -158,6 +158,41 @@ public async Task CanUserViewRecordAsync(string userId, string recordId, i } } + public async Task CanSystemPrincipalViewRecordAsync(SystemPrincipalRecordGrant grant, string recordId) + { + if (grant == null || string.IsNullOrWhiteSpace(recordId)) + return false; + + try + { + // The record must exist, live, in the granted department. Both aggregates share the id space. + var record = await _recordsRepository.GetByIdForDepartmentAsync(grant.DepartmentId, recordId); + var exists = record != null && !record.DeletedOn.HasValue; + if (!exists) + { + var report = await _incidentReports.GetByIdForDepartmentAsync(grant.DepartmentId, recordId); + exists = report != null && !report.DeletedOn.HasValue; + } + + if (!exists) + return false; + + if (grant.DepartmentWide) + return true; + + if (grant.GroupIds.Count == 0) + return false; + + var scope = await _groupScopesRepository.GetForRecordAsync(grant.DepartmentId, recordId); + return scope != null && scope.Any(s => grant.GroupIds.Contains(s.DepartmentGroupId)); + } + catch (Exception ex) + { + Logging.LogException(ex, $"CanSystemPrincipalViewRecordAsync failed for record {recordId}; failing closed."); + return false; + } + } + private class VisibleGroupsCacheEntry { public int DepartmentId { get; set; } diff --git a/Core/Resgrid.Services/Records/RecordsDashboardService.cs b/Core/Resgrid.Services/Records/RecordsDashboardService.cs new file mode 100644 index 00000000..e5be47ba --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsDashboardService.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// The Records work queues and the crosswalk gap report (RMS plan RMS-3). + /// + /// Each count is produced independently and a failure degrades to a warning rather than an exception. A + /// dashboard that shows nine correct numbers and says the tenth is unavailable is useful; one that throws + /// because the disclosure table is missing is not. + /// + /// + public class RecordsDashboardService : IRecordsDashboardService + { + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsIncidentReportsRepository _incidentReports; + private readonly IRmsIncidentAnalysesRepository _analyses; + private readonly IRmsRecordDueStatesRepository _dueStates; + private readonly IRmsDisclosureRequestsRepository _disclosures; + private readonly INerisProfileService _neris; + private readonly ICallsService _calls; + + public RecordsDashboardService(IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidentReports, + IRmsIncidentAnalysesRepository analyses, IRmsRecordDueStatesRepository dueStates, IRmsDisclosureRequestsRepository disclosures, + INerisProfileService neris, ICallsService calls) + { + _records = records; + _incidentReports = incidentReports; + _analyses = analyses; + _dueStates = dueStates; + _disclosures = disclosures; + _neris = neris; + _calls = calls; + } + + public async Task GetAsync(int departmentId, string userId, CancellationToken cancellationToken = default) + { + var dashboard = new RecordsDashboard(); + var now = DateTime.UtcNow; + + await SafeAsync(dashboard, "operational queues", async () => + { + dashboard.OperationalDrafts = await _records.CountByDepartmentAsync(departmentId, new[] { (int)RmsRecordState.Draft }); + dashboard.OperationalAwaitingReview = await _records.CountByDepartmentAsync(departmentId, new[] { (int)RmsRecordState.ReadyForReview }); + dashboard.OperationalReturned = await _records.CountByDepartmentAsync(departmentId, new[] { (int)RmsRecordState.Returned }); + }); + + await SafeAsync(dashboard, "incident report queues", async () => + { + dashboard.IncidentIncomplete = await CountReportsAsync(departmentId, RmsRecordState.Draft, RmsRecordState.Returned); + dashboard.IncidentAwaitingReview = await CountReportsAsync(departmentId, RmsRecordState.ReadyForReview, RmsRecordState.Approved); + dashboard.IncidentSubmitted = await CountReportsAsync(departmentId, RmsRecordState.Submitted); + dashboard.IncidentAccepted = await CountReportsAsync(departmentId, RmsRecordState.Accepted); + dashboard.IncidentRejected = await CountReportsAsync(departmentId, RmsRecordState.Rejected); + }); + + await SafeAsync(dashboard, "overdue obligations", async () => + { + dashboard.Overdue = await _dueStates.CountOverdueAsync(departmentId); + }); + + await SafeAsync(dashboard, "incident analyses", async () => + { + dashboard.AnalysesAwaitingFiling = await _analyses.CountByStateAsync(departmentId, RmsIncidentAnalysisState.Finalized); + }); + + await SafeAsync(dashboard, "disclosure requests", async () => + { + var open = 0; + foreach (var state in new[] { RmsDisclosureState.Received, RmsDisclosureState.Scoping, RmsDisclosureState.InReview, RmsDisclosureState.Produced }) + open += await _disclosures.CountByStateAsync(departmentId, state); + + dashboard.DisclosuresOpen = open; + dashboard.DisclosuresOverdue = await _disclosures.CountOverdueAsync(departmentId, now); + }); + + return dashboard; + } + + public async Task GetCrosswalkCoverageAsync(int departmentId, CancellationToken cancellationToken = default) + { + var coverage = new NerisCrosswalkCoverage { ContractVersion = _neris.ContractVersion }; + + List crosswalks; + try + { + crosswalks = await _neris.GetCrosswalksAsync(departmentId) ?? new List(); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Crosswalk coverage could not read the department {departmentId} crosswalks."); + coverage.Warnings.Add("The department's crosswalks could not be read."); + return coverage; + } + + var mapped = crosswalks + .Where(c => c != null && string.Equals(c.SetKey, "incident_type", StringComparison.Ordinal) && string.Equals(c.LocalSource, NerisCrosswalkSources.CallType, StringComparison.Ordinal)) + .GroupBy(c => c.LocalCode ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().NerisCode, StringComparer.OrdinalIgnoreCase); + + List callTypes; + try + { + callTypes = await _calls.GetCallTypesForDepartmentAsync(departmentId) ?? new List(); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Crosswalk coverage could not read the department {departmentId} call types."); + coverage.Warnings.Add("The department's call types could not be read."); + return coverage; + } + + var incidentTypes = _neris.GetValueSet("incident_type"); + var known = incidentTypes?.Codes == null + ? null + : new HashSet(incidentTypes.Codes, StringComparer.Ordinal); + + foreach (var callType in callTypes.Where(t => !string.IsNullOrWhiteSpace(t?.Type)).OrderBy(t => t.Type, StringComparer.OrdinalIgnoreCase)) + { + mapped.TryGetValue(callType.Type, out var nerisCode); + coverage.Items.Add(new NerisCrosswalkCoverageItem + { + SetKey = "incident_type", + LocalCode = callType.Type, + NerisCode = nerisCode + }); + + coverage.TotalLocalCodes++; + if (string.IsNullOrWhiteSpace(nerisCode)) + { + coverage.UnmappedCount++; + continue; + } + + coverage.MappedCount++; + + // A mapping to a code the pinned contract no longer carries looks configured and fails at + // submission. Counting it separately is the difference between a gap report and a false all-clear. + if (known != null && !known.Contains(nerisCode)) + coverage.StaleMappingCount++; + } + + return coverage; + } + + private async Task CountReportsAsync(int departmentId, params RmsRecordState[] states) + { + return await _incidentReports.CountAsync(departmentId, new RmsIncidentReportQuery + { + States = states.Select(s => (int)s).ToList(), + Take = 1 + }); + } + + /// One failed count degrades to a warning; the rest of the dashboard still renders. + private static async Task SafeAsync(RecordsDashboard dashboard, string what, Func work) + { + try + { + await work(); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Records dashboard could not produce {what}."); + dashboard.Warnings.Add($"The {what} count is unavailable."); + } + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs new file mode 100644 index 00000000..59425700 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs @@ -0,0 +1,527 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Public-records and access-to-information workflow (RMS plan section 4.7, registry M0171, RMS-3). + /// + /// Three rules shape everything here. A production never mutates a source revision — it is a new artifact, so + /// answering a request can never damage the record it answers from. The produced set is snapshotted with the + /// revision IDs and checksums that were released, so a later amendment cannot quietly change what the + /// department is on the hook for. And redaction runs off the same classification metadata the protected-field + /// catalog will consume, so this is built once and enrolls cleanly when Protected Data lands (plan 5.9). + /// + /// + public class RecordsDisclosureService : IRecordsDisclosureService + { + public const string NumberPrefix = "PRR-"; + + private readonly IRmsDisclosureRequestsRepository _requests; + private readonly IRmsDisclosureProductionsRepository _productions; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsRevisionsRepository _revisions; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IRecordsAuthorizationService _authorization; + private readonly IDepartmentSettingsService _settings; + private readonly IUnitOfWork _unitOfWork; + + public RecordsDisclosureService(IRmsDisclosureRequestsRepository requests, IRmsDisclosureProductionsRepository productions, + IRmsOperationalRecordsRepository records, IRmsRevisionsRepository revisions, IRmsAccessAuditsRepository audits, + IRecordsAuthorizationService authorization, IDepartmentSettingsService settings, IUnitOfWork unitOfWork) + { + _requests = requests; + _productions = productions; + _records = records; + _revisions = revisions; + _audits = audits; + _authorization = authorization; + _settings = settings; + _unitOfWork = unitOfWork; + } + + public async Task CreateRequestAsync(int departmentId, string userId, RmsDisclosureRequest request, CancellationToken cancellationToken = default) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.RequesterName)) + throw new ArgumentException("A requester is required.", nameof(request)); + + var config = await SafeConfigAsync(departmentId); + var now = DateTime.UtcNow; + var receivedOn = request.ReceivedOn == default ? now : request.ReceivedOn; + + request.RmsDisclosureRequestId = Guid.NewGuid().ToString(); + request.DepartmentId = departmentId; + request.ProtectionId = Guid.NewGuid().ToString(); + request.ReceivedOn = receivedOn; + // The clock starts when the department received it, not when someone got round to logging it. + request.StatutoryDueOn ??= receivedOn.AddDays(Math.Max(1, config.StatutoryClockDays)); + request.State = (int)RmsDisclosureState.Received; + request.RedactionProfile = Blank(request.RedactionProfile) ?? Blank(config.DefaultRedactionProfile) ?? RmsRedactionProfiles.Standard; + request.CreatedOn = now; + request.CreatedByUserId = userId; + request.ModifiedOn = now; + request.ModifiedByUserId = userId; + request.RowVersion = 1; + + await InTransactionAsync(async () => + { + request.RequestNumber = await AllocateNumberAsync(departmentId, receivedOn); + await _requests.InsertAsync(request, cancellationToken, true); + await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Disclosure request logged", + new { request.RmsDisclosureRequestId, request.RequestNumber, request.StatutoryDueOn, request.JurisdictionProfile }, cancellationToken); + }); + + return request; + } + + public Task GetAsync(int departmentId, string requestId) + { + return _requests.GetByIdForDepartmentAsync(departmentId, requestId); + } + + public async Task> QueryAsync(int departmentId, IEnumerable states, int skip = 0, int take = 50) + { + var stateValues = states?.Select(s => (int)s).ToList(); + return (await _requests.GetForDepartmentAsync(departmentId, stateValues, skip, take))?.ToList() ?? new List(); + } + + public async Task SaveScopeAsync(int departmentId, string userId, string requestId, string scopeNarrative, RmsRecordQuery scope, string redactionProfile, CancellationToken cancellationToken = default) + { + var request = await LoadAsync(departmentId, requestId); + RequireOpen(request); + + // Once something has been produced, the scope is the thing that was produced against; changing it + // would leave the release describing a query that no longer exists. + var existing = await _productions.GetForRequestAsync(departmentId, requestId); + if (existing != null && existing.Any()) + throw new InvalidOperationException("The scope cannot change after a production exists; log a new request for a wider scope."); + + var now = DateTime.UtcNow; + request.ScopeNarrative = Blank(scopeNarrative); + request.ScopeQueryJson = scope == null ? null : JsonConvert.SerializeObject(Sanitize(scope)); + request.RedactionProfile = Blank(redactionProfile) ?? request.RedactionProfile; + request.State = (int)RmsDisclosureState.Scoping; + request.ModifiedOn = now; + request.ModifiedByUserId = userId; + request.RowVersion += 1; + + await InTransactionAsync(async () => + { + await _requests.UpdateAsync(request, cancellationToken, true); + await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Disclosure scope saved", new { requestId, request.RedactionProfile }, cancellationToken); + }); + + return request; + } + + public async Task PreviewScopeAsync(int departmentId, string userId, string requestId, int take = 200) + { + var request = await LoadAsync(departmentId, requestId); + var preview = new RmsDisclosureScopePreview(); + + var scope = ParseScope(request.ScopeQueryJson); + if (scope == null) + return preview; + + // The same authorization and group-scope path as the Records queue. A disclosure officer does not get + // a wider view of the department than they have anywhere else in the product. + scope.VisibleGroupIds = await _authorization.GetVisibleGroupIdsAsync(userId, departmentId); + scope.ViewerUserId = userId; + scope.Skip = 0; + scope.Take = Math.Clamp(take, 1, 1000); + + var matched = (await _records.GetByDepartmentAndStatesAsync(departmentId, scope.States, scope.Year, scope.Skip, scope.Take + 1))?.ToList() + ?? new List(); + + preview.Truncated = matched.Count > scope.Take; + foreach (var record in matched.Take(scope.Take)) + { + if (!string.IsNullOrWhiteSpace(scope.DefinitionKey) && !string.Equals(record.DefinitionKey, scope.DefinitionKey, StringComparison.Ordinal)) + continue; + + preview.MatchedCount++; + + // Group scoping still applies to a disclosure preview; an officer outside the group sees the same + // nothing they would see in the queue. + if (scope.VisibleGroupIds != null && !await _authorization.CanUserViewRecordAsync(userId, record.RmsOperationalRecordId, departmentId)) + { + preview.WithheldWholeRecordCount++; + continue; + } + + var producible = IsProducible(record, out var reason); + if (producible) + preview.ProducibleCount++; + + preview.Items.Add(new RmsDisclosureScopeItem + { + RecordId = record.RmsOperationalRecordId, + RecordNumber = record.RecordNumber ?? record.DraftReference, + DefinitionKey = record.DefinitionKey, + Summary = record.DisplaySummary, + OccurredOn = record.StartedOn ?? record.CreatedOn, + CurrentRevisionId = record.CurrentRevisionId, + Producible = producible, + NotProducibleReason = reason + }); + } + + return preview; + } + + public async Task ProduceAsync(int departmentId, string userId, string requestId, string redactionProfile = null, CancellationToken cancellationToken = default) + { + var request = await LoadAsync(departmentId, requestId); + RequireOpen(request); + + var profile = Blank(redactionProfile) ?? request.RedactionProfile ?? RmsRedactionProfiles.Standard; + var preview = await PreviewScopeAsync(departmentId, userId, requestId, 1000); + var producible = preview.Items.Where(i => i.Producible).ToList(); + if (producible.Count == 0) + throw new InvalidOperationException("The scope resolves to nothing that can be produced; a draft record is not a public record."); + + var withheld = new List(); + var produced = new List(); + var documents = new List(); + + foreach (var item in producible) + { + cancellationToken.ThrowIfCancellationRequested(); + + var revision = string.IsNullOrWhiteSpace(item.CurrentRevisionId) + ? null + : await _revisions.GetByIdForDepartmentAsync(departmentId, item.CurrentRevisionId); + + if (revision == null || string.IsNullOrWhiteSpace(revision.SnapshotJson)) + { + withheld.Add(new RmsRedactionEntry { RecordId = item.RecordId, Section = "Record", Field = "*", Basis = "No finalized revision to produce from." }); + continue; + } + + var snapshot = RecordSnapshotSerializer.Deserialize(revision.SnapshotJson); + documents.Add(Redact(snapshot, item, profile, withheld)); + + // The produced set is the point: exactly which revision, and its checksum at release time. + produced.Add(new + { + record_id = item.RecordId, + record_number = item.RecordNumber, + revision_id = revision.RmsRevisionId, + revision_number = revision.RevisionNumber, + revision_checksum = revision.Checksum + }); + } + + if (documents.Count == 0) + throw new InvalidOperationException("Nothing in scope has a finalized revision to produce from."); + + var now = DateTime.UtcNow; + var artifactJson = RecordsEvidenceService.Serialize(new + { + request_number = request.RequestNumber, + jurisdiction_profile = request.JurisdictionProfile, + redaction_profile = profile, + produced_on = now, + // The manifest and page numbering the plan asks for; a packet a requester can navigate. + manifest = documents.Select((d, i) => new { page = i + 1, record = produced[i] }).ToList(), + documents + }); + + var production = new RmsDisclosureProduction + { + RmsDisclosureProductionId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + DisclosureRequestId = requestId, + RedactionProfile = profile, + ProducedSetJson = RecordsEvidenceService.Serialize(produced), + ArtifactJson = artifactJson, + Checksum = RecordSnapshotSerializer.Checksum(artifactJson), + ByteSize = Encoding.UTF8.GetByteCount(artifactJson), + RecordCount = documents.Count, + WithheldFieldsJson = RecordsEvidenceService.Serialize(withheld), + WithheldFieldCount = withheld.Count, + PreparedByUserId = userId, + PreparedOn = now, + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }; + + await InTransactionAsync(async () => + { + production.ProductionNumber = await _productions.GetMaxProductionNumberAsync(departmentId, requestId) + 1; + await _productions.InsertAsync(production, cancellationToken, true); + + request.State = (int)RmsDisclosureState.Produced; + request.ModifiedOn = now; + request.ModifiedByUserId = userId; + request.RowVersion += 1; + await _requests.UpdateAsync(request, cancellationToken, true); + + // Every produced record is audited individually: "what did we hand out about this record" has to + // be answerable from the record, not only from the request. + foreach (var item in producible.Take(documents.Count)) + await AuditAsync(departmentId, userId, item.RecordId, RmsAccessAuditAction.Export, "Disclosure production " + request.RequestNumber, + new { production.RmsDisclosureProductionId, production.ProductionNumber, profile }, cancellationToken); + + await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Export, "Disclosure produced", + new { requestId, production.RmsDisclosureProductionId, production.RecordCount, production.WithheldFieldCount, production.Checksum }, cancellationToken); + }); + + return production; + } + + public async Task ReleaseAsync(int departmentId, string userId, string productionId, CancellationToken cancellationToken = default) + { + var production = await _productions.GetByIdForDepartmentAsync(departmentId, productionId) + ?? throw new InvalidOperationException("The production does not exist."); + + if (production.ReleasedOn.HasValue) + throw new InvalidOperationException("The production has already been released."); + + var request = await LoadAsync(departmentId, production.DisclosureRequestId); + var now = DateTime.UtcNow; + + await InTransactionAsync(async () => + { + production.ReleasedByUserId = userId; + production.ReleasedOn = now; + production.ModifiedOn = now; + production.RowVersion += 1; + await _productions.UpdateAsync(production, cancellationToken, true); + + request.State = (int)RmsDisclosureState.Released; + request.ClosedOn = now; + request.ClosedByUserId = userId; + request.ModifiedOn = now; + request.ModifiedByUserId = userId; + request.RowVersion += 1; + await _requests.UpdateAsync(request, cancellationToken, true); + + await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Share, "Disclosure released", + new { request.RmsDisclosureRequestId, request.RequestNumber, production.RmsDisclosureProductionId, production.Checksum }, cancellationToken); + }); + + return production; + } + + public async Task> GetProductionsAsync(int departmentId, string requestId) + { + return (await _productions.GetForRequestAsync(departmentId, requestId))?.ToList() ?? new List(); + } + + public async Task CloseAsync(int departmentId, string userId, string requestId, RmsDisclosureState disposition, string reason, CancellationToken cancellationToken = default) + { + if (disposition != RmsDisclosureState.Denied && disposition != RmsDisclosureState.Withdrawn && disposition != RmsDisclosureState.Closed) + throw new ArgumentException("A request closes as denied, withdrawn or closed.", nameof(disposition)); + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("A reason is required; a refusal without a recorded basis is not defensible.", nameof(reason)); + + var request = await LoadAsync(departmentId, requestId); + RequireOpen(request); + + var now = DateTime.UtcNow; + request.State = (int)disposition; + request.DispositionReason = reason.Trim(); + request.ClosedOn = now; + request.ClosedByUserId = userId; + request.ModifiedOn = now; + request.ModifiedByUserId = userId; + request.RowVersion += 1; + + await InTransactionAsync(async () => + { + await _requests.UpdateAsync(request, cancellationToken, true); + await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Disclosure closed: " + disposition, new { requestId, reason = request.DispositionReason }, cancellationToken); + }); + + return request; + } + + public async Task VerifyProductionAsync(int departmentId, string productionId) + { + var production = await _productions.GetByIdForDepartmentAsync(departmentId, productionId); + if (production == null || string.IsNullOrWhiteSpace(production.Checksum)) + return false; + + return string.Equals(production.Checksum, RecordSnapshotSerializer.Checksum(production.ArtifactJson ?? string.Empty), StringComparison.Ordinal); + } + + // ── internals ──────────────────────────────────────────────────────────────── + + /// + /// Redacts one revision snapshot for release. Restricted detail fields come out under the standard + /// profile; participant identity comes out as well under the no-identifiers profile. Withholding is + /// logged rather than silent, because a requester is entitled to know something was withheld even when + /// they are not entitled to the content. + /// + private static object Redact(RecordSnapshot snapshot, RmsDisclosureScopeItem item, string profile, List withheld) + { + var full = string.Equals(profile, RmsRedactionProfiles.FullDisclosure, StringComparison.Ordinal); + var hideIdentities = string.Equals(profile, RmsRedactionProfiles.NoPersonalIdentifiers, StringComparison.Ordinal); + + var details = new Dictionary(StringComparer.Ordinal); + foreach (var field in RecordSnapshotSerializer.DetailFieldOrder) + { + var value = ReadDetail(snapshot?.Details, field); + if (value == null) + continue; + + if (!full && RecordSnapshotSerializer.RestrictedDetailFields.Contains(field)) + { + withheld.Add(new RmsRedactionEntry { RecordId = item.RecordId, Section = "Details", Field = field, Basis = "Restricted class" }); + continue; + } + + details[field] = value; + } + + var participants = new List(); + foreach (var participant in snapshot?.Participants ?? new List()) + { + if (hideIdentities) + { + withheld.Add(new RmsRedactionEntry { RecordId = item.RecordId, Section = "Participants", Field = "Identity", Basis = "Personal identifiers withheld by profile" }); + continue; + } + + participants.Add(new { name = participant.DisplayNameSnapshot, role = participant.Role, group = participant.GroupNameSnapshot }); + } + + return new + { + record_id = item.RecordId, + record_number = item.RecordNumber, + definition_key = item.DefinitionKey, + occurred_on = item.OccurredOn, + summary = item.Summary, + details, + participants, + units = (snapshot?.Units ?? new List()).Select(u => new { unit = u.UnitNameSnapshot }).ToList() + }; + } + + private static string ReadDetail(RmsOperationalRecordDetail details, string field) + { + if (details == null) + return null; + + var property = typeof(RmsOperationalRecordDetail).GetProperty(field); + return property?.PropertyType == typeof(string) ? (string)property.GetValue(details) : null; + } + + /// A public record is a finalized one. Drafts and voided records are listed, never produced. + private static bool IsProducible(RmsOperationalRecord record, out string reason) + { + var state = (RmsRecordState)record.State; + if (state == RmsRecordState.Finalized || state == RmsRecordState.Amended) + { + reason = null; + return true; + } + + reason = state == RmsRecordState.Voided || state == RmsRecordState.Cancelled + ? "The record was voided or cancelled." + : "The record is not finalized; a draft is not a public record."; + return false; + } + + /// + /// A scope arriving from a client is never trusted with the viewer fields: those are set from the caller's + /// own authorization, or a request could be scoped to see somebody else's groups. + /// + private static RmsRecordQuery Sanitize(RmsRecordQuery scope) + { + return new RmsRecordQuery + { + States = scope.States, + DefinitionKey = scope.DefinitionKey, + Year = scope.Year, + CallId = scope.CallId, + StationGroupId = scope.StationGroupId, + Take = Math.Clamp(scope.Take <= 0 ? 200 : scope.Take, 1, 1000) + }; + } + + private static RmsRecordQuery ParseScope(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try { return JsonConvert.DeserializeObject(json); } + catch (JsonException ex) { Logging.LogException(ex, "A disclosure scope query could not be parsed."); return null; } + } + + private async Task LoadAsync(int departmentId, string requestId) + { + var request = await _requests.GetByIdForDepartmentAsync(departmentId, requestId); + if (request == null || request.DeletedOn.HasValue) + throw new InvalidOperationException("The disclosure request does not exist."); + return request; + } + + private static void RequireOpen(RmsDisclosureRequest request) + { + if (request.ClosedOn.HasValue) + throw new InvalidOperationException("The request is closed; log a new one rather than reopening a released answer."); + } + + private async Task AllocateNumberAsync(int departmentId, DateTime receivedOn) + { + var prefix = NumberPrefix + receivedOn.Year + "-"; + var sequence = await _requests.GetMaxRequestNumberSequenceAsync(departmentId, prefix) + 1; + return prefix + sequence.ToString("D4"); + } + + private async Task SafeConfigAsync(int departmentId) + { + try { return await _settings.GetRecordsDisclosureConfigAsync(departmentId) ?? new RecordsDisclosureConfig(); } + catch (Exception ex) { Logging.LogException(ex); return new RecordsDisclosureConfig(); } + } + + private static string Blank(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private Task AuditAsync(int departmentId, string userId, string recordId, RmsAccessAuditAction action, string purpose, object detail, CancellationToken cancellationToken) + { + return _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + RecordId = recordId, + Action = (int)action, + ActorUserId = userId, + Purpose = purpose, + OriginClient = (int)RmsOriginClient.Web, + Successful = true, + OccurredOn = DateTime.UtcNow, + DetailJson = detail == null ? null : JsonConvert.SerializeObject(detail) + }, cancellationToken, true); + } + + private async Task InTransactionAsync(Func work) + { + _unitOfWork.CreateOrGetConnection(); + try + { + await work(); + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsDueStateService.cs b/Core/Resgrid.Services/Records/RecordsDueStateService.cs new file mode 100644 index 00000000..31a0c745 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsDueStateService.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Queue; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Worker command 42, the bounded RecordOverdue evaluation (RMS plan RMS-3). For every activated department it + /// works out where each open Record stands against its obligations, writes the answer to + /// , and emits trigger 112 plus notification 32 only where the answer changed + /// into overdue. + /// + /// The plan is explicit that the "at most once per record/due-state transition" guarantee must be carried by + /// the persisted row rather than inferred from the last run time. That is why nothing here reads a + /// last-run timestamp: a run that never happened emits late, and a run that happens twice emits once. + /// + /// + /// Bounded by construction: a department is capped at open Records per + /// sweep and emissions, so a department that has let a thousand + /// reports go stale produces a steady trickle of chasing rather than a thousand notifications at 04:00. + /// + /// + public class RecordsDueStateService : IRecordsDueStateService + { + public const int MaxRecordsPerDepartment = 2000; + public const int MaxEmissionsPerDepartment = 100; + + /// How long a returned or rejected Record may sit with its author before it counts as overdue. + public const int CorrectionGraceHours = 72; + + private readonly IRmsDepartmentCutoversRepository _cutovers; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsIncidentReportsRepository _incidentReports; + private readonly IRmsRecordDueStatesRepository _dueStates; + private readonly IDomainEventOutboxService _outbox; + private readonly IOutboundQueueProvider _outboundQueue; + + public RecordsDueStateService(IRmsDepartmentCutoversRepository cutovers, IRmsOperationalRecordsRepository records, + IRmsIncidentReportsRepository incidentReports, IRmsRecordDueStatesRepository dueStates, IDomainEventOutboxService outbox, + IOutboundQueueProvider outboundQueue) + { + _cutovers = cutovers; + _records = records; + _incidentReports = incidentReports; + _dueStates = dueStates; + _outbox = outbox; + _outboundQueue = outboundQueue; + } + + public async Task SweepAsync(CancellationToken cancellationToken = default) + { + var result = new RecordsDueStateSweepResult(); + var active = (await _cutovers.GetActiveAsync())?.ToList() ?? new List(); + + foreach (var cutover in active) + { + cancellationToken.ThrowIfCancellationRequested(); + result.DepartmentsEvaluated++; + + try + { + var department = await EvaluateDepartmentAsync(cutover.DepartmentId, cancellationToken); + result.RecordsEvaluated += department.RecordsEvaluated; + result.BecameOverdue += department.BecameOverdue; + result.Cleared += department.Cleared; + result.NotificationsSent += department.NotificationsSent; + result.Errors += department.Errors; + } + catch (Exception ex) + { + // One department's bad data never stops the sweep for the rest. + Logging.LogException(ex, $"Due-state evaluation failed for department {cutover.DepartmentId}."); + result.Errors++; + } + } + + result.Message = $"{result.DepartmentsEvaluated} departments, {result.RecordsEvaluated} records, {result.BecameOverdue} newly overdue."; + return result; + } + + public async Task EvaluateDepartmentAsync(int departmentId, CancellationToken cancellationToken = default) + { + var result = new RecordsDueStateSweepResult { DepartmentsEvaluated = 1 }; + var now = DateTime.UtcNow; + var emissions = new List(); + var observed = new HashSet(StringComparer.Ordinal); + + var open = (await _records.GetOpenAsync(departmentId))?.Take(MaxRecordsPerDepartment).ToList() ?? new List(); + foreach (var record in open) + { + cancellationToken.ThrowIfCancellationRequested(); + result.RecordsEvaluated++; + foreach (var observation in ObserveOperational(record, now)) + await ApplyAsync(departmentId, observation, now, emissions, observed, result, cancellationToken); + } + + var reports = (await _incidentReports.QueryAsync(departmentId, new RmsIncidentReportQuery + { + States = OpenReportStates, + Skip = 0, + Take = MaxRecordsPerDepartment + }))?.ToList() ?? new List(); + + foreach (var report in reports) + { + cancellationToken.ThrowIfCancellationRequested(); + result.RecordsEvaluated++; + foreach (var observation in ObserveIncidentReport(report, now)) + await ApplyAsync(departmentId, observation, now, emissions, observed, result, cancellationToken); + } + + // Anything still carrying an open obligation that this pass did not observe has met it (or the Record + // left the state that created it). Clearing here rather than from the lifecycle transitions keeps the + // bookkeeping self-healing: a transition that failed to call in still resolves on the next sweep, and + // a stale Overdue row can never block a later, genuine emission. + foreach (var stale in (await _dueStates.GetOpenForDepartmentAsync(departmentId, MaxRecordsPerDepartment))?.ToList() ?? new List()) + { + if (observed.Contains(Key(stale.RecordId, (RmsRecordObligation)stale.Obligation))) + continue; + + stale.LastEmittedState = (int)RmsDueState.Cleared; + stale.ModifiedOn = now; + stale.RowVersion += 1; + await _dueStates.UpdateAsync(stale, cancellationToken, true); + result.Cleared++; + } + + // Events and notifications leave only after the state rows are committed, so a crash between the two + // re-emits at worst nothing: the row already says overdue, so the next run stays silent. + await _outbox.DispatchAfterCommitAsync(emissions.Where(e => e.OutboxId.HasValue).Select(e => e.OutboxId.Value), cancellationToken); + foreach (var emission in emissions.Where(e => e.Notification != null)) + { + try + { + await _outboundQueue.EnqueueNotification(emission.Notification); + result.NotificationsSent++; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Overdue notification for record {emission.RecordId} could not be queued."); + result.Errors++; + } + } + + return result; + } + + public async Task ClearForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(recordId)) + return; + + try + { + await _dueStates.ClearForRecordAsync(departmentId, recordId, DateTime.UtcNow, cancellationToken); + } + catch (Exception ex) + { + // Clearing is bookkeeping; a failure must never fail the lifecycle transition that asked for it. + Logging.LogException(ex, $"Due states for record {recordId} could not be cleared."); + } + } + + private static readonly List OpenReportStates = new List + { + (int)RmsRecordState.Draft, (int)RmsRecordState.ReadyForReview, (int)RmsRecordState.Returned, + (int)RmsRecordState.Approved, (int)RmsRecordState.Rejected + }; + + /// One obligation's computed standing for one Record. + private readonly struct Observation + { + public Observation(string recordId, RmsRecordKind kind, RmsRecordObligation obligation, DateTime? dueOn, RmsDueState state, string responsibleUserId, object recordBlock) + { + RecordId = recordId; + Kind = kind; + Obligation = obligation; + DueOn = dueOn; + State = state; + ResponsibleUserId = responsibleUserId; + RecordBlock = recordBlock; + } + + public string RecordId { get; } + public RmsRecordKind Kind { get; } + public RmsRecordObligation Obligation { get; } + public DateTime? DueOn { get; } + public RmsDueState State { get; } + public string ResponsibleUserId { get; } + public object RecordBlock { get; } + } + + /// Identity of one obligation on one Record, as the reconciliation pass compares them. + private static string Key(string recordId, RmsRecordObligation obligation) => recordId + "|" + (int)obligation; + + private sealed class Emission + { + public string RecordId { get; set; } + public long? OutboxId { get; set; } + public NotificationItem Notification { get; set; } + } + + private static IEnumerable ObserveOperational(RmsOperationalRecord record, DateTime now) + { + var state = (RmsRecordState)record.State; + var block = RecordsService.RecordBlock(record, null, state); + + if (state == RmsRecordState.ReadyForReview) + { + yield return new Observation(record.RmsOperationalRecordId, RmsRecordKind.Operational, RmsRecordObligation.Review, + record.ReviewDueOn, Standing(record.ReviewDueOn, now), record.ReviewerUserId ?? record.OwnerUserId, block); + } + else if (state == RmsRecordState.Returned) + { + var due = record.ReturnedOn?.AddHours(CorrectionGraceHours); + yield return new Observation(record.RmsOperationalRecordId, RmsRecordKind.Operational, RmsRecordObligation.Correction, + due, Standing(due, now), record.OwnerUserId ?? record.AuthorUserId, block); + } + } + + private static IEnumerable ObserveIncidentReport(RmsIncidentReport report, DateTime now) + { + var state = (RmsRecordState)report.State; + var block = IncidentReportsService.RecordBlock(report, null, state); + + if (state == RmsRecordState.ReadyForReview) + { + yield return new Observation(report.RmsIncidentReportId, RmsRecordKind.IncidentReport, RmsRecordObligation.Review, + report.ReviewDueOn, Standing(report.ReviewDueOn, now), report.ReviewerUserId ?? report.OwnerUserId, block); + } + else if (state == RmsRecordState.Returned) + { + var due = report.ReturnedOn?.AddHours(CorrectionGraceHours); + yield return new Observation(report.RmsIncidentReportId, RmsRecordKind.IncidentReport, RmsRecordObligation.Correction, + due, Standing(due, now), report.OwnerUserId ?? report.AuthorUserId, block); + } + else if (state == RmsRecordState.Rejected) + { + // A rejected filing is the department's problem to fix; the clock runs from the rejection. + var due = report.RejectedOn?.AddHours(CorrectionGraceHours); + yield return new Observation(report.RmsIncidentReportId, RmsRecordKind.IncidentReport, RmsRecordObligation.Submission, + due, Standing(due, now), report.OwnerUserId ?? report.AuthorUserId, block); + } + } + + /// An obligation with no deadline is never overdue; the department simply has not set one. + private static RmsDueState Standing(DateTime? dueOn, DateTime now) + { + if (!dueOn.HasValue) + return RmsDueState.NotDue; + if (now >= dueOn.Value) + return RmsDueState.Overdue; + return now >= dueOn.Value.AddHours(-24) ? RmsDueState.DueSoon : RmsDueState.NotDue; + } + + private async Task ApplyAsync(int departmentId, Observation observation, DateTime now, List emissions, HashSet observed, RecordsDueStateSweepResult result, CancellationToken cancellationToken) + { + observed.Add(Key(observation.RecordId, observation.Obligation)); + var row = await _dueStates.GetAsync(departmentId, observation.RecordId, observation.Obligation); + var deadlineMoved = row != null && row.DueOn != observation.DueOn; + + if (row == null) + { + row = new RmsRecordDueState + { + RmsRecordDueStateId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + RecordId = observation.RecordId, + RecordKind = (int)observation.Kind, + Obligation = (int)observation.Obligation, + DueOn = observation.DueOn, + LastEmittedState = (int)RmsDueState.NotDue, + ResponsibleUserId = observation.ResponsibleUserId, + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }; + await _dueStates.InsertAsync(row, cancellationToken, true); + } + + // A moved deadline re-arms emission: the obligation the department is now tracking is a different one. + var alreadyEmitted = (RmsDueState)row.LastEmittedState == RmsDueState.Overdue && !deadlineMoved; + var becameOverdue = observation.State == RmsDueState.Overdue && !alreadyEmitted; + + row.DueOn = observation.DueOn; + row.ResponsibleUserId = observation.ResponsibleUserId; + row.ModifiedOn = now; + + if (becameOverdue) + { + row.LastEmittedState = (int)RmsDueState.Overdue; + row.LastEmittedOn = now; + row.OverdueCount += 1; + result.BecameOverdue++; + } + else if (observation.State != RmsDueState.Overdue && (RmsDueState)row.LastEmittedState == RmsDueState.Overdue) + { + row.LastEmittedState = (int)observation.State; + result.Cleared++; + } + else if (!becameOverdue && (RmsDueState)row.LastEmittedState != RmsDueState.Overdue) + { + row.LastEmittedState = (int)observation.State; + } + + row.RowVersion += 1; + await _dueStates.UpdateAsync(row, cancellationToken, true); + + if (!becameOverdue || emissions.Count >= MaxEmissionsPerDepartment) + return; + + var emission = new Emission { RecordId = observation.RecordId }; + var overdueHours = observation.DueOn.HasValue ? (int)Math.Max(0, (now - observation.DueOn.Value).TotalHours) : 0; + + try + { + var entry = await _outbox.EnqueueAsync(departmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = WorkflowTriggerEventType.RecordOverdue.ToString(), + SchemaVersion = 1, + AggregateType = observation.Kind == RmsRecordKind.IncidentReport ? IncidentReportsService.IncidentAggregate : DomainEventProducers.RecordsAggregate, + AggregateId = observation.RecordId, + AggregateVersion = 0, + Trigger = WorkflowTriggerEventType.RecordOverdue, + Payload = new Dictionary + { + ["record"] = observation.RecordBlock, + ["obligation"] = new + { + type = observation.Obligation.ToString(), + due_on = observation.DueOn, + overdue_hours = overdueHours, + responsible_user_id = observation.ResponsibleUserId ?? string.Empty, + overdue_count = row.OverdueCount + } + }, + CorrelationId = observation.RecordId, + OriginClient = RmsOriginClient.System + }, cancellationToken); + emission.OutboxId = entry.DomainEventOutboxId; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Overdue event for record {observation.RecordId} could not be enqueued."); + result.Errors++; + } + + if (!string.IsNullOrWhiteSpace(observation.ResponsibleUserId)) + { + emission.Notification = new NotificationItem + { + DepartmentId = departmentId, + Type = (int)EventTypes.RecordReviewOverdue, + Value = observation.RecordId + "|" + (int)observation.Obligation, + UserId = observation.ResponsibleUserId + }; + } + + emissions.Add(emission); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsEvidenceService.cs b/Core/Resgrid.Services/Records/RecordsEvidenceService.cs new file mode 100644 index 00000000..ef40ae5b --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsEvidenceService.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Capture and read of immutable evidence artifacts (RMS plan sections 4.5, 5.2; registry M0169, RMS-3). + /// + /// The plan requires every one of the six sources to prove authorization, provenance, classification, checksum + /// and retention behaviour. Those five live here rather than in the adapters, so a new source cannot ship with + /// a weaker rule than the others by accident: an adapter only decides what a bounded snapshot of its subsystem + /// looks like, and this service decides what happens to it. + /// + /// + public class RecordsEvidenceService : IRecordsEvidenceService + { + private readonly IRmsEvidenceArtifactsRepository _artifacts; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsIncidentReportsRepository _incidentReports; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IUnitOfWork _unitOfWork; + private readonly IEnumerable _adapters; + + public RecordsEvidenceService(IRmsEvidenceArtifactsRepository artifacts, IRmsOperationalRecordsRepository records, + IRmsIncidentReportsRepository incidentReports, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork, + IEnumerable adapters) + { + _artifacts = artifacts; + _records = records; + _incidentReports = incidentReports; + _audits = audits; + _unitOfWork = unitOfWork; + _adapters = adapters ?? Enumerable.Empty(); + } + + public async Task> GetSourceStatesAsync(int departmentId) + { + var states = new List(); + + // Every kind is reported, present or not: an author who cannot see that readiness evidence is + // unavailable will assume there was none, which is a different and wrong conclusion. + foreach (RmsEvidenceKind kind in Enum.GetValues(typeof(RmsEvidenceKind))) + { + var adapter = _adapters.FirstOrDefault(a => a.Kind == kind); + if (adapter == null) + { + states.Add(new RecordEvidenceSourceState { Kind = kind, Available = false, Reason = "No adapter is registered for this source." }); + continue; + } + + try + { + var available = await adapter.IsAvailableAsync(departmentId); + states.Add(new RecordEvidenceSourceState { Kind = kind, Available = available, Reason = available ? null : "The source subsystem is not available for this department." }); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Evidence source {kind} could not report availability for department {departmentId}."); + states.Add(new RecordEvidenceSourceState { Kind = kind, Available = false, Reason = "The source subsystem could not be reached." }); + } + } + + return states; + } + + public async Task CaptureAsync(RecordEvidenceCaptureRequest request, bool canCaptureRestricted = true, CancellationToken cancellationToken = default) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(request.RecordId)) throw new ArgumentException("A record is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.CaptureReason)) + throw new ArgumentException("A capture reason is required; evidence never enters an official record anonymously.", nameof(request)); + + await RequireOpenRecordAsync(request); + + var adapter = _adapters.FirstOrDefault(a => a.Kind == request.Kind) + ?? throw new InvalidOperationException($"No evidence adapter is registered for {request.Kind}."); + + if (!await adapter.IsAvailableAsync(request.DepartmentId)) + throw new InvalidOperationException($"{request.Kind} evidence is not available for this department."); + + var capture = await adapter.CaptureAsync(request, cancellationToken); + if (capture == null || !capture.Available) + throw new InvalidOperationException(capture?.UnavailableReason ?? $"{request.Kind} evidence could not be captured."); + + // Classification is the adapter's judgement about its own content, but the grant check is not: a member + // without RecordRestricted_View must not be able to pull restricted content into a record they can read. + if (capture.Classification != RmsEvidenceClassification.Unrestricted && !canCaptureRestricted) + throw new InvalidOperationException("Capturing restricted evidence requires the restricted-records grant."); + + var manifestJson = Serialize(capture.Manifest); + var now = DateTime.UtcNow; + + var artifact = new RmsEvidenceArtifact + { + RmsEvidenceArtifactId = Guid.NewGuid().ToString(), + DepartmentId = request.DepartmentId, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = request.RecordId, + RecordKind = (int)request.RecordKind, + RevisionId = null, + Kind = (int)request.Kind, + Title = Trim(capture.Title) ?? request.Kind.ToString(), + CaptureReason = Trim(request.CaptureReason), + SourceSubsystem = Trim(capture.SourceSubsystem), + SourceEntityType = Trim(capture.SourceEntityType), + SourceEntityId = Trim(capture.SourceEntityId), + IdentifierScheme = Trim(capture.IdentifierScheme), + SourceVersion = Trim(capture.SourceVersion), + CoverageStart = capture.CoverageStart, + CoverageEnd = capture.CoverageEnd, + ManifestJson = manifestJson, + Checksum = RecordSnapshotSerializer.Checksum(manifestJson), + ByteSize = manifestJson == null ? 0 : Encoding.UTF8.GetByteCount(manifestJson), + SourceItemCount = capture.SourceItemCount, + Classification = (int)capture.Classification, + RetentionYears = capture.RetentionYears, + CapturedByUserId = request.CapturedByUserId, + CapturedOn = now, + OriginClient = (int)request.OriginClient, + CreatedOn = now, + ModifiedOn = now, + RowVersion = 1 + }; + + await InTransactionAsync(async () => + { + // A re-capture of the same source supersedes rather than replaces: the earlier artifact is what an + // earlier revision attested to, and deleting it would rewrite history. + var current = await _artifacts.GetCurrentDraftOfKindAsync(request.DepartmentId, request.RecordId, request.Kind, artifact.SourceEntityId); + if (current != null) + { + current.SupersededByArtifactId = artifact.RmsEvidenceArtifactId; + current.SupersededOn = now; + current.ModifiedOn = now; + current.RowVersion += 1; + await _artifacts.UpdateAsync(current, cancellationToken, true); + } + + await _artifacts.InsertAsync(artifact, cancellationToken, true); + await AuditAsync(artifact, RmsAccessAuditAction.Change, "Evidence captured: " + request.Kind, cancellationToken); + }); + + return artifact; + } + + public async Task> GetForRecordAsync(int departmentId, string recordId, string revisionId = null, bool includeSuperseded = false) + { + return (await _artifacts.GetForRecordAsync(departmentId, recordId, revisionId, includeSuperseded))?.ToList() ?? new List(); + } + + public Task GetAsync(int departmentId, string artifactId) + { + return _artifacts.GetByIdForDepartmentAsync(departmentId, artifactId); + } + + public Task BindToRevisionAsync(int departmentId, string recordId, string revisionId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(revisionId)) + throw new ArgumentException("A revision is required.", nameof(revisionId)); + + return _artifacts.BindDraftToRevisionAsync(departmentId, recordId, revisionId, DateTime.UtcNow, cancellationToken); + } + + public async Task VerifyAsync(int departmentId, string artifactId) + { + var artifact = await _artifacts.GetByIdForDepartmentAsync(departmentId, artifactId); + if (artifact == null || string.IsNullOrWhiteSpace(artifact.Checksum)) + return false; + + return string.Equals(artifact.Checksum, RecordSnapshotSerializer.Checksum(artifact.ManifestJson ?? string.Empty), StringComparison.Ordinal); + } + + /// + /// Evidence attaches to a Record that exists and is still open. Attaching to a voided or cancelled Record + /// would put supporting material behind a filing nobody stands behind any more. + /// + private async Task RequireOpenRecordAsync(RecordEvidenceCaptureRequest request) + { + if (request.RecordKind == RmsRecordKind.IncidentReport) + { + var report = await _incidentReports.GetByIdForDepartmentAsync(request.DepartmentId, request.RecordId); + if (report == null || report.DeletedOn.HasValue) + throw new InvalidOperationException($"Incident report {request.RecordId} does not exist in department {request.DepartmentId}."); + if (RmsLifecycle.IsTerminal((RmsRecordState)report.State)) + throw new InvalidOperationException("Evidence cannot be captured against a voided or cancelled report."); + return; + } + + var record = await _records.GetByIdForDepartmentAsync(request.DepartmentId, request.RecordId); + if (record == null || record.DeletedOn.HasValue) + throw new InvalidOperationException($"Record {request.RecordId} does not exist in department {request.DepartmentId}."); + if (RmsLifecycle.IsTerminal((RmsRecordState)record.State)) + throw new InvalidOperationException("Evidence cannot be captured against a voided or cancelled Record."); + } + + /// + /// Deterministic serialization: the same manifest must produce the same bytes, or the checksum an auditor + /// re-computes years later will not match the one that was stored. + /// + public static string Serialize(object manifest) + { + if (manifest == null) + return "{}"; + + return JsonConvert.SerializeObject(manifest, new JsonSerializerSettings + { + Formatting = Formatting.None, + DateFormatHandling = DateFormatHandling.IsoDateFormat, + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + NullValueHandling = NullValueHandling.Ignore + }); + } + + private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private Task AuditAsync(RmsEvidenceArtifact artifact, RmsAccessAuditAction action, string purpose, CancellationToken cancellationToken) + { + return _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = artifact.DepartmentId, + RecordId = artifact.RecordId, + RevisionId = artifact.RevisionId, + Action = (int)action, + ActorUserId = artifact.CapturedByUserId, + Purpose = purpose, + OriginClient = artifact.OriginClient, + Successful = true, + OccurredOn = DateTime.UtcNow, + DetailJson = JsonConvert.SerializeObject(new + { + artifact.RmsEvidenceArtifactId, + artifact.Kind, + artifact.SourceSubsystem, + artifact.SourceEntityId, + artifact.Checksum, + artifact.SourceItemCount, + artifact.Classification, + artifact.CaptureReason + }) + }, cancellationToken, true); + } + + private async Task InTransactionAsync(Func work) + { + _unitOfWork.CreateOrGetConnection(); + try + { + await work(); + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsNotificationService.cs b/Core/Resgrid.Services/Records/RecordsNotificationService.cs index fbaa4637..1bedfe3e 100644 --- a/Core/Resgrid.Services/Records/RecordsNotificationService.cs +++ b/Core/Resgrid.Services/Records/RecordsNotificationService.cs @@ -18,6 +18,7 @@ public class RecordsNotificationService : IRecordsNotificationService { public const string ReturnedForCorrectionTitle = "Record returned for correction"; public const string SubmissionRejectedTitle = "Incident report rejected by NERIS"; + public const string ObligationOverdueTitle = "Record is overdue"; private const int MaxReasonTextLength = 200; private readonly IRmsOperationalRecordsRepository _records; @@ -94,6 +95,84 @@ public async Task NotifySubmissionRejectedAsync(int departmentId, string r } } + public async Task NotifyObligationOverdueAsync(int departmentId, string recordId, RmsRecordObligation obligation, CancellationToken cancellationToken = default) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(recordId)) + return false; + + // Both aggregates share the id space; whichever holds the row supplies the reference and the target. + string reference = null, targetUserId = null, detailPath = null; + DateTime? dueOn = null; + + var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); + if (record != null && !record.DeletedOn.HasValue) + { + reference = string.IsNullOrWhiteSpace(record.RecordNumber) ? record.DraftReference : record.RecordNumber; + dueOn = record.ReviewDueOn; + targetUserId = ResponsibleFor(obligation, record.ReviewerUserId, record.OwnerUserId, record.AuthorUserId); + detailPath = "/User/Records/Details/"; + } + else + { + var report = await _incidentReports.GetByIdForDepartmentAsync(departmentId, recordId); + if (report == null || report.DeletedOn.HasValue) + return false; + + reference = string.IsNullOrWhiteSpace(report.RecordNumber) ? report.DraftReference : report.RecordNumber; + dueOn = report.ReviewDueOn; + targetUserId = ResponsibleFor(obligation, report.ReviewerUserId, report.OwnerUserId, report.AuthorUserId); + detailPath = "/User/IncidentReports/Details/"; + } + + if (string.IsNullOrWhiteSpace(targetUserId)) + return false; + + cancellationToken.ThrowIfCancellationRequested(); + var department = await _departments.GetDepartmentByIdAsync(departmentId, false); + var departmentNumber = await _departmentSettings.GetTextToCallNumberForDepartmentAsync(departmentId); + var target = await _profiles.GetProfileByUserIdAsync(targetUserId, false); + var message = BuildObligationOverdueMessage(reference, obligation, dueOn, detailPath + recordId); + + try + { + return await _communication.SendNotificationAsync(targetUserId, departmentId, message, departmentNumber, department, ObligationOverdueTitle, target); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Overdue notification for record {recordId} could not be sent."); + return false; + } + } + + /// A review rests with the reviewer; a correction or a resubmission rests with the owner, else the author. + private static string ResponsibleFor(RmsRecordObligation obligation, string reviewerUserId, string ownerUserId, string authorUserId) + { + if (obligation == RmsRecordObligation.Review && !string.IsNullOrWhiteSpace(reviewerUserId)) + return reviewerUserId; + + return string.IsNullOrWhiteSpace(ownerUserId) ? authorUserId : ownerUserId; + } + + public static string BuildObligationOverdueMessage(string reference, RmsRecordObligation obligation, DateTime? dueOn, string detailPath) + { + var what = obligation == RmsRecordObligation.Review ? "review" + : obligation == RmsRecordObligation.Correction ? "correction" + : "resubmission"; + + var builder = new StringBuilder(); + builder.Append("Record ").Append(reference).Append(" is overdue for ").Append(what).Append('.'); + + if (dueOn.HasValue) + { + var hours = (int)Math.Max(0, (DateTime.UtcNow - dueOn.Value).TotalHours); + builder.Append(" It was due ").Append(hours).Append(hours == 1 ? " hour ago." : " hours ago."); + } + + var baseUrl = (Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/'); + builder.Append(' ').Append(baseUrl).Append(detailPath); + return builder.ToString(); + } + /// Header, normalized rejection summary (codes and field paths, never destination payloads) and a link. public static string BuildSubmissionRejectedMessage(RmsIncidentReport report) { diff --git a/Core/Resgrid.Services/Records/RecordsRetentionService.cs b/Core/Resgrid.Services/Records/RecordsRetentionService.cs new file mode 100644 index 00000000..6aa8a9fb --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsRetentionService.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Worker command 43 (RmsRetentionAndPurgeCommand, RMS plan RMS-3). Three passes per activated department, + /// each bounded and each independently safe to interrupt: + /// + /// Retention. Records past their resolved retention period lose their content and keep their + /// identity — a content-free tombstone (plan section 4.9), so a reference to a purged Record still resolves + /// to a Record that says it was purged rather than to nothing. + /// Legal hold. Anything an active hold covers is skipped, and the refusal is written to the + /// access audit. A silent skip would be indistinguishable from a sweep that never ran. + /// Attachment rescan. Attachments left at Pending — stored while the scanner was unreachable + /// because the department chose availability over fail-closed — are re-submitted to the scanner. Without this + /// pass a Pending attachment stays unscanned forever, which is the RMS-1 gap this package closes. + /// + /// Restricted classes resolve to permanent retention and are therefore never purged by this sweep at all. + /// + public class RecordsRetentionService : IRecordsRetentionService + { + public const int MaxRecordsPerDepartment = 500; + public const int MaxRescansPerDepartment = 100; + public const string PurgeAuditPurpose = "Retention purge"; + public const string HeldAuditPurpose = "Retention purge refused: legal hold"; + public const string PurgedPlaceholder = "[purged]"; + + private readonly IRmsDepartmentCutoversRepository _cutovers; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsIncidentReportsRepository _incidentReports; + private readonly IRmsOperationalRecordDetailsRepository _details; + private readonly IRmsRecordAttachmentsRepository _attachments; + private readonly IRmsRecordLegalHoldsRepository _legalHolds; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IRmsRecordSearchProjectionsRepository _projections; + private readonly IRecordAttachmentScanner _scanner; + private readonly IDepartmentSettingsService _settings; + + public RecordsRetentionService(IRmsDepartmentCutoversRepository cutovers, IRmsOperationalRecordsRepository records, + IRmsIncidentReportsRepository incidentReports, IRmsOperationalRecordDetailsRepository details, IRmsRecordAttachmentsRepository attachments, + IRmsRecordLegalHoldsRepository legalHolds, IRmsAccessAuditsRepository audits, IRmsRecordSearchProjectionsRepository projections, + IRecordAttachmentScanner scanner, IDepartmentSettingsService settings) + { + _cutovers = cutovers; + _records = records; + _incidentReports = incidentReports; + _details = details; + _attachments = attachments; + _legalHolds = legalHolds; + _audits = audits; + _projections = projections; + _scanner = scanner; + _settings = settings; + } + + public async Task SweepAsync(CancellationToken cancellationToken = default) + { + var result = new RecordsRetentionSweepResult(); + var active = (await _cutovers.GetActiveAsync())?.ToList() ?? new List(); + + foreach (var cutover in active) + { + cancellationToken.ThrowIfCancellationRequested(); + result.DepartmentsEvaluated++; + + try + { + var department = await ProcessDepartmentAsync(cutover.DepartmentId, cancellationToken); + result.RecordsEvaluated += department.RecordsEvaluated; + result.RecordsPurged += department.RecordsPurged; + result.AttachmentsPurged += department.AttachmentsPurged; + result.HeldByLegalHold += department.HeldByLegalHold; + result.AttachmentsRescanned += department.AttachmentsRescanned; + result.AttachmentsRejectedOnRescan += department.AttachmentsRejectedOnRescan; + result.Errors += department.Errors; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Retention sweep failed for department {cutover.DepartmentId}."); + result.Errors++; + } + } + + result.Message = $"{result.DepartmentsEvaluated} departments, {result.RecordsPurged} purged, {result.HeldByLegalHold} held, {result.AttachmentsRescanned} rescanned."; + return result; + } + + public async Task ProcessDepartmentAsync(int departmentId, CancellationToken cancellationToken = default) + { + var result = new RecordsRetentionSweepResult { DepartmentsEvaluated = 1 }; + var now = DateTime.UtcNow; + + var policy = await _settings.GetRecordsRetentionPolicyAsync(departmentId) ?? new RecordsRetentionPolicy(); + var holds = (await _legalHolds.GetActiveForDepartmentAsync(departmentId))?.ToList() ?? new List(); + + // The widest period any definition could still be retained for bounds the candidate query; anything + // finalized after it cannot be past retention under any policy, so it is not worth loading. + var longestYears = LongestRetentionYears(policy); + if (longestYears > 0) + { + var cutoff = now.AddYears(-longestYears); + + foreach (var record in (await _records.GetRetentionCandidatesAsync(departmentId, cutoff, MaxRecordsPerDepartment))?.ToList() ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + result.RecordsEvaluated++; + await ConsiderOperationalAsync(departmentId, record, policy, holds, now, result, cancellationToken); + } + + foreach (var report in (await _incidentReports.GetRetentionCandidatesAsync(departmentId, cutoff, MaxRecordsPerDepartment))?.ToList() ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + result.RecordsEvaluated++; + await ConsiderIncidentReportAsync(departmentId, report, policy, holds, now, result, cancellationToken); + } + } + + await RescanPendingAttachmentsAsync(departmentId, now, result, cancellationToken); + return result; + } + + /// + /// Zero means permanent for the definitions that resolve to it, so it never bounds the query; the answer is + /// the longest finite period any definition could resolve to, or 0 when everything is permanent. + /// + private static int LongestRetentionYears(RecordsRetentionPolicy policy) + { + var years = new List(); + if (policy.DepartmentDefaultYears.HasValue && policy.DepartmentDefaultYears.Value > 0) + years.Add(policy.DepartmentDefaultYears.Value); + else if (!policy.DepartmentDefaultYears.HasValue) + years.Add(RecordsRetentionPolicy.StandardClassDefaultYears); + + foreach (var over in policy.Overrides ?? new List()) + { + if (over.RetentionYears > 0) + years.Add(over.RetentionYears); + } + + return years.Count == 0 ? 0 : years.Max(); + } + + private async Task ConsiderOperationalAsync(int departmentId, RmsOperationalRecord record, RecordsRetentionPolicy policy, + List holds, DateTime now, RecordsRetentionSweepResult result, CancellationToken cancellationToken) + { + var years = policy.ResolveYears(record.DefinitionKey); + if (years <= RecordsRetentionPolicy.Permanent) + return; + + var expiresOn = (record.FinalizedOn ?? record.CreatedOn).AddYears(years); + if (now < expiresOn) + return; + + var hold = holds.FirstOrDefault(h => h.Covers(record.RmsOperationalRecordId, record.DefinitionKey, record.StartedOn ?? record.CreatedOn)); + if (hold != null) + { + result.HeldByLegalHold++; + await AuditAsync(departmentId, record.RmsOperationalRecordId, HeldAuditPurpose, new { hold.RmsRecordLegalHoldId, hold.Reason, hold.ReferenceNumber, expiresOn }, now, cancellationToken); + return; + } + + try + { + result.AttachmentsPurged += await PurgeAttachmentsAsync(departmentId, record.RmsOperationalRecordId, now, cancellationToken); + + // Content goes; identity, number and lifecycle history stay. This is the tombstone of plan 4.9. + var detail = await _details.GetDraftAsync(departmentId, record.RmsOperationalRecordId); + if (detail != null) + { + BlankContent(detail); + detail.ModifiedOn = now; + await _details.UpdateAsync(detail, cancellationToken, true); + } + + record.DisplaySummary = PurgedPlaceholder; + record.PurgedOn = now; + record.ModifiedOn = now; + record.RowVersion += 1; + await _records.UpdateAsync(record, cancellationToken, true); + await TombstoneProjectionAsync(departmentId, record.RmsOperationalRecordId, now, cancellationToken); + + result.RecordsPurged++; + await AuditAsync(departmentId, record.RmsOperationalRecordId, PurgeAuditPurpose, new { record.DefinitionKey, years, expiresOn }, now, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Retention purge failed for record {record.RmsOperationalRecordId}."); + result.Errors++; + } + } + + private async Task ConsiderIncidentReportAsync(int departmentId, RmsIncidentReport report, RecordsRetentionPolicy policy, + List holds, DateTime now, RecordsRetentionSweepResult result, CancellationToken cancellationToken) + { + var years = policy.ResolveYears(report.DefinitionKey); + if (years <= RecordsRetentionPolicy.Permanent) + return; + + var expiresOn = (report.FinalizedOn ?? report.CreatedOn).AddYears(years); + if (now < expiresOn) + return; + + var hold = holds.FirstOrDefault(h => h.Covers(report.RmsIncidentReportId, report.DefinitionKey, report.CallCreatedOn ?? report.CreatedOn)); + if (hold != null) + { + result.HeldByLegalHold++; + await AuditAsync(departmentId, report.RmsIncidentReportId, HeldAuditPurpose, new { hold.RmsRecordLegalHoldId, hold.Reason, hold.ReferenceNumber, expiresOn }, now, cancellationToken); + return; + } + + try + { + result.AttachmentsPurged += await PurgeAttachmentsAsync(departmentId, report.RmsIncidentReportId, now, cancellationToken); + + report.DisplaySummary = PurgedPlaceholder; + report.ModifiedOn = now; + report.RowVersion += 1; + await _incidentReports.UpdateAsync(report, cancellationToken, true); + await TombstoneProjectionAsync(departmentId, report.RmsIncidentReportId, now, cancellationToken); + + result.RecordsPurged++; + await AuditAsync(departmentId, report.RmsIncidentReportId, PurgeAuditPurpose, new { report.DefinitionKey, years, expiresOn }, now, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Retention purge failed for incident report {report.RmsIncidentReportId}."); + result.Errors++; + } + } + + private async Task PurgeAttachmentsAsync(int departmentId, string recordId, DateTime now, CancellationToken cancellationToken) + { + var purged = 0; + foreach (var metadata in (await _attachments.GetMetadataForRecordAsync(departmentId, recordId))?.ToList() ?? new List()) + { + var attachment = await _attachments.GetByIdForDepartmentAsync(departmentId, metadata.RmsRecordAttachmentId); + if (attachment == null) + continue; + + attachment.Data = null; + attachment.StorageReference = null; + attachment.DeletedOn = now; + attachment.ModifiedOn = now; + await _attachments.UpdateAsync(attachment, cancellationToken, true); + purged++; + } + + return purged; + } + + private async Task TombstoneProjectionAsync(int departmentId, string recordId, DateTime now, CancellationToken cancellationToken) + { + var projection = await _projections.GetByRecordIdAsync(departmentId, recordId); + if (projection == null) + return; + + // The queue and the index must stop showing content the department no longer holds. + projection.DisplaySummary = PurgedPlaceholder; + projection.SearchText = null; + projection.ModifiedOn = now; + await _projections.UpdateAsync(projection, cancellationToken, true); + } + + /// + /// Re-submits Pending attachments to the scanner. A clean result promotes the row; a rejection deletes the + /// bytes and marks it Rejected, because a Pending attachment that turns out to be malware has been + /// downloadable the whole time it sat unscanned. A scanner that is still unreachable leaves the row alone. + /// + private async Task RescanPendingAttachmentsAsync(int departmentId, DateTime now, RecordsRetentionSweepResult result, CancellationToken cancellationToken) + { + List pending; + try + { + pending = (await _attachments.GetPendingScanAsync(departmentId, MaxRescansPerDepartment))?.ToList() ?? new List(); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Pending attachments could not be listed for department {departmentId}."); + result.Errors++; + return; + } + + foreach (var metadata in pending) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var attachment = await _attachments.GetByIdForDepartmentAsync(departmentId, metadata.RmsRecordAttachmentId); + if (attachment?.Data == null || attachment.Data.Length == 0) + continue; + + var scan = await _scanner.ScanAsync(attachment.FileName, attachment.ContentType, attachment.Data, cancellationToken); + if (scan == null || scan.State == RmsAttachmentScanState.Pending) + continue; + + attachment.ScanState = (int)scan.State; + if (scan.State == RmsAttachmentScanState.Rejected) + { + attachment.Data = null; + attachment.StorageReference = null; + attachment.DeletedOn = now; + result.AttachmentsRejectedOnRescan++; + } + + attachment.ModifiedOn = now; + await _attachments.UpdateAsync(attachment, cancellationToken, true); + result.AttachmentsRescanned++; + + if (scan.State == RmsAttachmentScanState.Rejected) + await AuditAsync(departmentId, attachment.RecordId, "Attachment rejected on rescan", new { attachment.RmsRecordAttachmentId, attachment.FileName, scan.Detail }, now, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Attachment {metadata.RmsRecordAttachmentId} could not be rescanned."); + result.Errors++; + } + } + } + + /// + /// Identity and keys survive a purge; every other text field on the detail row is content and goes. This is + /// done by reflection deliberately: a typed field added to the detail row later is purged by default rather + /// than quietly surviving a retention sweep nobody remembered to update. + /// + private static readonly HashSet DetailKeepFields = new HashSet(StringComparer.Ordinal) + { + nameof(RmsOperationalRecordDetail.RmsOperationalRecordDetailId), + nameof(RmsOperationalRecordDetail.ProtectionId), + nameof(RmsOperationalRecordDetail.RecordId), + nameof(RmsOperationalRecordDetail.RevisionId), + nameof(RmsOperationalRecordDetail.TableName), + nameof(RmsOperationalRecordDetail.IdName) + }; + + public static void BlankContent(RmsOperationalRecordDetail detail) + { + if (detail == null) + return; + + foreach (var property in typeof(RmsOperationalRecordDetail).GetProperties()) + { + if (property.PropertyType != typeof(string) || !property.CanWrite || DetailKeepFields.Contains(property.Name)) + continue; + + property.SetValue(detail, null); + } + } + + private Task AuditAsync(int departmentId, string recordId, string purpose, object detail, DateTime now, CancellationToken cancellationToken) + { + return _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + RecordId = recordId, + Action = (int)RmsAccessAuditAction.Change, + ActorUserId = null, + Purpose = purpose, + OriginClient = (int)RmsOriginClient.System, + Successful = true, + OccurredOn = now, + DetailJson = detail == null ? null : JsonConvert.SerializeObject(detail) + }, cancellationToken, true); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsService.cs b/Core/Resgrid.Services/Records/RecordsService.cs index 49ed6953..934bc7a2 100644 --- a/Core/Resgrid.Services/Records/RecordsService.cs +++ b/Core/Resgrid.Services/Records/RecordsService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data.Common; using System.Linq; @@ -35,6 +35,7 @@ public class RecordsService : IRecordsService private readonly IRmsRecordUnitResponsesRepository _units; private readonly IRmsRecordAttachmentsRepository _attachments; private readonly IRmsRevisionsRepository _revisions; + private readonly IRecordsEvidenceService _evidence; private readonly IRmsRecordGroupScopesRepository _scopes; private readonly IRmsRecordSharesRepository _shares; private readonly IRmsRecordSearchProjectionsRepository _projections; @@ -53,7 +54,7 @@ public class RecordsService : IRecordsService public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueService details, IRmsRecordParticipantsRepository participants, IRmsRecordUnitResponsesRepository units, IRmsRecordAttachmentsRepository attachments, - IRmsRevisionsRepository revisions, IRmsRecordGroupScopesRepository scopes, IRmsRecordSharesRepository shares, + IRmsRevisionsRepository revisions, IRecordsEvidenceService evidence, IRmsRecordGroupScopesRepository scopes, IRmsRecordSharesRepository shares, IRmsRecordSearchProjectionsRepository projections, IRmsAccessAuditsRepository audits, IDomainEventOutboxService outbox, IRecordsCutoverService cutover, IDepartmentSettingsService settings, IDepartmentGroupsService groups, IUserProfileService profiles, IUnitsService unitsService, ICallsService calls, IDepartmentDataProtectionService dataProtection, IUnitOfWork unitOfWork, @@ -65,6 +66,7 @@ public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueS _units = units; _attachments = attachments; _revisions = revisions; + _evidence = evidence; _scopes = scopes; _shares = shares; _projections = projections; @@ -584,10 +586,10 @@ public async Task> GetYearsAsync(int departmentId) return (await _projections.GetYearsAsync(departmentId))?.ToList() ?? new List(); } - public async Task> GetChangesSinceAsync(int departmentId, DateTime? since, int take) + public async Task> GetChangesSinceAsync(int departmentId, DateTime? since, int take, string sinceId = null) { var bounded = Math.Max(1, Math.Min(500, take)); - return (await _projections.GetModifiedSinceAsync(departmentId, since, bounded))?.OrderBy(p => p.ModifiedOn).ThenBy(p => p.RmsRecordSearchProjectionId, StringComparer.Ordinal).ToList() ?? new List(); + return (await _projections.GetModifiedSinceAsync(departmentId, since, bounded, sinceId))?.OrderBy(p => p.ModifiedOn).ThenBy(p => p.RmsRecordSearchProjectionId, StringComparer.Ordinal).ToList() ?? new List(); } public async Task> GetRevisionsAsync(int departmentId, string recordId) @@ -817,6 +819,10 @@ private async Task WriteRevisionAsync(RmsOperationalRecord record, }; await _revisions.InsertAsync(revision, cancellationToken, true); + // Evidence captured against the draft becomes evidence of this revision (RMS-3c). Binding rather than + // copying is deliberate: an artifact is immutable and belongs to exactly one attested revision. + await _evidence.BindToRevisionAsync(record.DepartmentId, record.RmsOperationalRecordId, revision.RmsRevisionId, cancellationToken); + // Revision-bound copies of the typed rows keep finalized data queryable without touching the draft rows. if (draft.Details != null) { @@ -1009,31 +1015,7 @@ private async Task EnqueueLifecycleEventAsync(RmsOperati { var payload = new Dictionary { - ["record"] = new - { - id = record.RmsOperationalRecordId, - record_number = record.RecordNumber, - draft_reference = record.DraftReference, - definition_key = record.DefinitionKey, - definition_version = record.DefinitionVersion, - type_key = record.RecordType.HasValue ? ((RmsOperationalRecordType)record.RecordType.Value).ToString() : null, - state = to.ToString(), - lifecycle_preset = ((RmsLifecyclePreset)record.LifecyclePreset).ToString(), - department_id = record.DepartmentId, - station_group_id = record.StationGroupId, - call_id = record.CallId, - external_id = record.ExternalId, - author_user_id = record.AuthorUserId, - owner_user_id = record.OwnerUserId, - started_on = record.StartedOn, - ended_on = record.EndedOn, - created_on = record.CreatedOn, - finalized_on = record.FinalizedOn, - revision_id = revision?.RmsRevisionId ?? record.CurrentRevisionId, - revision_number = revision?.RevisionNumber ?? record.RevisionCount, - checksum = revision?.Checksum, - summary = record.DisplaySummary - }, + ["record"] = RecordBlock(record, revision, to), ["record_change"] = new { previous_state = from.ToString(), @@ -1078,6 +1060,40 @@ private async Task EnqueueLifecycleEventAsync(RmsOperati return entry; } + /// + /// The record.* block every Records event carries (plan section 5.6): header facts only, no details, no + /// participants and nothing restricted. Shared so the lifecycle events and the RMS-3 overdue event + /// (worker 42) describe a Record identically. + /// + public static object RecordBlock(RmsOperationalRecord record, RmsRevision revision, RmsRecordState state) + { + return new + { + id = record.RmsOperationalRecordId, + record_number = record.RecordNumber, + draft_reference = record.DraftReference, + definition_key = record.DefinitionKey, + definition_version = record.DefinitionVersion, + type_key = record.RecordType.HasValue ? ((RmsOperationalRecordType)record.RecordType.Value).ToString() : null, + state = state.ToString(), + lifecycle_preset = ((RmsLifecyclePreset)record.LifecyclePreset).ToString(), + department_id = record.DepartmentId, + station_group_id = record.StationGroupId, + call_id = record.CallId, + external_id = record.ExternalId, + author_user_id = record.AuthorUserId, + owner_user_id = record.OwnerUserId, + started_on = record.StartedOn, + ended_on = record.EndedOn, + created_on = record.CreatedOn, + finalized_on = record.FinalizedOn, + revision_id = revision?.RmsRevisionId ?? record.CurrentRevisionId, + revision_number = revision?.RevisionNumber ?? record.RevisionCount, + checksum = revision?.Checksum, + summary = record.DisplaySummary + }; + } + /// /// Legacy LogAdded compatibility (plan section 5.6): the exact pre-existing log.* contract, projected once at /// finalize for eligible Logs-parity records through the same outbox, caused by the RecordFinalized event. diff --git a/Core/Resgrid.Services/Records/RecordsSubmissionService.cs b/Core/Resgrid.Services/Records/RecordsSubmissionService.cs index 832814ba..0e40644a 100644 --- a/Core/Resgrid.Services/Records/RecordsSubmissionService.cs +++ b/Core/Resgrid.Services/Records/RecordsSubmissionService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -29,6 +29,9 @@ public class RecordsSubmissionService : IRecordsSubmissionService private readonly IRmsSubmissionsRepository _submissions; private readonly IRmsIncidentReportsRepository _reports; + private readonly IRmsIncidentAnalysesRepository _analyses; + private readonly IRmsDepartmentCutoversRepository _cutovers; + private readonly IIncidentAnalysisService _analysisService; private readonly IRmsRecordSearchProjectionsRepository _projections; private readonly IRmsAccessAuditsRepository _audits; private readonly INerisProfileService _profiles; @@ -37,12 +40,15 @@ public class RecordsSubmissionService : IRecordsSubmissionService private readonly IOutboundQueueProvider _outboundQueue; private readonly IUnitOfWork _unitOfWork; - public RecordsSubmissionService(IRmsSubmissionsRepository submissions, IRmsIncidentReportsRepository reports, IRmsRecordSearchProjectionsRepository projections, + public RecordsSubmissionService(IRmsSubmissionsRepository submissions, IRmsIncidentReportsRepository reports, IRmsIncidentAnalysesRepository analyses, IRmsRecordSearchProjectionsRepository projections, IRmsAccessAuditsRepository audits, INerisProfileService profiles, INerisSubmissionService delivery, IDomainEventOutboxService outbox, - IOutboundQueueProvider outboundQueue, IUnitOfWork unitOfWork) + IOutboundQueueProvider outboundQueue, IUnitOfWork unitOfWork, IRmsDepartmentCutoversRepository cutovers, IIncidentAnalysisService analysisService) { _submissions = submissions; _reports = reports; + _analyses = analyses; + _cutovers = cutovers; + _analysisService = analysisService; _projections = projections; _audits = audits; _profiles = profiles; @@ -61,6 +67,21 @@ public async Task SweepAsync(CancellationToken can return result; } + // An analysis finalized before its incident was filed has no submission row yet. Queue those first so + // they join this sweep rather than waiting a whole cycle after the incident finally lands. + foreach (var cutover in (await _cutovers.GetActiveAsync())?.ToList() ?? new List()) + { + try + { + await _analysisService.QueueAwaitingIncidentAsync(cutover.DepartmentId, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Awaiting incident analyses could not be queued for department {cutover.DepartmentId}."); + result.Errors++; + } + } + var owner = $"{Environment.MachineName}:{Guid.NewGuid():N}"; var claimed = (await _submissions.ClaimDueBatchAsync(owner, TimeSpan.FromSeconds(Math.Max(30, NerisConfig.LeaseSeconds)), Math.Max(1, NerisConfig.BatchSize), DateTime.UtcNow, cancellationToken))?.ToList() ?? new List(); result.Claimed = claimed.Count; @@ -100,9 +121,15 @@ public async Task ProcessAsync(RmsSubmission submission, Cancella if (submission == null) throw new ArgumentNullException(nameof(submission)); var now = DateTime.UtcNow; + // The incident-analysis filing (RMS-3) rides the same queue and the same lease, but it is a different + // endpoint against a different aggregate, and it raises none of triggers 108-111: those describe the + // incident's own filing, and an analysis outcome must never be mistaken for one. + if (string.Equals(submission.Destination, RmsSubmissionDestinations.NerisIncidentAnalysis, StringComparison.Ordinal)) + return await ProcessAnalysisAsync(submission, now, cancellationToken); + var report = await _reports.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RecordId); if (report == null || report.DeletedOn.HasValue) - return await PersistAsync(submission, report, Fatal("The report no longer exists."), now, cancellationToken); + return await PersistAsync(submission, report, Fatal("The report no longer exists."), now, false, cancellationToken); // A superseding revision or a void may have arrived while this row waited; never deliver stale content. if (submission.State == (int)RmsSubmissionState.Superseded || RmsLifecycle.IsTerminal((RmsRecordState)report.State)) @@ -120,7 +147,8 @@ public async Task ProcessAsync(RmsSubmission submission, Cancella } NerisSubmissionOutcome outcome; - if (submission.State == (int)RmsSubmissionState.AwaitingDestination) + var wasDelivery = submission.State != (int)RmsSubmissionState.AwaitingDestination; + if (!wasDelivery) { var nerisId = submission.ExternalId ?? report.NerisIncidentId; outcome = string.IsNullOrWhiteSpace(nerisId) @@ -134,10 +162,174 @@ public async Task ProcessAsync(RmsSubmission submission, Cancella outcome = await _delivery.DeliverAsync(profile, submission, report.NerisIncidentId, cancellationToken); } - return await PersistAsync(submission, report, outcome, now, cancellationToken); + return await PersistAsync(submission, report, outcome, now, wasDelivery, cancellationToken); + } + + /// + /// One delivery attempt for an incident-analysis filing. The analysis can only be filed against an incident + /// the destination already holds, so a missing incident id is a wait — the row is deferred, never failed. + /// + private async Task ProcessAnalysisAsync(RmsSubmission submission, DateTime now, CancellationToken cancellationToken) + { + var analysis = await _analyses.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RecordId); + if (analysis == null || analysis.DeletedOn.HasValue) + return await PersistAnalysisAsync(submission, null, Fatal("The incident analysis no longer exists."), now, false, cancellationToken); + + if (submission.State == (int)RmsSubmissionState.Superseded || (RmsIncidentAnalysisState)analysis.State == RmsIncidentAnalysisState.Voided) + { + submission.State = (int)RmsSubmissionState.Superseded; + submission.CompletedOn = now; + return await ReleaseAsync(submission, now, cancellationToken); + } + + var profile = await _profiles.GetProfileAsync(submission.DepartmentId); + if (!await _profiles.IsSubmissionEnabledAsync(submission.DepartmentId)) + { + submission.NextAttemptOn = now.AddMinutes(Math.Max(1, NerisConfig.StatusPollMinutes)); + return await ReleaseAsync(submission, now, cancellationToken); + } + + var report = await _reports.GetByIdForDepartmentAsync(submission.DepartmentId, analysis.IncidentReportId); + + NerisSubmissionOutcome outcome; + var wasDelivery = submission.State != (int)RmsSubmissionState.AwaitingDestination; + if (!wasDelivery) + { + var analysisId = submission.ExternalId ?? analysis.NerisAnalysisId; + outcome = string.IsNullOrWhiteSpace(analysisId) + ? Fatal("The analysis submission is awaiting the destination but carries no analysis ID.") + : await _delivery.CheckAnalysisStatusAsync(profile, analysisId, cancellationToken); + } + else + { + submission.Attempts += 1; + submission.SentOn = now; + outcome = await _delivery.DeliverAnalysisAsync(profile, submission, report?.NerisIncidentId, analysis.NerisAnalysisId, cancellationToken); + } + + return await PersistAnalysisAsync(submission, analysis, outcome, now, wasDelivery, cancellationToken); + } + + private async Task PersistAnalysisAsync(RmsSubmission submission, RmsIncidentAnalysis analysis, NerisSubmissionOutcome outcome, DateTime now, bool wasDelivery, CancellationToken cancellationToken) + { + await InTransactionAsync(async () => + { + if (outcome.ResponseJson != null) + { + submission.ResponseJson = outcome.ResponseJson; + submission.ResponseChecksum = RecordSnapshotSerializer.Checksum(outcome.ResponseJson); + } + submission.ResponseStatusCode = outcome.StatusCode; + if (!string.IsNullOrWhiteSpace(outcome.ExternalId)) + submission.ExternalId = outcome.ExternalId; + if (!string.IsNullOrWhiteSpace(outcome.ExternalStatus)) + submission.ExternalStatus = outcome.ExternalStatus; + + RmsIncidentAnalysisState? analysisState = null; + string auditPurpose; + + switch (outcome.Kind) + { + case NerisOutcomeKind.Created: + case NerisOutcomeKind.Updated: + case NerisOutcomeKind.Pending: + submission.State = (int)RmsSubmissionState.AwaitingDestination; + submission.NextAttemptOn = now.AddMinutes(Math.Max(1, NerisConfig.StatusPollMinutes)); + submission.ErrorSummary = null; + analysisState = RmsIncidentAnalysisState.Submitted; + auditPurpose = "Analysis delivered"; + break; + + case NerisOutcomeKind.Accepted: + submission.State = (int)RmsSubmissionState.Accepted; + submission.CompletedOn = now; + submission.NextAttemptOn = null; + submission.ErrorSummary = null; + analysisState = RmsIncidentAnalysisState.Accepted; + auditPurpose = "Analysis accepted"; + break; + + case NerisOutcomeKind.Rejected: + submission.State = (int)RmsSubmissionState.Rejected; + submission.CompletedOn = now; + submission.NextAttemptOn = null; + submission.ErrorSummary = Summarize(outcome); + analysisState = RmsIncidentAnalysisState.Rejected; + auditPurpose = "Analysis rejected"; + break; + + case NerisOutcomeKind.Transient: + // Only a delivery spends the retry budget. A status poll leaves Attempts untouched, so once a + // row reaches AwaitingDestination on its last allowed attempt the first transient poll error + // would otherwise fail a submission the destination already holds and may still accept. + if (wasDelivery && submission.Attempts >= Math.Max(1, submission.MaxAttempts)) + { + submission.State = (int)RmsSubmissionState.Failed; + submission.CompletedOn = now; + submission.NextAttemptOn = null; + submission.ErrorSummary = "Delivery exhausted its retries: " + (outcome.Message ?? "destination unavailable"); + auditPurpose = "Analysis submission failed (retries exhausted)"; + } + else + { + if (submission.State != (int)RmsSubmissionState.AwaitingDestination) + submission.State = (int)RmsSubmissionState.Queued; + submission.NextAttemptOn = now.AddMinutes(Backoff(submission.Attempts)); + submission.ErrorSummary = outcome.Message; + auditPurpose = "Analysis submission deferred"; + } + break; + + default: + submission.State = (int)RmsSubmissionState.Failed; + submission.CompletedOn = now; + submission.NextAttemptOn = null; + submission.ErrorSummary = outcome.Message ?? "Delivery needs operator attention."; + auditPurpose = "Analysis submission failed"; + break; + } + + submission.LeaseOwner = null; + submission.LeaseExpiresOn = null; + submission.ModifiedOn = now; + submission.RowVersion += 1; + await _submissions.UpdateAsync(submission, cancellationToken, true); + + if (analysis != null) + { + if (!string.IsNullOrWhiteSpace(submission.ExternalId)) + analysis.NerisAnalysisId = submission.ExternalId; + analysis.LastSubmissionId = submission.RmsSubmissionId; + analysis.LastSubmissionState = submission.State; + if (analysisState.HasValue) + { + analysis.State = (int)analysisState.Value; + if (analysisState == RmsIncidentAnalysisState.Accepted) analysis.AcceptedOn = now; + if (analysisState == RmsIncidentAnalysisState.Rejected) { analysis.RejectedOn = now; analysis.RejectionSummary = submission.ErrorSummary; } + } + analysis.ModifiedOn = now; + analysis.RowVersion += 1; + await _analyses.UpdateAsync(analysis, cancellationToken, true); + + await _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = analysis.DepartmentId, + RecordId = analysis.RmsIncidentAnalysisId, + RevisionId = submission.RevisionId, + Action = (int)RmsAccessAuditAction.Submit, + Purpose = auditPurpose, + OriginClient = (int)RmsOriginClient.System, + Successful = outcome.Kind != NerisOutcomeKind.Fatal, + OccurredOn = now, + DetailJson = JsonConvert.SerializeObject(new { submission.RmsSubmissionId, submission.Attempts, outcome.Kind, outcome.StatusCode, submission.ExternalId, submission.ExternalStatus, submission.ResponseChecksum }) + }, cancellationToken, true); + } + }); + + return submission; } - private async Task PersistAsync(RmsSubmission submission, RmsIncidentReport report, NerisSubmissionOutcome outcome, DateTime now, CancellationToken cancellationToken) + private async Task PersistAsync(RmsSubmission submission, RmsIncidentReport report, NerisSubmissionOutcome outcome, DateTime now, bool wasDelivery, CancellationToken cancellationToken) { var outboxIds = new List(); NotificationItem notification = null; @@ -192,7 +384,10 @@ await InTransactionAsync(async () => break; case NerisOutcomeKind.Transient: - if (submission.Attempts >= Math.Max(1, submission.MaxAttempts)) + // Only a delivery spends the retry budget. A status poll leaves Attempts untouched, so once a + // row reaches AwaitingDestination on its last allowed attempt the first transient poll error + // would otherwise fail a submission the destination already holds and may still accept. + if (wasDelivery && submission.Attempts >= Math.Max(1, submission.MaxAttempts)) { submission.State = (int)RmsSubmissionState.Failed; submission.CompletedOn = now; diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 78883f88..58de2d6b 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -251,6 +251,23 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-3: worker 42 (due-state evaluation, trigger 112 + notification 32) and worker 43 (retention, + // legal hold, attachment purge and the Pending-attachment rescan). + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-3c evidence: the service owns checksum, classification, retention and audit; the six adapters + // only decide what a bounded snapshot of their own subsystem looks like (plan section 4.5, all six ship). + 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(); + // RMS-3d: public-records workflow (M0171) and the Records queue dashboards. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); // Default attachment scanner: no engine, rows stay Skipped. A real scanner provider replaces this registration. builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 21e888ce..7a6e38eb 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -513,13 +513,21 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve case WorkflowTriggerEventType.RecordAmended: case WorkflowTriggerEventType.RecordVoided: case WorkflowTriggerEventType.RecordCancelled: + // The submission and obligation triggers build their own blocks inside AddRecordsSamples; without + // these cases the template preview advertises submission.* and obligation.* variables that never + // render. + case WorkflowTriggerEventType.RecordSubmissionQueued: + case WorkflowTriggerEventType.RecordSubmissionAccepted: + case WorkflowTriggerEventType.RecordSubmissionRejected: + case WorkflowTriggerEventType.RecordSubmissionFailed: + case WorkflowTriggerEventType.RecordOverdue: AddRecordsSamples(obj, eventType); break; } } /// - /// Records (RMS) triggers 100-107: a bounded snapshot matching RecordEventVariables, RecordVariables and + /// Records (RMS) triggers 100-112: a bounded snapshot matching RecordEventVariables, RecordVariables and /// RecordChangeVariables in WorkflowTemplateVariableCatalog. The state pair follows the trigger. /// private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType eventType) @@ -644,6 +652,18 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType obj["submission"] = s; } + if (eventType == WorkflowTriggerEventType.RecordOverdue) + { + r["kind"] = "Operational"; + var o = new ScriptObject(); + o["type"] = "Review"; + o["due_on"] = DateTime.Now.AddHours(-30); + o["overdue_hours"] = 30; + o["responsible_user_id"] = "00000000-0000-0000-0000-000000000000"; + o["overdue_count"] = 1; + obj["obligation"] = o; + } + if (eventType == WorkflowTriggerEventType.RecordSubmittedForReview || eventType == WorkflowTriggerEventType.RecordReturnedForCorrection) { var returned = eventType == WorkflowTriggerEventType.RecordReturnedForCorrection; diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index d05ef114..d52ce4e5 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -411,6 +411,7 @@ public async Task BuildContextAsync( case WorkflowTriggerEventType.RecordSubmissionAccepted: case WorkflowTriggerEventType.RecordSubmissionRejected: case WorkflowTriggerEventType.RecordSubmissionFailed: + case WorkflowTriggerEventType.RecordOverdue: { // Records (RMS): the payload is the outbox snapshot carried by RecordsWorkflowEvent; it is never // rehydrated from current record state, so a retry sees exactly what the original run saw. @@ -1203,6 +1204,9 @@ private static string MapRecordsEventVariables(ScriptObject obj, RecordsWorkflow if (payload["submission"] is JObject submission) obj["submission"] = ToScriptObject(submission); + if (payload["obligation"] is JObject obligation) + obj["obligation"] = ToScriptObject(obligation); + return recordToken?["author_user_id"]?.Type == JTokenType.String ? (string)recordToken["author_user_id"] : null; } diff --git a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs index b7e3affa..43969366 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs @@ -44,6 +44,14 @@ public static class Data /// per-user authorization checks and support cross-department operation. /// public const string ServiceAccount = "ServiceAccount"; + + /// + /// Records (RMS): the purpose named by the system principal's configured Record grant + /// (Identifier Allocation Registry section 4.4). Present only on system-principal tokens that + /// carry a grant, so its presence is what marks a Record read as non-user; the value is written + /// to the Record access audit. A user principal never carries this claim. + /// + public const string RecordGrantPurpose = "RecordGrantPurpose"; } public static class Resources diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0167_AddRmsConditionalIncidentModules.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0167_AddRmsConditionalIncidentModules.cs new file mode 100644 index 00000000..9513160b --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0167_AddRmsConditionalIncidentModules.cs @@ -0,0 +1,207 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// NERIS conditional sections and the separate incident-analysis filing (RMS plan section 4.2, registry M0167, + /// package RMS-3). + /// + /// RmsIncidentModules — one row per conditional section instance (fire, hazsit, chemical, medical, the + /// three alarm sections, both suppression sections, the four emerging-hazard sections, and the analysis-side + /// origin/outside-fire/products/batteries). Reportable facts are columns; the section body is contract-shaped + /// JSON validated against the pinned schema named in the row. + /// RmsIncidentResources — non-unit resources used on the incident. + /// RmsIncidentAnalyses — the fire/hazmat analysis the contract posts to /incident_analysis, a second + /// submittable artifact for the same incident with its own state, revisions and idempotency key. + /// RmsIncidentProperties, RmsIncidentVehicles — the analysis's enumerated property and vehicle rows, + /// typed because value and loss are summed by department reporting. + /// + /// Every table carries DepartmentId and an immutable ProtectionId; child rows are a working draft + /// (RevisionId NULL) plus immutable revision copies. Protected-candidate holders carry the inert envelope + /// columns of plan section 5.9.1. Existence-guarded for safe retry. + /// + [Migration(167)] + public class M0167_AddRmsConditionalIncidentModules : Migration + { + public override void Up() + { + if (!Schema.Table("RmsIncidentModules").Exists()) + { + Create.Table("RmsIncidentModules") + .WithColumn("RmsIncidentModuleId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RecordKind").AsInt32().NotNullable() + .WithColumn("RevisionId").AsString(36).Nullable() + .WithColumn("ModuleKind").AsInt32().NotNullable() + .WithColumn("SchemaName").AsString(100).Nullable() + .WithColumn("ProfileVersion").AsString(20).Nullable() + .WithColumn("PrimaryCode").AsString(150).Nullable() + .WithColumn("SecondaryCode").AsString(150).Nullable() + .WithColumn("Quantity").AsDecimal(18, 4).Nullable() + .WithColumn("QuantityUnit").AsString(50).Nullable() + .WithColumn("OccurredOn").AsDateTime2().Nullable() + .WithColumn("DetailJson").AsString(int.MaxValue).Nullable() + .WithColumn("ProtectedEnvelope").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsIncidentModules_Department_Record_Revision").OnTable("RmsIncidentModules") + .OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending().OnColumn("RevisionId").Ascending(); + Create.Index("IX_RmsIncidentModules_Department_Kind").OnTable("RmsIncidentModules") + .OnColumn("DepartmentId").Ascending().OnColumn("ModuleKind").Ascending().OnColumn("PrimaryCode").Ascending(); + } + + if (!Schema.Table("RmsIncidentResources").Exists()) + { + Create.Table("RmsIncidentResources") + .WithColumn("RmsIncidentResourceId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RevisionId").AsString(36).Nullable() + .WithColumn("ResourceCode").AsString(150).NotNullable() + .WithColumn("Quantity").AsInt32().Nullable() + .WithColumn("Detail").AsString(400).Nullable() + .WithColumn("Ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsIncidentResources_Department_Record_Revision").OnTable("RmsIncidentResources") + .OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending().OnColumn("RevisionId").Ascending(); + } + + if (!Schema.Table("RmsIncidentAnalyses").Exists()) + { + Create.Table("RmsIncidentAnalyses") + .WithColumn("RmsIncidentAnalysisId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("IncidentReportId").AsString(36).NotNullable() + .WithColumn("ReportingEntityId").AsString(100).Nullable() + .WithColumn("ProfileVersion").AsString(20).Nullable() + .WithColumn("State").AsInt32().NotNullable() + .WithColumn("GeneralCause").AsString(100).Nullable() + .WithColumn("InvestigationTypesCsv").AsString(500).Nullable() + .WithColumn("EstimatedLossTotal").AsDecimal(18, 2).Nullable() + .WithColumn("EstimatedValueTotal").AsDecimal(18, 2).Nullable() + .WithColumn("CurrencyCode").AsString(3).Nullable() + .WithColumn("AuthorUserId").AsString(128).NotNullable() + .WithColumn("OwnerUserId").AsString(128).Nullable() + .WithColumn("FinalizedOn").AsDateTime2().Nullable() + .WithColumn("FinalizedByUserId").AsString(128).Nullable() + .WithColumn("CurrentRevisionId").AsString(36).Nullable() + .WithColumn("RevisionCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("NerisAnalysisId").AsString(100).Nullable() + .WithColumn("LastSubmissionId").AsString(36).Nullable() + .WithColumn("LastSubmissionState").AsInt32().Nullable() + .WithColumn("LastSubmittedOn").AsDateTime2().Nullable() + .WithColumn("AcceptedOn").AsDateTime2().Nullable() + .WithColumn("RejectedOn").AsDateTime2().Nullable() + .WithColumn("RejectionSummary").AsString(int.MaxValue).Nullable() + .WithColumn("VoidedOn").AsDateTime2().Nullable() + .WithColumn("VoidedByUserId").AsString(128).Nullable() + .WithColumn("VoidReasonCode").AsString(50).Nullable() + .WithColumn("VoidReasonText").AsString(int.MaxValue).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedByUserId").AsString(128).Nullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsIncidentAnalyses_Department_State").OnTable("RmsIncidentAnalyses") + .OnColumn("DepartmentId").Ascending().OnColumn("State").Ascending(); + // One analysis per incident report; a second one would be a competing filing for the same incident. + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsIncidentAnalyses_Report ON RmsIncidentAnalyses (DepartmentId, IncidentReportId) WHERE DeletedOn IS NULL;"); + } + + if (!Schema.Table("RmsIncidentProperties").Exists()) + { + Create.Table("RmsIncidentProperties") + .WithColumn("RmsIncidentPropertyId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RevisionId").AsString(36).Nullable() + .WithColumn("LocationUse").AsString(150).Nullable() + .WithColumn("ConstructionType").AsString(50).Nullable() + .WithColumn("Foundation").AsString(50).Nullable() + .WithColumn("ExteriorFinish").AsString(50).Nullable() + .WithColumn("RoofMaterial").AsString(50).Nullable() + .WithColumn("StoriesAboveGrade").AsInt32().Nullable() + .WithColumn("StoriesBelowGrade").AsInt32().Nullable() + .WithColumn("YearBuilt").AsInt32().Nullable() + .WithColumn("Vacancy").AsString(50).Nullable() + .WithColumn("DamageType").AsString(50).Nullable() + .WithColumn("FireSpread").AsString(50).Nullable() + .WithColumn("EstimatedValue").AsDecimal(18, 2).Nullable() + .WithColumn("EstimatedLoss").AsDecimal(18, 2).Nullable() + .WithColumn("ContentsValue").AsDecimal(18, 2).Nullable() + .WithColumn("ContentsLoss").AsDecimal(18, 2).Nullable() + .WithColumn("CurrencyCode").AsString(3).Nullable() + .WithColumn("DetailJson").AsString(int.MaxValue).Nullable() + .WithColumn("ProtectedEnvelope").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsIncidentProperties_Department_Record_Revision").OnTable("RmsIncidentProperties") + .OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending().OnColumn("RevisionId").Ascending(); + } + + if (!Schema.Table("RmsIncidentVehicles").Exists()) + { + Create.Table("RmsIncidentVehicles") + .WithColumn("RmsIncidentVehicleId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RevisionId").AsString(36).Nullable() + .WithColumn("VehicleKind").AsString(20).NotNullable() + .WithColumn("Make").AsString(100).Nullable() + .WithColumn("Model").AsString(100).Nullable() + .WithColumn("ModelYear").AsInt32().Nullable() + .WithColumn("BodyStyle").AsString(50).Nullable() + .WithColumn("Powertrain").AsString(50).Nullable() + .WithColumn("DamageType").AsString(50).Nullable() + .WithColumn("Vin").AsString(50).Nullable() + .WithColumn("LicensePlate").AsString(50).Nullable() + .WithColumn("LicenseState").AsString(10).Nullable() + .WithColumn("WasOccupied").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("EstimatedValue").AsDecimal(18, 2).Nullable() + .WithColumn("EstimatedLoss").AsDecimal(18, 2).Nullable() + .WithColumn("CurrencyCode").AsString(3).Nullable() + .WithColumn("DetailJson").AsString(int.MaxValue).Nullable() + .WithColumn("ProtectedEnvelope").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsIncidentVehicles_Department_Record_Revision").OnTable("RmsIncidentVehicles") + .OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending().OnColumn("RevisionId").Ascending(); + } + } + + public override void Down() + { + foreach (var table in new[] { "RmsIncidentVehicles", "RmsIncidentProperties", "RmsIncidentAnalyses", "RmsIncidentResources", "RmsIncidentModules" }) + { + if (Schema.Table(table).Exists()) + Delete.Table(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0168_AddRmsCasualtyRescueExposure.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0168_AddRmsCasualtyRescueExposure.cs new file mode 100644 index 00000000..a2efa720 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0168_AddRmsCasualtyRescueExposure.cs @@ -0,0 +1,118 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Civilian and responder casualties, rescues, and property exposures (RMS plan section 4.2, registry M0168, + /// package RMS-3): RmsCasualtyRescues and RmsExposures. + /// + /// Both are restricted classes. Demographics, the injury detail and the vehicle/person identity require + /// RecordRestricted_View wherever they are rendered, both carry the inert Protected Data envelope of plan + /// section 5.9.1, and their retention default is permanent (plan section 4.9) — so nothing here is ever + /// purged by the retention sweep without an explicit department override. + /// + /// Existence-guarded for safe retry. + /// + [Migration(168)] + public class M0168_AddRmsCasualtyRescueExposure : Migration + { + public override void Up() + { + if (!Schema.Table("RmsCasualtyRescues").Exists()) + { + Create.Table("RmsCasualtyRescues") + .WithColumn("RmsCasualtyRescueId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RevisionId").AsString(36).Nullable() + .WithColumn("Kind").AsInt32().NotNullable() + .WithColumn("PersonType").AsString(10).NotNullable() + .WithColumn("PersonnelUserId").AsString(128).Nullable() + .WithColumn("Rank").AsString(255).Nullable() + .WithColumn("YearsOfService").AsDecimal(6, 2).Nullable() + .WithColumn("JobClassification").AsString(50).Nullable() + .WithColumn("BirthMonthYear").AsString(7).Nullable() + .WithColumn("Gender").AsString(50).Nullable() + .WithColumn("Race").AsString(50).Nullable() + .WithColumn("WasInjured").AsBoolean().Nullable() + .WithColumn("CasualtyCause").AsString(100).Nullable() + .WithColumn("CasualtyAction").AsString(100).Nullable() + .WithColumn("CasualtyTimeline").AsString(100).Nullable() + .WithColumn("DutyType").AsString(100).Nullable() + .WithColumn("PpeCsv").AsString(500).Nullable() + .WithColumn("InjuryDetailJson").AsString(int.MaxValue).Nullable() + .WithColumn("WasFatal").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("RescueType").AsString(100).Nullable() + .WithColumn("RescueActionsCsv").AsString(500).Nullable() + .WithColumn("RescueImpedimentsCsv").AsString(500).Nullable() + .WithColumn("RescueMode").AsString(100).Nullable() + .WithColumn("RescuePath").AsString(100).Nullable() + .WithColumn("RescueElevation").AsString(100).Nullable() + .WithColumn("PresenceKnown").AsString(100).Nullable() + .WithColumn("DetailJson").AsString(int.MaxValue).Nullable() + .WithColumn("OccurredOn").AsDateTime2().Nullable() + .WithColumn("ProtectedEnvelope").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsCasualtyRescues_Department_Record_Revision").OnTable("RmsCasualtyRescues") + .OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending().OnColumn("RevisionId").Ascending(); + // Responder injury reporting: a member's own casualty history across incidents. + Create.Index("IX_RmsCasualtyRescues_Department_Personnel").OnTable("RmsCasualtyRescues") + .OnColumn("DepartmentId").Ascending().OnColumn("PersonnelUserId").Ascending(); + } + + if (!Schema.Table("RmsExposures").Exists()) + { + Create.Table("RmsExposures") + .WithColumn("RmsExposureId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RevisionId").AsString(36).Nullable() + .WithColumn("LocationKind").AsString(20).Nullable() + .WithColumn("ItemType").AsString(50).Nullable() + .WithColumn("DamageType").AsString(50).Nullable() + .WithColumn("LocationUse").AsString(150).Nullable() + .WithColumn("PeoplePresent").AsBoolean().Nullable() + .WithColumn("DisplacementCount").AsInt32().Nullable() + .WithColumn("DisplacementCausesCsv").AsString(500).Nullable() + .WithColumn("AddressText").AsString(500).Nullable() + .WithColumn("Street").AsString(200).Nullable() + .WithColumn("Municipality").AsString(150).Nullable() + .WithColumn("State").AsString(10).Nullable() + .WithColumn("PostalCode").AsString(20).Nullable() + .WithColumn("Latitude").AsDecimal(12, 8).Nullable() + .WithColumn("Longitude").AsDecimal(12, 8).Nullable() + .WithColumn("EstimatedValue").AsDecimal(18, 2).Nullable() + .WithColumn("EstimatedLoss").AsDecimal(18, 2).Nullable() + .WithColumn("CurrencyCode").AsString(3).Nullable() + .WithColumn("DetailJson").AsString(int.MaxValue).Nullable() + .WithColumn("ProtectedEnvelope").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsExposures_Department_Record_Revision").OnTable("RmsExposures") + .OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending().OnColumn("RevisionId").Ascending(); + } + } + + public override void Down() + { + foreach (var table in new[] { "RmsExposures", "RmsCasualtyRescues" }) + { + if (Schema.Table(table).Exists()) + Delete.Table(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0169_AddRmsEvidenceArtifacts.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0169_AddRmsEvidenceArtifacts.cs new file mode 100644 index 00000000..3fc0d7b3 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0169_AddRmsEvidenceArtifacts.cs @@ -0,0 +1,78 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Immutable evidence artifacts (RMS plan sections 4.5 and 5.2, registry M0169, package RMS-3): + /// RmsEvidenceArtifacts. + /// + /// One table serves all six sources — readiness packets, run card activations, tracking fixes, promoted chat, + /// inventory usage and certification snapshots — because the properties that matter are the same for each: + /// provenance, coverage period, a checksummed manifest, a classification decided at capture, and a retention + /// rule. The per-source rows live inside ManifestJson rather than a child table, so the checksum covers + /// everything that was attested to and the two cannot drift apart. + /// + /// + /// There is no update path. A correction inserts a new artifact and stamps SupersededByArtifactId on the old + /// one, which stays readable — that is what makes "what did the crew have on the night" answerable later. + /// + /// Existence-guarded for safe retry. + /// + [Migration(169)] + public class M0169_AddRmsEvidenceArtifacts : Migration + { + public override void Up() + { + if (!Schema.Table("RmsEvidenceArtifacts").Exists()) + { + Create.Table("RmsEvidenceArtifacts") + .WithColumn("RmsEvidenceArtifactId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RecordKind").AsInt32().NotNullable() + .WithColumn("RevisionId").AsString(36).Nullable() + .WithColumn("Kind").AsInt32().NotNullable() + .WithColumn("Title").AsString(400).Nullable() + .WithColumn("CaptureReason").AsString(500).Nullable() + .WithColumn("SourceSubsystem").AsString(100).Nullable() + .WithColumn("SourceEntityType").AsString(100).Nullable() + .WithColumn("SourceEntityId").AsString(200).Nullable() + .WithColumn("IdentifierScheme").AsString(100).Nullable() + .WithColumn("SourceVersion").AsString(50).Nullable() + .WithColumn("CoverageStart").AsDateTime2().Nullable() + .WithColumn("CoverageEnd").AsDateTime2().Nullable() + .WithColumn("ManifestJson").AsString(int.MaxValue).Nullable() + .WithColumn("Checksum").AsString(80).Nullable() + .WithColumn("ByteSize").AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn("SourceItemCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("StorageReference").AsString(500).Nullable() + .WithColumn("Classification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RetentionYears").AsInt32().Nullable() + .WithColumn("CapturedByUserId").AsString(128).Nullable() + .WithColumn("CapturedOn").AsDateTime2().NotNullable() + .WithColumn("OriginClient").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SupersededByArtifactId").AsString(36).Nullable() + .WithColumn("SupersededOn").AsDateTime2().Nullable() + .WithColumn("ProtectedEnvelope").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsEvidenceArtifacts_Department_Record_Revision").OnTable("RmsEvidenceArtifacts") + .OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending().OnColumn("RevisionId").Ascending(); + Create.Index("IX_RmsEvidenceArtifacts_Department_Kind").OnTable("RmsEvidenceArtifacts") + .OnColumn("DepartmentId").Ascending().OnColumn("Kind").Ascending().OnColumn("CapturedOn").Descending(); + } + } + + public override void Down() + { + if (Schema.Table("RmsEvidenceArtifacts").Exists()) + Delete.Table("RmsEvidenceArtifacts"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0170_AddRmsDueStateAndRetention.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0170_AddRmsDueStateAndRetention.cs new file mode 100644 index 00000000..286b6753 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0170_AddRmsDueStateAndRetention.cs @@ -0,0 +1,89 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Due-state tracking and legal holds (RMS plan section 4.7 and RMS-3, registry M0170). + /// + /// RmsRecordDueStates — one row per (Record, obligation). The plan requires that the "at most once per + /// record/due-state transition" guarantee be carried by a persisted row rather than inferred from the last + /// worker run, so worker 42 compares against LastEmittedState and never against a timestamp. + /// RmsRecordLegalHolds — holds by Record, by definition, or by date range. A hold only ever prevents a + /// purge; it never deletes or edits, and a refused purge is audited. + /// + /// Existence-guarded for safe retry. + /// + [Migration(170)] + public class M0170_AddRmsDueStateAndRetention : Migration + { + public override void Up() + { + if (!Schema.Table("RmsRecordDueStates").Exists()) + { + Create.Table("RmsRecordDueStates") + .WithColumn("RmsRecordDueStateId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RecordKind").AsInt32().NotNullable() + .WithColumn("Obligation").AsInt32().NotNullable() + .WithColumn("DueOn").AsDateTime2().Nullable() + .WithColumn("LastEmittedState").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("LastEmittedOn").AsDateTime2().Nullable() + .WithColumn("ResponsibleUserId").AsString(128).Nullable() + .WithColumn("OverdueCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + // One row per obligation per Record: the uniqueness is what makes the emission guarantee hold. + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsRecordDueStates_Record_Obligation ON RmsRecordDueStates (DepartmentId, RecordId, Obligation);"); + Create.Index("IX_RmsRecordDueStates_Department_Due").OnTable("RmsRecordDueStates") + .OnColumn("DepartmentId").Ascending().OnColumn("DueOn").Ascending(); + } + + if (!Schema.Table("RmsRecordLegalHolds").Exists()) + { + Create.Table("RmsRecordLegalHolds") + .WithColumn("RmsRecordLegalHoldId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("RecordId").AsString(36).Nullable() + .WithColumn("DefinitionKey").AsString(100).Nullable() + .WithColumn("PeriodStart").AsDateTime2().Nullable() + .WithColumn("PeriodEnd").AsDateTime2().Nullable() + .WithColumn("Reason").AsString(50).Nullable() + .WithColumn("ReferenceNumber").AsString(100).Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("PlacedByUserId").AsString(128).Nullable() + .WithColumn("PlacedOn").AsDateTime2().NotNullable() + .WithColumn("ReleasedByUserId").AsString(128).Nullable() + .WithColumn("ReleasedOn").AsDateTime2().Nullable() + .WithColumn("ReleaseNotes").AsString(int.MaxValue).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsRecordLegalHolds_Department_Released").OnTable("RmsRecordLegalHolds") + .OnColumn("DepartmentId").Ascending().OnColumn("ReleasedOn").Ascending(); + Create.Index("IX_RmsRecordLegalHolds_Department_Record").OnTable("RmsRecordLegalHolds") + .OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending(); + } + + // A purged Record keeps its row, its number and its history and loses only its content (plan 4.9), so + // the tombstone needs a column that says so; "empty" and "purged" must not look the same. + if (Schema.Table("RmsOperationalRecords").Exists() && !Schema.Table("RmsOperationalRecords").Column("PurgedOn").Exists()) + Alter.Table("RmsOperationalRecords").AddColumn("PurgedOn").AsDateTime2().Nullable(); + + if (Schema.Table("RmsIncidentReports").Exists() && !Schema.Table("RmsIncidentReports").Column("PurgedOn").Exists()) + Alter.Table("RmsIncidentReports").AddColumn("PurgedOn").AsDateTime2().Nullable(); + } + + public override void Down() + { + foreach (var table in new[] { "RmsRecordLegalHolds", "RmsRecordDueStates" }) + { + if (Schema.Table(table).Exists()) + Delete.Table(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0171_AddRmsDisclosures.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0171_AddRmsDisclosures.cs new file mode 100644 index 00000000..918b00df --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0171_AddRmsDisclosures.cs @@ -0,0 +1,98 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Public-records and disclosure workflow (RMS plan section 4.7, registry M0171, package RMS-3): + /// RmsDisclosureRequests and RmsDisclosureProductions. + /// + /// Section 5.8 requires public-records export and redaction as a control; for a public agency it is a + /// statutory obligation with a clock, which is why the request is a record with a due date rather than an + /// ad-hoc export. A production is a new immutable artifact — never a mutation of the source revisions — and + /// it freezes the produced set so a later amendment cannot silently change what was released. + /// + /// Existence-guarded for safe retry. + /// + [Migration(171)] + public class M0171_AddRmsDisclosures : Migration + { + public override void Up() + { + if (!Schema.Table("RmsDisclosureRequests").Exists()) + { + Create.Table("RmsDisclosureRequests") + .WithColumn("RmsDisclosureRequestId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("RequestNumber").AsString(50).Nullable() + .WithColumn("RequesterName").AsString(255).Nullable() + .WithColumn("RequesterOrganization").AsString(255).Nullable() + .WithColumn("RequesterContact").AsString(500).Nullable() + .WithColumn("ReceivedOn").AsDateTime2().NotNullable() + .WithColumn("StatutoryDueOn").AsDateTime2().Nullable() + .WithColumn("JurisdictionProfile").AsString(20).Nullable() + .WithColumn("ScopeNarrative").AsString(int.MaxValue).Nullable() + .WithColumn("ScopeQueryJson").AsString(int.MaxValue).Nullable() + .WithColumn("State").AsInt32().NotNullable() + .WithColumn("AssignedToUserId").AsString(128).Nullable() + .WithColumn("RedactionProfile").AsString(50).Nullable() + .WithColumn("ClosedOn").AsDateTime2().Nullable() + .WithColumn("ClosedByUserId").AsString(128).Nullable() + .WithColumn("DispositionReason").AsString(int.MaxValue).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedByUserId").AsString(128).Nullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsDisclosureRequests_Department_State").OnTable("RmsDisclosureRequests") + .OnColumn("DepartmentId").Ascending().OnColumn("State").Ascending(); + // The statutory clock is the thing an officer opens this screen to check. + Create.Index("IX_RmsDisclosureRequests_Department_Due").OnTable("RmsDisclosureRequests") + .OnColumn("DepartmentId").Ascending().OnColumn("StatutoryDueOn").Ascending(); + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsDisclosureRequests_Number ON RmsDisclosureRequests (DepartmentId, RequestNumber) WHERE RequestNumber IS NOT NULL AND DeletedOn IS NULL;"); + } + + if (!Schema.Table("RmsDisclosureProductions").Exists()) + { + Create.Table("RmsDisclosureProductions") + .WithColumn("RmsDisclosureProductionId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).NotNullable() + .WithColumn("DisclosureRequestId").AsString(36).NotNullable() + .WithColumn("ProductionNumber").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("RedactionProfile").AsString(50).Nullable() + .WithColumn("ProducedSetJson").AsString(int.MaxValue).Nullable() + .WithColumn("ArtifactJson").AsString(int.MaxValue).Nullable() + .WithColumn("Checksum").AsString(80).Nullable() + .WithColumn("ByteSize").AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn("RecordCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("WithheldFieldsJson").AsString(int.MaxValue).Nullable() + .WithColumn("WithheldFieldCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("PreparedByUserId").AsString(128).Nullable() + .WithColumn("PreparedOn").AsDateTime2().NotNullable() + .WithColumn("ReleasedByUserId").AsString(128).Nullable() + .WithColumn("ReleasedOn").AsDateTime2().Nullable() + .WithColumn("ProtectedEnvelope").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L); + + Create.Index("IX_RmsDisclosureProductions_Department_Request").OnTable("RmsDisclosureProductions") + .OnColumn("DepartmentId").Ascending().OnColumn("DisclosureRequestId").Ascending(); + } + } + + public override void Down() + { + foreach (var table in new[] { "RmsDisclosureProductions", "RmsDisclosureRequests" }) + { + if (Schema.Table(table).Exists()) + Delete.Table(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0167_AddRmsConditionalIncidentModulesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0167_AddRmsConditionalIncidentModulesPg.cs new file mode 100644 index 00000000..09773a3e --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0167_AddRmsConditionalIncidentModulesPg.cs @@ -0,0 +1,187 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0167 (registry M0167, RMS-3): rmsincidentmodules, rmsincidentresources, + /// rmsincidentanalyses, rmsincidentproperties, rmsincidentvehicles. Lowercase unquoted identifiers, citext for + /// text, partial unique index for the one-analysis-per-report rule. + /// + [Migration(167)] + public class M0167_AddRmsConditionalIncidentModulesPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmsincidentmodules").Exists()) + { + Create.Table("rmsincidentmodules") + .WithColumn("rmsincidentmoduleid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("recordkind").AsInt32().NotNullable() + .WithColumn("revisionid").AsString(36).Nullable() + .WithColumn("modulekind").AsInt32().NotNullable() + .WithColumn("schemaname").AsCustom("citext").Nullable() + .WithColumn("profileversion").AsCustom("citext").Nullable() + .WithColumn("primarycode").AsCustom("citext").Nullable() + .WithColumn("secondarycode").AsCustom("citext").Nullable() + .WithColumn("quantity").AsDecimal(18, 4).Nullable() + .WithColumn("quantityunit").AsCustom("citext").Nullable() + .WithColumn("occurredon").AsDateTime2().Nullable() + .WithColumn("detailjson").AsCustom("citext").Nullable() + .WithColumn("protectedenvelope").AsCustom("citext").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsincidentmodules_department_record_revision ON rmsincidentmodules (departmentid, recordid, revisionid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsincidentmodules_department_kind ON rmsincidentmodules (departmentid, modulekind, primarycode);"); + } + + if (!Schema.Table("rmsincidentresources").Exists()) + { + Create.Table("rmsincidentresources") + .WithColumn("rmsincidentresourceid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("revisionid").AsString(36).Nullable() + .WithColumn("resourcecode").AsCustom("citext").NotNullable() + .WithColumn("quantity").AsInt32().Nullable() + .WithColumn("detail").AsCustom("citext").Nullable() + .WithColumn("ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsincidentresources_department_record_revision ON rmsincidentresources (departmentid, recordid, revisionid);"); + } + + if (!Schema.Table("rmsincidentanalyses").Exists()) + { + Create.Table("rmsincidentanalyses") + .WithColumn("rmsincidentanalysisid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).NotNullable() + .WithColumn("incidentreportid").AsString(36).NotNullable() + .WithColumn("reportingentityid").AsCustom("citext").Nullable() + .WithColumn("profileversion").AsCustom("citext").Nullable() + .WithColumn("state").AsInt32().NotNullable() + .WithColumn("generalcause").AsCustom("citext").Nullable() + .WithColumn("investigationtypescsv").AsCustom("citext").Nullable() + .WithColumn("estimatedlosstotal").AsDecimal(18, 2).Nullable() + .WithColumn("estimatedvaluetotal").AsDecimal(18, 2).Nullable() + .WithColumn("currencycode").AsCustom("citext").Nullable() + .WithColumn("authoruserid").AsString(128).NotNullable() + .WithColumn("owneruserid").AsString(128).Nullable() + .WithColumn("finalizedon").AsDateTime2().Nullable() + .WithColumn("finalizedbyuserid").AsString(128).Nullable() + .WithColumn("currentrevisionid").AsString(36).Nullable() + .WithColumn("revisioncount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("nerisanalysisid").AsCustom("citext").Nullable() + .WithColumn("lastsubmissionid").AsString(36).Nullable() + .WithColumn("lastsubmissionstate").AsInt32().Nullable() + .WithColumn("lastsubmittedon").AsDateTime2().Nullable() + .WithColumn("acceptedon").AsDateTime2().Nullable() + .WithColumn("rejectedon").AsDateTime2().Nullable() + .WithColumn("rejectionsummary").AsCustom("citext").Nullable() + .WithColumn("voidedon").AsDateTime2().Nullable() + .WithColumn("voidedbyuserid").AsString(128).Nullable() + .WithColumn("voidreasoncode").AsCustom("citext").Nullable() + .WithColumn("voidreasontext").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("modifiedbyuserid").AsString(128).Nullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsincidentanalyses_department_state ON rmsincidentanalyses (departmentid, state);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmsincidentanalyses_report ON rmsincidentanalyses (departmentid, incidentreportid) WHERE deletedon IS NULL;"); + } + + if (!Schema.Table("rmsincidentproperties").Exists()) + { + Create.Table("rmsincidentproperties") + .WithColumn("rmsincidentpropertyid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("revisionid").AsString(36).Nullable() + .WithColumn("locationuse").AsCustom("citext").Nullable() + .WithColumn("constructiontype").AsCustom("citext").Nullable() + .WithColumn("foundation").AsCustom("citext").Nullable() + .WithColumn("exteriorfinish").AsCustom("citext").Nullable() + .WithColumn("roofmaterial").AsCustom("citext").Nullable() + .WithColumn("storiesabovegrade").AsInt32().Nullable() + .WithColumn("storiesbelowgrade").AsInt32().Nullable() + .WithColumn("yearbuilt").AsInt32().Nullable() + .WithColumn("vacancy").AsCustom("citext").Nullable() + .WithColumn("damagetype").AsCustom("citext").Nullable() + .WithColumn("firespread").AsCustom("citext").Nullable() + .WithColumn("estimatedvalue").AsDecimal(18, 2).Nullable() + .WithColumn("estimatedloss").AsDecimal(18, 2).Nullable() + .WithColumn("contentsvalue").AsDecimal(18, 2).Nullable() + .WithColumn("contentsloss").AsDecimal(18, 2).Nullable() + .WithColumn("currencycode").AsCustom("citext").Nullable() + .WithColumn("detailjson").AsCustom("citext").Nullable() + .WithColumn("protectedenvelope").AsCustom("citext").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsincidentproperties_department_record_revision ON rmsincidentproperties (departmentid, recordid, revisionid);"); + } + + if (!Schema.Table("rmsincidentvehicles").Exists()) + { + Create.Table("rmsincidentvehicles") + .WithColumn("rmsincidentvehicleid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("revisionid").AsString(36).Nullable() + .WithColumn("vehiclekind").AsCustom("citext").NotNullable() + .WithColumn("make").AsCustom("citext").Nullable() + .WithColumn("model").AsCustom("citext").Nullable() + .WithColumn("modelyear").AsInt32().Nullable() + .WithColumn("bodystyle").AsCustom("citext").Nullable() + .WithColumn("powertrain").AsCustom("citext").Nullable() + .WithColumn("damagetype").AsCustom("citext").Nullable() + .WithColumn("vin").AsCustom("citext").Nullable() + .WithColumn("licenseplate").AsCustom("citext").Nullable() + .WithColumn("licensestate").AsCustom("citext").Nullable() + .WithColumn("wasoccupied").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("estimatedvalue").AsDecimal(18, 2).Nullable() + .WithColumn("estimatedloss").AsDecimal(18, 2).Nullable() + .WithColumn("currencycode").AsCustom("citext").Nullable() + .WithColumn("detailjson").AsCustom("citext").Nullable() + .WithColumn("protectedenvelope").AsCustom("citext").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsincidentvehicles_department_record_revision ON rmsincidentvehicles (departmentid, recordid, revisionid);"); + } + } + + public override void Down() + { + foreach (var table in new[] { "rmsincidentvehicles", "rmsincidentproperties", "rmsincidentanalyses", "rmsincidentresources", "rmsincidentmodules" }) + { + if (Schema.Table(table).Exists()) + Delete.Table(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0168_AddRmsCasualtyRescueExposurePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0168_AddRmsCasualtyRescueExposurePg.cs new file mode 100644 index 00000000..ec12d548 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0168_AddRmsCasualtyRescueExposurePg.cs @@ -0,0 +1,108 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0168 (registry M0168, RMS-3): rmscasualtyrescues and rmsexposures. Lowercase unquoted + /// identifiers, citext for text. Both are restricted classes with the inert Protected Data envelope and a + /// permanent retention default. + /// + [Migration(168)] + public class M0168_AddRmsCasualtyRescueExposurePg : Migration + { + public override void Up() + { + if (!Schema.Table("rmscasualtyrescues").Exists()) + { + Create.Table("rmscasualtyrescues") + .WithColumn("rmscasualtyrescueid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("revisionid").AsString(36).Nullable() + .WithColumn("kind").AsInt32().NotNullable() + .WithColumn("persontype").AsCustom("citext").NotNullable() + .WithColumn("personneluserid").AsString(128).Nullable() + .WithColumn("rank").AsCustom("citext").Nullable() + .WithColumn("yearsofservice").AsDecimal(6, 2).Nullable() + .WithColumn("jobclassification").AsCustom("citext").Nullable() + .WithColumn("birthmonthyear").AsCustom("citext").Nullable() + .WithColumn("gender").AsCustom("citext").Nullable() + .WithColumn("race").AsCustom("citext").Nullable() + .WithColumn("wasinjured").AsBoolean().Nullable() + .WithColumn("casualtycause").AsCustom("citext").Nullable() + .WithColumn("casualtyaction").AsCustom("citext").Nullable() + .WithColumn("casualtytimeline").AsCustom("citext").Nullable() + .WithColumn("dutytype").AsCustom("citext").Nullable() + .WithColumn("ppecsv").AsCustom("citext").Nullable() + .WithColumn("injurydetailjson").AsCustom("citext").Nullable() + .WithColumn("wasfatal").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("rescuetype").AsCustom("citext").Nullable() + .WithColumn("rescueactionscsv").AsCustom("citext").Nullable() + .WithColumn("rescueimpedimentscsv").AsCustom("citext").Nullable() + .WithColumn("rescuemode").AsCustom("citext").Nullable() + .WithColumn("rescuepath").AsCustom("citext").Nullable() + .WithColumn("rescueelevation").AsCustom("citext").Nullable() + .WithColumn("presenceknown").AsCustom("citext").Nullable() + .WithColumn("detailjson").AsCustom("citext").Nullable() + .WithColumn("occurredon").AsDateTime2().Nullable() + .WithColumn("protectedenvelope").AsCustom("citext").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmscasualtyrescues_department_record_revision ON rmscasualtyrescues (departmentid, recordid, revisionid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmscasualtyrescues_department_personnel ON rmscasualtyrescues (departmentid, personneluserid);"); + } + + if (!Schema.Table("rmsexposures").Exists()) + { + Create.Table("rmsexposures") + .WithColumn("rmsexposureid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("revisionid").AsString(36).Nullable() + .WithColumn("locationkind").AsCustom("citext").Nullable() + .WithColumn("itemtype").AsCustom("citext").Nullable() + .WithColumn("damagetype").AsCustom("citext").Nullable() + .WithColumn("locationuse").AsCustom("citext").Nullable() + .WithColumn("peoplepresent").AsBoolean().Nullable() + .WithColumn("displacementcount").AsInt32().Nullable() + .WithColumn("displacementcausescsv").AsCustom("citext").Nullable() + .WithColumn("addresstext").AsCustom("citext").Nullable() + .WithColumn("street").AsCustom("citext").Nullable() + .WithColumn("municipality").AsCustom("citext").Nullable() + .WithColumn("state").AsCustom("citext").Nullable() + .WithColumn("postalcode").AsCustom("citext").Nullable() + .WithColumn("latitude").AsDecimal(12, 8).Nullable() + .WithColumn("longitude").AsDecimal(12, 8).Nullable() + .WithColumn("estimatedvalue").AsDecimal(18, 2).Nullable() + .WithColumn("estimatedloss").AsDecimal(18, 2).Nullable() + .WithColumn("currencycode").AsCustom("citext").Nullable() + .WithColumn("detailjson").AsCustom("citext").Nullable() + .WithColumn("protectedenvelope").AsCustom("citext").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ordinal").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsexposures_department_record_revision ON rmsexposures (departmentid, recordid, revisionid);"); + } + } + + public override void Down() + { + foreach (var table in new[] { "rmsexposures", "rmscasualtyrescues" }) + { + if (Schema.Table(table).Exists()) + Delete.Table(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0169_AddRmsEvidenceArtifactsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0169_AddRmsEvidenceArtifactsPg.cs new file mode 100644 index 00000000..c2fec2b1 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0169_AddRmsEvidenceArtifactsPg.cs @@ -0,0 +1,65 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0169 (registry M0169, RMS-3): rmsevidenceartifacts. Lowercase unquoted identifiers, + /// citext for text. One table serves all six evidence sources; the per-source manifest lives in manifestjson + /// so the checksum covers everything that was attested to. + /// + [Migration(169)] + public class M0169_AddRmsEvidenceArtifactsPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmsevidenceartifacts").Exists()) + { + Create.Table("rmsevidenceartifacts") + .WithColumn("rmsevidenceartifactid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("recordkind").AsInt32().NotNullable() + .WithColumn("revisionid").AsString(36).Nullable() + .WithColumn("kind").AsInt32().NotNullable() + .WithColumn("title").AsCustom("citext").Nullable() + .WithColumn("capturereason").AsCustom("citext").Nullable() + .WithColumn("sourcesubsystem").AsCustom("citext").Nullable() + .WithColumn("sourceentitytype").AsCustom("citext").Nullable() + .WithColumn("sourceentityid").AsCustom("citext").Nullable() + .WithColumn("identifierscheme").AsCustom("citext").Nullable() + .WithColumn("sourceversion").AsCustom("citext").Nullable() + .WithColumn("coveragestart").AsDateTime2().Nullable() + .WithColumn("coverageend").AsDateTime2().Nullable() + .WithColumn("manifestjson").AsCustom("citext").Nullable() + .WithColumn("checksum").AsCustom("citext").Nullable() + .WithColumn("bytesize").AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn("sourceitemcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("storagereference").AsCustom("citext").Nullable() + .WithColumn("classification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("retentionyears").AsInt32().Nullable() + .WithColumn("capturedbyuserid").AsString(128).Nullable() + .WithColumn("capturedon").AsDateTime2().NotNullable() + .WithColumn("originclient").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("supersededbyartifactid").AsString(36).Nullable() + .WithColumn("supersededon").AsDateTime2().Nullable() + .WithColumn("protectedenvelope").AsCustom("citext").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsevidenceartifacts_department_record_revision ON rmsevidenceartifacts (departmentid, recordid, revisionid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsevidenceartifacts_department_kind ON rmsevidenceartifacts (departmentid, kind, capturedon DESC);"); + } + } + + public override void Down() + { + if (Schema.Table("rmsevidenceartifacts").Exists()) + Delete.Table("rmsevidenceartifacts"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0170_AddRmsDueStateAndRetentionPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0170_AddRmsDueStateAndRetentionPg.cs new file mode 100644 index 00000000..3299667b --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0170_AddRmsDueStateAndRetentionPg.cs @@ -0,0 +1,78 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0170 (registry M0170, RMS-3): rmsrecordduestates and rmsrecordlegalholds. Lowercase + /// unquoted identifiers, citext for text, unique index on (department, record, obligation) so the + /// emit-once-per-transition guarantee is enforced by the database and not by the worker's memory. + /// + [Migration(170)] + public class M0170_AddRmsDueStateAndRetentionPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmsrecordduestates").Exists()) + { + Create.Table("rmsrecordduestates") + .WithColumn("rmsrecordduestateid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("recordkind").AsInt32().NotNullable() + .WithColumn("obligation").AsInt32().NotNullable() + .WithColumn("dueon").AsDateTime2().Nullable() + .WithColumn("lastemittedstate").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("lastemittedon").AsDateTime2().Nullable() + .WithColumn("responsibleuserid").AsString(128).Nullable() + .WithColumn("overduecount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmsrecordduestates_record_obligation ON rmsrecordduestates (departmentid, recordid, obligation);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordduestates_department_due ON rmsrecordduestates (departmentid, dueon);"); + } + + if (!Schema.Table("rmsrecordlegalholds").Exists()) + { + Create.Table("rmsrecordlegalholds") + .WithColumn("rmsrecordlegalholdid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("recordid").AsString(36).Nullable() + .WithColumn("definitionkey").AsCustom("citext").Nullable() + .WithColumn("periodstart").AsDateTime2().Nullable() + .WithColumn("periodend").AsDateTime2().Nullable() + .WithColumn("reason").AsCustom("citext").Nullable() + .WithColumn("referencenumber").AsCustom("citext").Nullable() + .WithColumn("notes").AsCustom("citext").Nullable() + .WithColumn("placedbyuserid").AsString(128).Nullable() + .WithColumn("placedon").AsDateTime2().NotNullable() + .WithColumn("releasedbyuserid").AsString(128).Nullable() + .WithColumn("releasedon").AsDateTime2().Nullable() + .WithColumn("releasenotes").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordlegalholds_department_released ON rmsrecordlegalholds (departmentid, releasedon);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordlegalholds_department_record ON rmsrecordlegalholds (departmentid, recordid);"); + } + + // A purged Record keeps its row and loses only its content (plan 4.9); the tombstone needs to say so. + if (Schema.Table("rmsoperationalrecords").Exists() && !Schema.Table("rmsoperationalrecords").Column("purgedon").Exists()) + Alter.Table("rmsoperationalrecords").AddColumn("purgedon").AsDateTime2().Nullable(); + + if (Schema.Table("rmsincidentreports").Exists() && !Schema.Table("rmsincidentreports").Column("purgedon").Exists()) + Alter.Table("rmsincidentreports").AddColumn("purgedon").AsDateTime2().Nullable(); + } + + public override void Down() + { + foreach (var table in new[] { "rmsrecordlegalholds", "rmsrecordduestates" }) + { + if (Schema.Table(table).Exists()) + Delete.Table(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0171_AddRmsDisclosuresPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0171_AddRmsDisclosuresPg.cs new file mode 100644 index 00000000..ffca9e0a --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0171_AddRmsDisclosuresPg.cs @@ -0,0 +1,87 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0171 (registry M0171, RMS-3): rmsdisclosurerequests and rmsdisclosureproductions. + /// Lowercase unquoted identifiers, citext for text, partial unique index on the department request number. + /// + [Migration(171)] + public class M0171_AddRmsDisclosuresPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmsdisclosurerequests").Exists()) + { + Create.Table("rmsdisclosurerequests") + .WithColumn("rmsdisclosurerequestid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).NotNullable() + .WithColumn("requestnumber").AsCustom("citext").Nullable() + .WithColumn("requestername").AsCustom("citext").Nullable() + .WithColumn("requesterorganization").AsCustom("citext").Nullable() + .WithColumn("requestercontact").AsCustom("citext").Nullable() + .WithColumn("receivedon").AsDateTime2().NotNullable() + .WithColumn("statutorydueon").AsDateTime2().Nullable() + .WithColumn("jurisdictionprofile").AsCustom("citext").Nullable() + .WithColumn("scopenarrative").AsCustom("citext").Nullable() + .WithColumn("scopequeryjson").AsCustom("citext").Nullable() + .WithColumn("state").AsInt32().NotNullable() + .WithColumn("assignedtouserid").AsString(128).Nullable() + .WithColumn("redactionprofile").AsCustom("citext").Nullable() + .WithColumn("closedon").AsDateTime2().Nullable() + .WithColumn("closedbyuserid").AsString(128).Nullable() + .WithColumn("dispositionreason").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("modifiedbyuserid").AsString(128).Nullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsdisclosurerequests_department_state ON rmsdisclosurerequests (departmentid, state);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsdisclosurerequests_department_due ON rmsdisclosurerequests (departmentid, statutorydueon);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmsdisclosurerequests_number ON rmsdisclosurerequests (departmentid, requestnumber) WHERE requestnumber IS NOT NULL AND deletedon IS NULL;"); + } + + if (!Schema.Table("rmsdisclosureproductions").Exists()) + { + Create.Table("rmsdisclosureproductions") + .WithColumn("rmsdisclosureproductionid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).NotNullable() + .WithColumn("disclosurerequestid").AsString(36).NotNullable() + .WithColumn("productionnumber").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("redactionprofile").AsCustom("citext").Nullable() + .WithColumn("producedsetjson").AsCustom("citext").Nullable() + .WithColumn("artifactjson").AsCustom("citext").Nullable() + .WithColumn("checksum").AsCustom("citext").Nullable() + .WithColumn("bytesize").AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn("recordcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("withheldfieldsjson").AsCustom("citext").Nullable() + .WithColumn("withheldfieldcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("preparedbyuserid").AsString(128).Nullable() + .WithColumn("preparedon").AsDateTime2().NotNullable() + .WithColumn("releasedbyuserid").AsString(128).Nullable() + .WithColumn("releasedon").AsDateTime2().Nullable() + .WithColumn("protectedenvelope").AsCustom("citext").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsdisclosureproductions_department_request ON rmsdisclosureproductions (departmentid, disclosurerequestid);"); + } + } + + public override void Down() + { + foreach (var table in new[] { "rmsdisclosureproductions", "rmsdisclosurerequests" }) + { + if (Schema.Table(table).Exists()) + Delete.Table(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Neris/Contract/neris-value-sets-v1.4.78-2026-09-03.json b/Providers/Resgrid.Providers.Neris/Contract/neris-value-sets-v1.4.78-2026-09-03.json index 7c68f06c..0e2a0a9b 100644 --- a/Providers/Resgrid.Providers.Neris/Contract/neris-value-sets-v1.4.78-2026-09-03.json +++ b/Providers/Resgrid.Providers.Neris/Contract/neris-value-sets-v1.4.78-2026-09-03.json @@ -1,1309 +1,2888 @@ { - "contract_version": "1.4.78", - "pinned_on": "2026-09-03", - "source": "https://api.neris.fsri.org/v1/openapi.json", - "sets": { - "incident_type": { - "schema": "TypeIncidentValue", - "codes": [ - "FIRE||OUTSIDE_FIRE||CONSTRUCTION_WASTE", - "FIRE||OUTSIDE_FIRE||DUMPSTER_OUTDOOR_CONTAINER_FIRE", - "FIRE||OUTSIDE_FIRE||OTHER_OUTSIDE_FIRE", - "FIRE||OUTSIDE_FIRE||OUTSIDE_TANK_FIRE", - "FIRE||OUTSIDE_FIRE||TRASH_RUBBISH_FIRE", - "FIRE||OUTSIDE_FIRE||UTILITY_INFRASTRUCTURE_FIRE", - "FIRE||OUTSIDE_FIRE||VEGETATION_GRASS_FIRE", - "FIRE||OUTSIDE_FIRE||WILDFIRE_URBAN_INTERFACE", - "FIRE||OUTSIDE_FIRE||WILDFIRE_WILDLAND", - "FIRE||SPECIAL_FIRE||ESS_FIRE", - "FIRE||SPECIAL_FIRE||EXPLOSION", - "FIRE||SPECIAL_FIRE||INFRASTRUCTURE_FIRE", - "FIRE||STRUCTURE_FIRE||CHIMNEY_FIRE", - "FIRE||STRUCTURE_FIRE||CONFINED_COOKING_APPLIANCE_FIRE", - "FIRE||STRUCTURE_FIRE||ROOM_AND_CONTENTS_FIRE", - "FIRE||STRUCTURE_FIRE||STRUCTURAL_INVOLVEMENT_FIRE", - "FIRE||TRANSPORTATION_FIRE||AIRCRAFT_FIRE", - "FIRE||TRANSPORTATION_FIRE||BOAT_PERSONAL_WATERCRAFT_BARGE_FIRE", - "FIRE||TRANSPORTATION_FIRE||POWERED_MOBILITY_DEVICE_FIRE", - "FIRE||TRANSPORTATION_FIRE||TRAIN_RAIL_FIRE", - "FIRE||TRANSPORTATION_FIRE||VEHICLE_FIRE_COMMERCIAL", - "FIRE||TRANSPORTATION_FIRE||VEHICLE_FIRE_FOOD_TRUCK", - "FIRE||TRANSPORTATION_FIRE||VEHICLE_FIRE_PASSENGER", - "FIRE||TRANSPORTATION_FIRE||VEHICLE_FIRE_RV", - "HAZSIT||HAZARDOUS_MATERIALS||BIOLOGICAL_RELEASE_INCIDENT", - "HAZSIT||HAZARDOUS_MATERIALS||CARBON_MONOXIDE_RELEASE", - "HAZSIT||HAZARDOUS_MATERIALS||FUEL_SPILL_ODOR", - "HAZSIT||HAZARDOUS_MATERIALS||GAS_LEAK_ODOR", - "HAZSIT||HAZARDOUS_MATERIALS||HAZMAT_RELEASE_FACILITY", - "HAZSIT||HAZARDOUS_MATERIALS||HAZMAT_RELEASE_TRANSPORT", - "HAZSIT||HAZARDOUS_MATERIALS||RADIOACTIVE_RELEASE_INCIDENT", - "HAZSIT||HAZARD_NONCHEM||BOMB_THREAT_RESPONSE_SUSPICIOUS_PACKAGE", - "HAZSIT||HAZARD_NONCHEM||ELEC_HAZARD_SHORT_CIRCUIT", - "HAZSIT||HAZARD_NONCHEM||ELEC_POWER_LINE_DOWN_ARCHING_MALFUNC", - "HAZSIT||HAZARD_NONCHEM||MOTOR_VEHICLE_COLLISION", - "HAZSIT||INVESTIGATION||ODOR", - "HAZSIT||INVESTIGATION||SMOKE_INVESTIGATION", - "HAZSIT||OVERPRESSURE||NO_RUPTURE", - "HAZSIT||OVERPRESSURE||RUPTURE_WITHOUT_FIRE", - "LAWENFORCE", - "MEDICAL||ILLNESS", - "MEDICAL||ILLNESS||ABDOMINAL_PAIN", - "MEDICAL||ILLNESS||ALLERGIC_REACTION_STINGS", - "MEDICAL||ILLNESS||ALTERED_MENTAL_STATUS", - "MEDICAL||ILLNESS||BACK_PAIN_NON_TRAUMA", - "MEDICAL||ILLNESS||BREATHING_PROBLEMS", - "MEDICAL||ILLNESS||CARDIAC_ARREST", - "MEDICAL||ILLNESS||CHEST_PAIN_NON_TRAUMA", - "MEDICAL||ILLNESS||CONVULSIONS_SEIZURES", - "MEDICAL||ILLNESS||DIABETIC_PROBLEMS", - "MEDICAL||ILLNESS||HEADACHE", - "MEDICAL||ILLNESS||HEART_PROBLEMS", - "MEDICAL||ILLNESS||NAUSEA_VOMITING", - "MEDICAL||ILLNESS||NO_APPROPRIATE_CHOICE", - "MEDICAL||ILLNESS||OVERDOSE", - "MEDICAL||ILLNESS||PANDEMIC_EPIDEMIC_OUTBREAK", - "MEDICAL||ILLNESS||PREGNANCY_CHILDBIRTH", - "MEDICAL||ILLNESS||PSYCHOLOGICAL_BEHAVIOR_ISSUES", - "MEDICAL||ILLNESS||SICK_CASE", - "MEDICAL||ILLNESS||STROKE_CVA", - "MEDICAL||ILLNESS||UNCONSCIOUS_VICTIM", - "MEDICAL||ILLNESS||UNKNOWN_PROBLEM", - "MEDICAL||ILLNESS||WELL_PERSON_CHECK", - "MEDICAL||INJURY", - "MEDICAL||INJURY||ANIMAL_BITES", - "MEDICAL||INJURY||ASSAULT", - "MEDICAL||INJURY||BURNS_EXPLOSION", - "MEDICAL||INJURY||CARBON_MONOXIDE_OTHER_INHALATION_INJURY", - "MEDICAL||INJURY||CHOKING", - "MEDICAL||INJURY||DROWNING_DIVING_SCUBA_ACCIDENT", - "MEDICAL||INJURY||ELECTROCUTION", - "MEDICAL||INJURY||EYE_TRAUMA", - "MEDICAL||INJURY||FALL", - "MEDICAL||INJURY||GUNSHOT_WOUND", - "MEDICAL||INJURY||HEAT_COLD_EXPOSURE", - "MEDICAL||INJURY||HEMORRHAGE_LACERATION", - "MEDICAL||INJURY||INDUSTRIAL_INACCESSIBLE_ENTRAPMENT", - "MEDICAL||INJURY||MOTOR_VEHICLE_COLLISION", - "MEDICAL||INJURY||OTHER_TRAUMATIC_INJURY", - "MEDICAL||INJURY||POISONING", - "MEDICAL||INJURY||STAB_PENETRATING_TRAUMA", - "MEDICAL||OTHER||AIRMEDICAL_TRANSPORT", - "MEDICAL||OTHER||COMMUNITY_PUBLIC_HEALTH", - "MEDICAL||OTHER||HEALTHCARE_PROFESSIONAL_ADMISSION", - "MEDICAL||OTHER||INTERCEPT_OTHER_UNIT", - "MEDICAL||OTHER||MEDICAL_ALARM", - "MEDICAL||OTHER||STANDBY_REQUEST", - "MEDICAL||OTHER||TRANSFER_INTERFACILITY", - "NOEMERG||CANCELLED", - "NOEMERG||FALSE_ALARM||ACCIDENTAL_ALARM", - "NOEMERG||FALSE_ALARM||BOMB_SCARE", - "NOEMERG||FALSE_ALARM||INTENTIONAL_FALSE_ALARM", - "NOEMERG||FALSE_ALARM||MALFUNCTIONING_ALARM", - "NOEMERG||FALSE_ALARM||OTHER_FALSE_CALL", - "NOEMERG||GOOD_INTENT||CONTROLLED_BURNING_AUTHORIZED", - "NOEMERG||GOOD_INTENT||INVESTIGATE_HAZARDOUS_RELEASE", - "NOEMERG||GOOD_INTENT||NO_INCIDENT_FOUND_LOCATION_ERROR", - "NOEMERG||GOOD_INTENT||SMOKE_FROM_NONHOSTILE_SOURCE", - "PUBSERV||ALARMS_NONMED||CO_ALARM", - "PUBSERV||ALARMS_NONMED||FIRE_ALARM", - "PUBSERV||ALARMS_NONMED||GAS_ALARM", - "PUBSERV||ALARMS_NONMED||OTHER_ALARM", - "PUBSERV||CITIZEN_ASSIST||CITIZEN_ASSIST_SERVICE_CALL", - "PUBSERV||CITIZEN_ASSIST||LIFT_ASSIST", - "PUBSERV||CITIZEN_ASSIST||LOST_PERSON", - "PUBSERV||CITIZEN_ASSIST||PERSON_IN_DISTRESS", - "PUBSERV||DISASTER_WEATHER||DAMAGE_ASSESSMENT", - "PUBSERV||DISASTER_WEATHER||WEATHER_RESPONSE", - "PUBSERV||OTHER||DAMAGED_HYDRANT", - "PUBSERV||OTHER||MOVE_UP", - "PUBSERV||OTHER||STANDBY", - "RESCUE||OUTSIDE||BACKCOUNTRY_RESCUE", - "RESCUE||OUTSIDE||CONFINED_SPACE_RESCUE", - "RESCUE||OUTSIDE||EXTRICATION_ENTRAPPED", - "RESCUE||OUTSIDE||HIGH_ANGLE_RESCUE", - "RESCUE||OUTSIDE||LIMITED_NO_ACCESS", - "RESCUE||OUTSIDE||LOW_ANGLE_RESCUE", - "RESCUE||OUTSIDE||STEEP_ANGLE_RESCUE", - "RESCUE||OUTSIDE||TRENCH", - "RESCUE||STRUCTURE||BUILDING_STRUCTURE_COLLAPSE", - "RESCUE||STRUCTURE||CONFINED_SPACE_RESCUE", - "RESCUE||STRUCTURE||ELEVATOR_ESCALATOR_RESCUE", - "RESCUE||STRUCTURE||EXTRICATION_ENTRAPPED", - "RESCUE||TRANSPORTATION||AVIATION_COLLISION_CRASH", - "RESCUE||TRANSPORTATION||AVIATION_STANDBY", - "RESCUE||TRANSPORTATION||MOTOR_VEHICLE_EXTRICATION_ENTRAPPED", - "RESCUE||TRANSPORTATION||TRAIN_RAIL_COLLISION_DERAILMENT", - "RESCUE||WATER||PERSON_IN_WATER_STANDING", - "RESCUE||WATER||PERSON_IN_WATER_SWIFTWATER", - "RESCUE||WATER||WATERCRAFT_IN_DISTRESS" - ] - }, - "action_tactic": { - "schema": "TypeActionTacticValue", - "codes": [ - "COMMAND_AND_CONTROL||ACCOUNTABILITY_OFFICER_ASSIGNED", - "COMMAND_AND_CONTROL||ESTABLISH_INCIDENT_COMMAND", - "COMMAND_AND_CONTROL||INCIDENT_ASSESSMENT_COMPLETED", - "COMMAND_AND_CONTROL||NOTIFY_OTHER_AGENCIES", - "COMMAND_AND_CONTROL||PIO_ASSIGNED", - "COMMAND_AND_CONTROL||SAFETY_OFFICER_ASSIGNED", - "CONTAINMENT||OUTSIDE_FIRE_SUPPRESSION||DOZER_FUEL_BREAK", - "CONTAINMENT||OUTSIDE_FIRE_SUPPRESSION||HAND_CREW_FUEL_BREAK", - "EMERGENCY_MEDICAL_CARE||PATIENT_ASSESSMENT", - "EMERGENCY_MEDICAL_CARE||PATIENT_REFERRAL", - "EMERGENCY_MEDICAL_CARE||PROVIDE_ADVANCED_LIFE_SUPPORT", - "EMERGENCY_MEDICAL_CARE||PROVIDE_BASIC_LIFE_SUPPORT", - "EMERGENCY_MEDICAL_CARE||PROVIDE_TRANSPORT", - "FORCIBLE_ENTRY", - "HAZARDOUS_SITUATION_MITIGATION||ATMOSPHERIC_MONITORING_EXTERIOR_FENCELINE", - "HAZARDOUS_SITUATION_MITIGATION||ATMOSPHERIC_MONITORING_INTERIOR", - "HAZARDOUS_SITUATION_MITIGATION||DECONTAMINATION", - "HAZARDOUS_SITUATION_MITIGATION||LEAK_STOP", - "HAZARDOUS_SITUATION_MITIGATION||REMOVE_HAZARD", - "HAZARDOUS_SITUATION_MITIGATION||SPILL_CONTROL", - "HAZARDOUS_SITUATION_MITIGATION||TAKE_SAMPLES", - "INFORMATION_ENFORCEMENT||ENFORCE_CODE_OR_LAW", - "INFORMATION_ENFORCEMENT||PROVIDE_PUBLIC_INFORMATION", - "INFORMATION_ENFORCEMENT||REFER_TO_PROPER_AHJ", - "INVESTIGATION", - "NON_STRUCTURE_SEARCH||BODY_RECOVERY", - "NON_STRUCTURE_SEARCH||SEARCH_AREA_OF_COLLAPSE", - "NON_STRUCTURE_SEARCH||SEARCH_UNDERGROUND_INFRASTRUCTURE", - "NON_STRUCTURE_SEARCH||SEARCH_WATERWAY", - "NON_STRUCTURE_SEARCH||USAR_K9_SEARCH", - "NON_STRUCTURE_SEARCH||WIDE_AREA_OUTDOOR_SEARCH", - "PERSONNEL_CONTAMINATION_REDUCTION||CLEAN_CAB_TRANSPORT", - "PERSONNEL_CONTAMINATION_REDUCTION||ON_SCENE_CONTAMINATION_REDUCTION", - "PERSONNEL_CONTAMINATION_REDUCTION||PPE_WASHED_POST_INCIDENT", - "PROVIDE_EQUIPMENT||PROVIDE_DRONE_VIDEO_EQUIPMENT", - "PROVIDE_EQUIPMENT||PROVIDE_ELECTRICAL_POWER", - "PROVIDE_EQUIPMENT||PROVIDE_LIGHT", - "PROVIDE_EQUIPMENT||PROVIDE_SPECIAL_EQUIPMENT", - "PROVIDE_EVACUATION_SUPPORT||CONNECTED_INTERIOR_SPACES", - "PROVIDE_EVACUATION_SUPPORT||LARGE_AREA", - "PROVIDE_EVACUATION_SUPPORT||NEARBY_BUILDINGS", - "PROVIDE_EVACUATION_SUPPORT||REMOTE_INTERIOR_SPACES", - "PROVIDE_SERVICES||ASSIST_ANIMAL", - "PROVIDE_SERVICES||ASSIST_UNINJURED_PERSON", - "PROVIDE_SERVICES||CONTROL_CROWD", - "PROVIDE_SERVICES||CONTROL_TRAFFIC", - "PROVIDE_SERVICES||DAMAGE_ASSESSMENT", - "PROVIDE_SERVICES||PROVIDE_APPARATUS_WATER", - "PROVIDE_SERVICES||REMOVE_WATER", - "PROVIDE_SERVICES||RESTORE_RESET_ALARM_SYSTEM", - "PROVIDE_SERVICES||RESTORE_SPRINKLER_SYSTEM", - "PROVIDE_SERVICES||SECURE_PROPERTY", - "PROVIDE_SERVICES||SHUT_DOWN_ALARM", - "PROVIDE_SERVICES||SHUT_DOWN_SPRINKLER_SYSTEM", - "SALVAGE_AND_OVERHAUL", - "SEARCH_STRUCTURE||DOOR_INITIATED_SEARCH", - "SEARCH_STRUCTURE||DOOR_INITIATED_SEARCH||DURING_SUPPRESSION", - "SEARCH_STRUCTURE||DOOR_INITIATED_SEARCH||POST_SUPPRESSION", - "SEARCH_STRUCTURE||DOOR_INITIATED_SEARCH||PRIOR_TO_SUPPRESSION", - "SEARCH_STRUCTURE||WINDOW_INITIATED_SEARCH", - "SEARCH_STRUCTURE||WINDOW_INITIATED_SEARCH||DURING_SUPPRESSION", - "SEARCH_STRUCTURE||WINDOW_INITIATED_SEARCH||POST_SUPPRESSION", - "SEARCH_STRUCTURE||WINDOW_INITIATED_SEARCH||PRIOR_TO_SUPPRESSION", - "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||BACKBURN", - "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||CONFINEMENT", - "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||ESTABLISH_FIRE_LINES", - "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||FIRE_CONTROL_EXTINGUISHMENT", - "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||FIRE_RETARDANT_DROP", - "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||STRUCTURE_PROTECTION", - "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||WATER_DROP", - "SUPPRESSION||STRUCTURAL_FIRE_SUPPRESSION||EXTERIOR", - "SUPPRESSION||STRUCTURAL_FIRE_SUPPRESSION||EXTERIOR_AND_INTERIOR", - "SUPPRESSION||STRUCTURAL_FIRE_SUPPRESSION||INTERIOR", - "VENTILATION||HORIZONTAL", - "VENTILATION||HORIZONTAL||DURING_SUPPRESSION", - "VENTILATION||HORIZONTAL||POST_SUPPRESSION", - "VENTILATION||HORIZONTAL||PRIOR_TO_SUPPRESSION", - "VENTILATION||HYDRAULIC", - "VENTILATION||HYDRAULIC||DURING_SUPPRESSION", - "VENTILATION||HYDRAULIC||POST_SUPPRESSION", - "VENTILATION||HYDRAULIC||PRIOR_TO_SUPPRESSION", - "VENTILATION||POSITIVE_PRESSURE", - "VENTILATION||POSITIVE_PRESSURE||DURING_SUPPRESSION", - "VENTILATION||POSITIVE_PRESSURE||POST_SUPPRESSION", - "VENTILATION||POSITIVE_PRESSURE||PRIOR_TO_SUPPRESSION", - "VENTILATION||VERTICAL", - "VENTILATION||VERTICAL||DURING_SUPPRESSION", - "VENTILATION||VERTICAL||POST_SUPPRESSION", - "VENTILATION||VERTICAL||PRIOR_TO_SUPPRESSION" - ] - }, - "aid_type": { - "schema": "TypeAidValue", - "codes": [ - "ACTING_AS_AID", - "IN_LIEU_AID", - "SUPPORT_AID" - ] - }, - "aid_direction": { - "schema": "TypeAidDirectionValue", - "codes": [ - "GIVEN", - "RECEIVED" - ] - }, - "aid_nonfd": { - "schema": "TypeAidNonfdValue", - "codes": [ - "ANIMAL_SERVICES", - "EMS", - "HOUSING_SERVICES", - "LAW_ENFORCEMENT", - "REMEDIATION_SERVICES", - "SOCIAL_SERVICES", - "UTILITIES_PUBLIC_WORKS" - ] - }, - "special_modifier": { - "schema": "TypeSpecialModifierValue", - "codes": [ - "ACTIVE_ASSAILANT", - "COUNTY_LOCAL_DECLARED_DISASTER", - "FEDERAL_DECLARED_DISASTER", - "MCI", - "STATE_DECLARED_DISASTER", - "URBAN_CONFLAGRATION", - "VIOLENCE_AGAINST_RESPONDER", - "WORLD_CUP_2026" - ] - }, - "response_mode": { - "schema": "TypeResponseModeValue", - "codes": [ - "EMERGENT", - "NON_EMERGENT" - ] - }, - "incident_status": { - "schema": "TypeIncidentStatusValue", - "codes": [ - "APPROVED", - "DELETED", - "FAILED", - "PENDING_APPROVAL", - "PENDING_INCIDENT_DATA", - "REJECTED", - "SUBMITTED" - ] - }, - "incident_status_payload": { - "schema": "TypeIncidentStatusPayloadValue", - "codes": [ - "APPROVED", - "REJECTED" - ] - }, - "location_place": { - "schema": "TypeLocPlaceValue", - "codes": [ - "AIRCRAFT", - "AIRPORT", - "ARENA", - "AUTOMOBILE", - "BANK", - "BAR", - "BICYCLE", - "BUS", - "BUS_STATION", - "CAFE", - "CLASSROOM", - "CLUB", - "CONSTRUCTION", - "CONVENTION_CENTER", - "GOVERNMENT", - "HOSPITAL", - "HOTEL", - "INDUSTRIAL", - "LIBRARY", - "MOTORCYCLE", - "MUSEUM", - "OFFICE", - "OTHER", - "OUTDOORS", - "PARKING", - "PLACE_OF_WORSHIP", - "PRISON", - "PUBLIC", - "PUBLIC_TRANSPORT", - "RESIDENCE", - "RESTAURANT", - "SCHOOL", - "SHOPPING_AREA", - "STADIUM", - "STORE", - "STREET", - "THEATER", - "TRAIN", - "TRAIN_STATION", - "TRUCK", - "UNDERWAY", - "UNKNOWN", - "WAREHOUSE", - "WATER", - "WATERCRAFT" - ] - }, - "location_use": { - "schema": "TypeLocationUseValue", - "codes": [ - "AGRICULTURE_STRUCT||ANIMAL_PROCESSING", - "AGRICULTURE_STRUCT||AUCTION_FEEDLOT", - "AGRICULTURE_STRUCT||FARM_BUILDING", - "AGRICULTURE_STRUCT||STORAGE_SILO", - "AGRICULTURE_STRUCT||VETERINARY_LIVESTOCK", - "ASSEMBLY||COMMUNITY_CENTER", - "ASSEMBLY||CONVENTION_CENTER", - "ASSEMBLY||INDOOR_ARENA", - "ASSEMBLY||MUSEUM_EXHIBIT_HALL_LIBRARY", - "ASSEMBLY||OUTDOOR_ARENA_AMPHITHEATER_PARK", - "ASSEMBLY||RELIGIOUS", - "ASSEMBLY||TEMP_OUTDOOR_STRUCT_EVENT", - "COMMERCIAL||BAR_NIGHTCLUB", - "COMMERCIAL||ENTERTAINMENT_RECREATION", - "COMMERCIAL||OFFICE_OTHER_TECHNICAL_SERVICES", - "COMMERCIAL||RESTAURANT_CAFE", - "COMMERCIAL||RETAIL_WHOLESALE_TRADE", - "COMMERCIAL||THEATERS_STUDIO", - "COMMERCIAL||VEHICLE_FUELING_CHARGING_STATION", - "COMMERCIAL||VEHICLE_REPAIR_SERVICES", - "COMMERCIAL||VETERINARY_PET", - "EDUCATION||COLLEGES_UNIVERSITIES", - "EDUCATION||DORMITORY_HOUSING", - "EDUCATION||K_12_SCHOOLS", - "EDUCATION||OTHER_EDUCATIONAL_BUILDINGS", - "EDUCATION||PREK_DAYCARE", - "GOVERNMENT||FIRE_MEDICAL_STATION", - "GOVERNMENT||GENERAL_SERVICES", - "GOVERNMENT||JAIL_PRISON_REFORMATORY", - "GOVERNMENT||NON_CIVILIAN_STRUCTURES", - "GOVERNMENT||POLICE_EMERGENCY_STATION", - "HEALTH_CARE||ALCOHOL_DRUG_REHABILITATION_CENTER", - "HEALTH_CARE||HOSPITAL_24_HOUR_MEDICAL_FACILITIES", - "HEALTH_CARE||MEDICAL_OFFICE_CLINIC", - "HEALTH_CARE||NURSING_HOME_ASSISTED_LIVING_RESIDENCE_ONSITE", - "INDUSTRIAL||CHEMICAL", - "INDUSTRIAL||COLD_STORAGE", - "INDUSTRIAL||FOOD_DRUGS", - "INDUSTRIAL||HEAVY", - "INDUSTRIAL||LIGHT", - "INDUSTRIAL||METALS_MINERALS_PROCESSING", - "OUTDOOR_INDUSTRIAL||CONSTRUCTION_SITE", - "OUTDOOR_INDUSTRIAL||DUMP_LANDFILL", - "OUTDOOR_INDUSTRIAL||INDUSTRIAL_YARD", - "OUTDOOR_INDUSTRIAL||MINE", - "OUTDOOR||CAMP_SITE", - "OUTDOOR||FOREST_GRASSLANDS_WOODLAND_WILDLAND_AREAS", - "OUTDOOR||GROUND_VACANT_LAND", - "OUTDOOR||HIKING_TRAIL", - "OUTDOOR||OPEN_WATER", - "OUTDOOR||ORCHARD_CROPS_FARMLAND", - "OUTDOOR||PLAYGROUND_PARK_RECREATIONAL_AREA", - "OUTDOOR||WATERFRONT", - "RESIDENTIAL||ATTACHED_SINGLE_FAMILY_DWELLING", - "RESIDENTIAL||CONGREGATE_HOUSING", - "RESIDENTIAL||DETATCHED_GARAGE", - "RESIDENTIAL||DETATCHED_SINGLE_FAMILY_DWELLING", - "RESIDENTIAL||MANUFACTURED_MOBILE_HOME", - "RESIDENTIAL||MULTI_FAMILY_HIGHRISE_DWELLING", - "RESIDENTIAL||MULTI_FAMILY_LOWRISE_DWELLING", - "RESIDENTIAL||MULTI_FAMILY_MIDRISE_DWELLING", - "RESIDENTIAL||TEMPORARY_LODGING_HOTEL_MOTEL", - "RESIDENTIAL||UNHOUSED_TEMPORARY_SHELTER", - "ROADWAY_ACCESS||BRIDGE", - "ROADWAY_ACCESS||HIGHWAY_INTERSTATE", - "ROADWAY_ACCESS||LIMITED_ACCESS_HIGHWAY_INTERSTATE", - "ROADWAY_ACCESS||PARKING_LOT_GARAGE", - "ROADWAY_ACCESS||RAILROAD_RAILYARD", - "ROADWAY_ACCESS||SIDEWALK", - "ROADWAY_ACCESS||STREET", - "ROADWAY_ACCESS||TUNNEL", - "STORAGE||STORAGE_MULTI_TENANT", - "STORAGE||STORAGE_PORTABLE_BUILDING", - "STORAGE||STORAGE_SINGLE_TENANT", - "UNCLASSIFIED||UNCLASSIFIED", - "UTILITY_MISC||ENERGY_FACILITY_INFRASTRUCTURE", - "UTILITY_MISC||TRANSPORTATION_STATION_HUB_AREA", - "UTILITY_MISC||TRASH_RECYCLING_FACILITY", - "UTILITY_MISC||WATER_SANITATION_FACILITY_INFRASTRUCTURE" - ] - }, - "vacancy": { - "schema": "TypeVacancyValue", - "codes": [ - "ABANDONED", - "DAMAGE_DECAY", - "FORECLOSURE", - "FOR_SALE_LEASE", - "NEW_CONSTRUCTION_REMODEL", - "SEASONAL_OCCASIONALLY_OCCUPIED", - "UNKNOWN" - ] - }, - "state": { - "schema": "StatesTerrs", - "codes": [ - "AL", - "AK", - "AS", - "AZ", - "AR", - "CA", - "CO", - "CT", - "DE", - "DC", - "FL", - "FM", - "GA", - "GU", - "HI", - "ID", - "IL", - "IN", - "IA", - "KS", - "KY", - "LA", - "ME", - "MD", - "MA", - "MI", - "MH", - "MN", - "MS", - "MO", - "MP", - "MT", - "NA", - "NE", - "NV", - "NH", - "NJ", - "NM", - "NY", - "NC", - "ND", - "OH", - "OK", - "OR", - "PA", - "PR", - "PW", - "RI", - "SC", - "SD", - "TN", - "TX", - "UM", - "UT", - "VT", - "VA", - "VI", - "WA", - "WV", - "WI", - "WY", - "AA", - "AE", - "AP", - "AB", - "BC", - "MB", - "NB", - "NL", - "NS", - "NT", - "NU", - "ON", - "PE", - "QC", - "SK", - "YT", - "AGU", - "BCN", - "BCS", - "CAM", - "CHP", - "CHH", - "CMX", - "COA", - "COL", - "DUR", - "GUA", - "GRO", - "HID", - "JAL", - "MIC", - "MOR", - "MEX", - "NAY", - "NLE", - "OAX", - "PUE", - "QUE", - "ROO", - "SLP", - "SIN", - "SON", - "TAB", - "TAM", - "TLA", - "VER", - "YUC", - "ZAC" - ] - }, - "country": { - "schema": "TypeLocCspCountryValue", - "codes": [ - "AD", - "AE", - "AF", - "AG", - "AI", - "AL", - "AM", - "AO", - "AQ", - "AR", - "AS", - "AT", - "AU", - "AW", - "AX", - "AZ", - "BA", - "BB", - "BD", - "BE", - "BF", - "BG", - "BH", - "BI", - "BJ", - "BL", - "BM", - "BN", - "BO", - "BQ", - "BR", - "BS", - "BT", - "BV", - "BW", - "BY", - "BZ", - "CA", - "CC", - "CD", - "CF", - "CG", - "CH", - "CI", - "CK", - "CL", - "CM", - "CN", - "CO", - "CR", - "CU", - "CV", - "CW", - "CX", - "CY", - "CZ", - "DE", - "DJ", - "DK", - "DM", - "DO", - "DZ", - "EC", - "EE", - "EG", - "EH", - "ER", - "ES", - "ET", - "FI", - "FJ", - "FK", - "FM", - "FO", - "FR", - "GA", - "GB", - "GD", - "GE", - "GF", - "GG", - "GH", - "GI", - "GL", - "GM", - "GN", - "GP", - "GQ", - "GR", - "GS", - "GT", - "GU", - "GW", - "GY", - "HK", - "HM", - "HN", - "HR", - "HT", - "HU", - "ID", - "IE", - "IL", - "IM", - "IN", - "IO", - "IQ", - "IR", - "IS", - "IT", - "JE", - "JM", - "JO", - "JP", - "KE", - "KG", - "KH", - "KI", - "KM", - "KN", - "KP", - "KR", - "KW", - "KY", - "KZ", - "LA", - "LB", - "LC", - "LI", - "LK", - "LR", - "LS", - "LT", - "LU", - "LV", - "LY", - "MA", - "MC", - "MD", - "ME", - "MF", - "MG", - "MH", - "MK", - "ML", - "MM", - "MN", - "MO", - "MP", - "MQ", - "MR", - "MS", - "MT", - "MU", - "MV", - "MW", - "MX", - "MY", - "MZ", - "NA", - "NC", - "NE", - "NF", - "NG", - "NI", - "NL", - "NO", - "NP", - "NR", - "NU", - "NZ", - "OM", - "PA", - "PE", - "PF", - "PG", - "PH", - "PK", - "PL", - "PM", - "PN", - "PR", - "PS", - "PT", - "PW", - "PY", - "QA", - "RE", - "RO", - "RS", - "RU", - "RW", - "SA", - "SB", - "SC", - "SD", - "SE", - "SG", - "SH", - "SI", - "SJ", - "SK", - "SL", - "SM", - "SN", - "SO", - "SR", - "SS", - "ST", - "SV", - "SX", - "SY", - "SZ", - "TC", - "TD", - "TF", - "TG", - "TH", - "TJ", - "TK", - "TL", - "TM", - "TN", - "TO", - "TR", - "TT", - "TV", - "TW", - "TZ", - "UA", - "UG", - "UM", - "US", - "UY", - "UZ", - "VA", - "VC", - "VE", - "VG", - "VI", - "VN", - "VU", - "WF", - "WS", - "YE", - "YT", - "ZA", - "ZM", - "ZW" - ] - }, - "street_prefix_postfix": { - "schema": "TypeLocSnPrePostValue", - "codes": [ - "ABBEY", - "ACCESS", - "ACCESS ROAD", - "ACRES", - "AIRPORT", - "ALCOVE", - "ALLE", - "ALLEY", - "ANNEX", - "APPROACH", - "ARC", - "ARCADE", - "ARCH", - "AVENIDA", - "AVENUE", - "AVENUE CIRCLE", - "AVENUE COURT", - "AVENUE LOOP", - "AVENUE PATH", - "AVENUE PLACE", - "AVENUE WAY", - "BANK", - "BAY", - "BAYOU", - "BAYWAY", - "BEACH", - "BEND", - "BLUFF", - "BLUFFS", - "BOARDWALK", - "BOTTOM", - "BOULEVARD", - "BRANCH", - "BRIDGE", - "BROOK", - "BROOKS", - "BUREAU OF INDIAN AFFAIRS ROUTE", - "BURG", - "BURGS", - "BYPASS", - "CALLE", - "CALLEJON", - "CAMINO", - "CAMP", - "CANYON", - "CAPE", - "CARTWAY", - "CAUSEWAY", - "CENTER", - "CENTERS", - "CENTRE", - "CHANNEL", - "CHASE", - "CHEMIN", - "CIRCLE", - "CIRCLES", - "CIRCUS", - "CLIFF", - "CLIFFS", - "CLOSE", - "CLUB", - "CLUSTER", - "COAST HIGHWAY", - "COMMON", - "COMMONS", - "CONCESSION ROAD", - "CONCOURSE", - "CONNECT", - "CONNECTOR", - "CORNER", - "CORNERS", - "CORRIDOR", - "CORSO", - "CORTE", - "COUNTY FOREST ROAD", - "COUNTY HIGHWAY", - "COUNTY ROAD", - "COUNTY ROUTE", - "COUNTY STATE AID HIGHWAY", - "COURS", - "COURSE", - "COURT", - "COURTS", - "COVE", - "COVES", - "CREEK", - "CRESCENT", - "CREST", - "CROSS", - "CROSSING", - "CROSSINGS", - "CROSSOVER", - "CROSSROAD", - "CROSSROADS", - "CROSSWAY", - "CURVE", - "CUSTER COUNTY ROAD", - "CUTOFF", - "CUTTING", - "DALE", - "DAM", - "DAWSON COUNTY ROAD", - "DELL", - "DIVIDE", - "DOCK", - "DOWN", - "DOWNS", - "DRAW", - "DRIFT", - "DRIVE", - "DRIVES", - "DRIVEWAY", - "DUGWAY", - "ECHO", - "EDGE", - "END", - "ENTRANCE", - "ENTRY", - "ESPLANADE", - "ESTATE", - "ESTATES", - "EXCHANGE", - "EXIT", - "EXPRESSWAY", - "EXTENSION", - "EXTENSIONS", - "FALL", - "FALLS", - "FARE", - "FARM", - "FARM TO MARKET", - "FEDERAL-AID SECONDARY HIGHWAY", - "FERRY", - "FIELD", - "FIELDS", - "FLAT", - "FLATS", - "FLOWAGE", - "FLYWAY", - "FORD", - "FORDS", - "FOREST", - "FOREST HIGHWAY", - "FOREST ROAD", - "FORGE", - "FORGES", - "FORK", - "FORKS", - "FORT", - "FREEWAY", - "FRONT", - "FRONTAGE ROAD", - "GABLES", - "GARDEN", - "GARDENS", - "GARTH", - "GATE", - "GATES", - "GATEWAY", - "GLADE", - "GLEN", - "GLENS", - "GORGE", - "GRADE", - "GREEN", - "GREENS", - "GREENWAY", - "GROVE", - "GROVES", - "HARBOR", - "HARBORS", - "HARBOUR", - "HAUL ROAD", - "HAVEN", - "HEATH", - "HEIGHTS", - "HIDEAWAY", - "HIGHWAY", - "HILL", - "HILLS", - "HOLLOW", - "HORN", - "HORSESHOE", - "INDIAN SERVICE ROAD", - "INLET", - "INTERSTATE", - "INTERVAL", - "ISLAND", - "ISLANDS", - "ISLE", - "ISLES", - "J-TURN", - "JUNCTION", - "JUNCTIONS", - "KEEP", - "KEY", - "KEYS", - "KNOLL", - "KNOLLS", - "LAIR", - "LAKE", - "LAKES", - "LAND", - "LANDING", - "LANE", - "LANE CIRCLE", - "LANE COURT", - "LANE ROAD", - "LATERAL", - "LEDGE", - "LIGHT", - "LIGHTS", - "LINE", - "LOAF", - "LOCK", - "LOCKS", - "LODGE", - "LOOKOUT", - "LOOP", - "LOOP ROAD", - "LUGAR", - "MALL", - "MANOR", - "MANORS", - "MARKET", - "MEADOW", - "MEADOWS", - "MEWS", - "MILL", - "MILLS", - "MISSION", - "MONTANA HIGHWAY", - "MOTORWAY", - "MOUNT", - "MOUNTAIN", - "MOUNTAINS", - "NARROWS", - "NATIONAL FOREST DEVELOPMENT ROAD", - "NECK", - "NOOK", - "NORTH CAROLINA HIGHWAY", - "OAKS", - "OLD COUNTY ROAD", - "ORCHARD", - "OVAL", - "OVERLOOK", - "OVERPASS", - "OVI", - "PARK", - "PARKE", - "PARKS", - "PARKWAY", - "PARKWAYS", - "PASEO", - "PASS", - "PASSAGE", - "PATH", - "PATHWAY", - "PIAZZA", - "PIKE", - "PINE", - "PINES", - "PLACE", - "PLACITA", - "PLAIN", - "PLAINS", - "PLATZ", - "PLAZA", - "POINT", - "POINTE", - "POINTS", - "PORT", - "PORTS", - "PRAIRIE", - "PRIVATE ROAD", - "PROMENADE", - "PUBLIC ACCESS", - "QUARTER", - "QUAY", - "RADIAL", - "RAMP", - "RANCH", - "RANCHO", - "RAPID", - "RAPIDS", - "REACH", - "RECREATIONAL ROAD", - "REST", - "RETREAT", - "RIDGE", - "RIDGES", - "RISE", - "RIVER", - "RIVER ROAD", - "ROAD", - "ROADS", - "ROUND", - "ROUTE", - "ROW", - "RUE", - "RUN", - "RUNNE", - "RUNWAY", - "SHOAL", - "SHOALS", - "SHORE", - "SHORES", - "SIDEROAD", - "SKIES", - "SKYWAY", - "SLIP", - "SPRING", - "SPRINGS", - "SPUR", - "SPURS", - "SQUARE", - "SQUARES", - "STATE HIGHWAY", - "STATE PARK ROAD", - "STATE PARKWAY", - "STATE ROAD", - "STATE ROUTE", - "STATE SECONDARY", - "STATE SPUR", - "STATION", - "STRAND", - "STRASSE", - "STRAVENUE", - "STREAM", - "STREET", - "STREET CIRCLE", - "STREET COURT", - "STREET LOOP", - "STREET PATH", - "STREET PLACE", - "STREET WAY", - "STREETS", - "STRIP", - "SUMMIT", - "TAXIWAY", - "TERMINAL", - "TERN", - "TERRACE", - "THROUGHWAY", - "THRUWAY", - "TIMBER ROAD", - "TOWN ROAD", - "TOWNLINE", - "TOWNSHIP ROAD", - "TRACE", - "TRACK", - "TRAFFICWAY", - "TRAIL", - "TRAILER", - "TRIANGLE", - "TRUCK TRAIL", - "TUNNEL", - "TURN", - "TURNPIKE", - "UNDERPASS", - "UNION", - "UNIONS", - "UNITED STATES FOREST SERVICE ROAD", - "UNITED STATES HIGHWAY", - "UUNYE", - "VALLEY", - "VALLEYS", - "VIA", - "VIADUCT", - "VIEW", - "VIEWS", - "VILLA", - "VILLAGE", - "VILLAGES", - "VILLE", - "VISTA", - "VOG", - "WADDY", - "WALK", - "WALKS", - "WALL", - "WAY", - "WAYS", - "WEEG", - "WELL", - "WELLS", - "WOODS", - "WYE", - "WYND" - ] - }, - "street_direction": { - "schema": "TypeLocSnDirectionValue", - "codes": [ - "EASTBOUND", - "NORTHBOUND", - "SOUTHBOUND", - "WESTBOUND" - ] - }, - "street_separator": { - "schema": "TypeLocSnPreSepValue", - "codes": [ - "AT", - "DE", - "DEL", - "DES", - "DE_LA", - "DE_LAS", - "IN_THE", - "OF", - "OF_THE", - "ON_THE", - "TO", - "TO_THE" - ] - }, - "cross_street_modifier": { - "schema": "TypeLocationCrossStreetValue", - "codes": [ - "CLOSEST", - "INCIDENT_IN_INTERSECTION", - "SECOND_CLOSEST" - ] - }, - "dispatch_protocol_fire": { - "schema": "TypeDispProtoFireValue", - "codes": [ - "APCO", - "IAED", - "OTHER", - "PROQA" - ] - }, - "dispatch_protocol_medical": { - "schema": "TypeDispProtoMedValue", - "codes": [ - "APCO", - "IAED", - "OTHER", - "PROQA" - ] - }, - "incident_complexity": { - "schema": "TypeIncidentComplexityValue", - "codes": [ - "ICS_TYPE_1", - "ICS_TYPE_2", - "ICS_TYPE_3", - "ICS_TYPE_4", - "ICS_TYPE_5" - ] - }, - "fire_condition_arrival": { - "schema": "TypeFireConditionArrivalValue", - "codes": [ - "FIRE_OUT_UPON_ARRIVAL", - "FIRE_SPREAD_BEYOND_STRUCTURE", - "NO_SMOKE_FIRE_SHOWING", - "SMOKE_FIRE_SHOWING", - "SMOKE_SHOWING", - "STRUCTURE_INVOLVED" - ] - }, - "dins_origin_location": { - "schema": "TypeDinsOriginLocationValue", - "codes": [ - "ATTACHED_FENCE", - "ATTACHED_PATIO_COVER_CARPORT", - "DECK_ELEVATED", - "DECK_ON_GRADE", - "EAVES", - "OTHER", - "ROOF", - "SIDING", - "UNKNOWN", - "VENT", - "WINDOW" - ] - }, - "dins_origin_cause": { - "schema": "TypeDinsOriginCauseValue", - "codes": [ - "DIRECT_FLAME_IMPINGEMENT", - "EMBERS", - "OTHER", - "RADIANT_HEAT", - "UNKNOWN" - ] + "contract_version": "1.4.78", + "pinned_on": "2026-09-03", + "source": "https://api.neris.fsri.org/v1/openapi.json", + "sets": { + "action_tactic": { + "schema": "TypeActionTacticValue", + "codes": [ + "COMMAND_AND_CONTROL||ACCOUNTABILITY_OFFICER_ASSIGNED", + "COMMAND_AND_CONTROL||ESTABLISH_INCIDENT_COMMAND", + "COMMAND_AND_CONTROL||INCIDENT_ASSESSMENT_COMPLETED", + "COMMAND_AND_CONTROL||NOTIFY_OTHER_AGENCIES", + "COMMAND_AND_CONTROL||PIO_ASSIGNED", + "COMMAND_AND_CONTROL||SAFETY_OFFICER_ASSIGNED", + "CONTAINMENT||OUTSIDE_FIRE_SUPPRESSION||DOZER_FUEL_BREAK", + "CONTAINMENT||OUTSIDE_FIRE_SUPPRESSION||HAND_CREW_FUEL_BREAK", + "EMERGENCY_MEDICAL_CARE||PATIENT_ASSESSMENT", + "EMERGENCY_MEDICAL_CARE||PATIENT_REFERRAL", + "EMERGENCY_MEDICAL_CARE||PROVIDE_ADVANCED_LIFE_SUPPORT", + "EMERGENCY_MEDICAL_CARE||PROVIDE_BASIC_LIFE_SUPPORT", + "EMERGENCY_MEDICAL_CARE||PROVIDE_TRANSPORT", + "FORCIBLE_ENTRY", + "HAZARDOUS_SITUATION_MITIGATION||ATMOSPHERIC_MONITORING_EXTERIOR_FENCELINE", + "HAZARDOUS_SITUATION_MITIGATION||ATMOSPHERIC_MONITORING_INTERIOR", + "HAZARDOUS_SITUATION_MITIGATION||DECONTAMINATION", + "HAZARDOUS_SITUATION_MITIGATION||LEAK_STOP", + "HAZARDOUS_SITUATION_MITIGATION||REMOVE_HAZARD", + "HAZARDOUS_SITUATION_MITIGATION||SPILL_CONTROL", + "HAZARDOUS_SITUATION_MITIGATION||TAKE_SAMPLES", + "INFORMATION_ENFORCEMENT||ENFORCE_CODE_OR_LAW", + "INFORMATION_ENFORCEMENT||PROVIDE_PUBLIC_INFORMATION", + "INFORMATION_ENFORCEMENT||REFER_TO_PROPER_AHJ", + "INVESTIGATION", + "NON_STRUCTURE_SEARCH||BODY_RECOVERY", + "NON_STRUCTURE_SEARCH||SEARCH_AREA_OF_COLLAPSE", + "NON_STRUCTURE_SEARCH||SEARCH_UNDERGROUND_INFRASTRUCTURE", + "NON_STRUCTURE_SEARCH||SEARCH_WATERWAY", + "NON_STRUCTURE_SEARCH||USAR_K9_SEARCH", + "NON_STRUCTURE_SEARCH||WIDE_AREA_OUTDOOR_SEARCH", + "PERSONNEL_CONTAMINATION_REDUCTION||CLEAN_CAB_TRANSPORT", + "PERSONNEL_CONTAMINATION_REDUCTION||ON_SCENE_CONTAMINATION_REDUCTION", + "PERSONNEL_CONTAMINATION_REDUCTION||PPE_WASHED_POST_INCIDENT", + "PROVIDE_EQUIPMENT||PROVIDE_DRONE_VIDEO_EQUIPMENT", + "PROVIDE_EQUIPMENT||PROVIDE_ELECTRICAL_POWER", + "PROVIDE_EQUIPMENT||PROVIDE_LIGHT", + "PROVIDE_EQUIPMENT||PROVIDE_SPECIAL_EQUIPMENT", + "PROVIDE_EVACUATION_SUPPORT||CONNECTED_INTERIOR_SPACES", + "PROVIDE_EVACUATION_SUPPORT||LARGE_AREA", + "PROVIDE_EVACUATION_SUPPORT||NEARBY_BUILDINGS", + "PROVIDE_EVACUATION_SUPPORT||REMOTE_INTERIOR_SPACES", + "PROVIDE_SERVICES||ASSIST_ANIMAL", + "PROVIDE_SERVICES||ASSIST_UNINJURED_PERSON", + "PROVIDE_SERVICES||CONTROL_CROWD", + "PROVIDE_SERVICES||CONTROL_TRAFFIC", + "PROVIDE_SERVICES||DAMAGE_ASSESSMENT", + "PROVIDE_SERVICES||PROVIDE_APPARATUS_WATER", + "PROVIDE_SERVICES||REMOVE_WATER", + "PROVIDE_SERVICES||RESTORE_RESET_ALARM_SYSTEM", + "PROVIDE_SERVICES||RESTORE_SPRINKLER_SYSTEM", + "PROVIDE_SERVICES||SECURE_PROPERTY", + "PROVIDE_SERVICES||SHUT_DOWN_ALARM", + "PROVIDE_SERVICES||SHUT_DOWN_SPRINKLER_SYSTEM", + "SALVAGE_AND_OVERHAUL", + "SEARCH_STRUCTURE||DOOR_INITIATED_SEARCH", + "SEARCH_STRUCTURE||DOOR_INITIATED_SEARCH||DURING_SUPPRESSION", + "SEARCH_STRUCTURE||DOOR_INITIATED_SEARCH||POST_SUPPRESSION", + "SEARCH_STRUCTURE||DOOR_INITIATED_SEARCH||PRIOR_TO_SUPPRESSION", + "SEARCH_STRUCTURE||WINDOW_INITIATED_SEARCH", + "SEARCH_STRUCTURE||WINDOW_INITIATED_SEARCH||DURING_SUPPRESSION", + "SEARCH_STRUCTURE||WINDOW_INITIATED_SEARCH||POST_SUPPRESSION", + "SEARCH_STRUCTURE||WINDOW_INITIATED_SEARCH||PRIOR_TO_SUPPRESSION", + "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||BACKBURN", + "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||CONFINEMENT", + "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||ESTABLISH_FIRE_LINES", + "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||FIRE_CONTROL_EXTINGUISHMENT", + "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||FIRE_RETARDANT_DROP", + "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||STRUCTURE_PROTECTION", + "SUPPRESSION||OUTSIDE_FIRE_SUPPRESSION||WATER_DROP", + "SUPPRESSION||STRUCTURAL_FIRE_SUPPRESSION||EXTERIOR", + "SUPPRESSION||STRUCTURAL_FIRE_SUPPRESSION||EXTERIOR_AND_INTERIOR", + "SUPPRESSION||STRUCTURAL_FIRE_SUPPRESSION||INTERIOR", + "VENTILATION||HORIZONTAL", + "VENTILATION||HORIZONTAL||DURING_SUPPRESSION", + "VENTILATION||HORIZONTAL||POST_SUPPRESSION", + "VENTILATION||HORIZONTAL||PRIOR_TO_SUPPRESSION", + "VENTILATION||HYDRAULIC", + "VENTILATION||HYDRAULIC||DURING_SUPPRESSION", + "VENTILATION||HYDRAULIC||POST_SUPPRESSION", + "VENTILATION||HYDRAULIC||PRIOR_TO_SUPPRESSION", + "VENTILATION||POSITIVE_PRESSURE", + "VENTILATION||POSITIVE_PRESSURE||DURING_SUPPRESSION", + "VENTILATION||POSITIVE_PRESSURE||POST_SUPPRESSION", + "VENTILATION||POSITIVE_PRESSURE||PRIOR_TO_SUPPRESSION", + "VENTILATION||VERTICAL", + "VENTILATION||VERTICAL||DURING_SUPPRESSION", + "VENTILATION||VERTICAL||POST_SUPPRESSION", + "VENTILATION||VERTICAL||PRIOR_TO_SUPPRESSION" + ] + }, + "aid_direction": { + "schema": "TypeAidDirectionValue", + "codes": [ + "GIVEN", + "RECEIVED" + ] + }, + "aid_nonfd": { + "schema": "TypeAidNonfdValue", + "codes": [ + "ANIMAL_SERVICES", + "EMS", + "HOUSING_SERVICES", + "LAW_ENFORCEMENT", + "REMEDIATION_SERVICES", + "SOCIAL_SERVICES", + "UTILITIES_PUBLIC_WORKS" + ] + }, + "aid_type": { + "schema": "TypeAidValue", + "codes": [ + "ACTING_AS_AID", + "IN_LIEU_AID", + "SUPPORT_AID" + ] + }, + "alarm_failure": { + "schema": "TypeAlarmFailureValue", + "codes": [ + "DEVICE_MALFUNCTION", + "EXPIRED", + "IMPROPER_INSTALLATION", + "NO_BATTERY", + "OTHER_NON_FUNCTIONAL_CAUSE", + "TAMPER", + "UNABLE_TO_DETERMINE" + ] + }, + "alarm_fire": { + "schema": "TypeAlarmFireValue", + "codes": [ + "AUTOMATIC", + "MANUAL", + "MANUAL_AND_AUTOMATIC" + ] + }, + "alarm_operation": { + "schema": "TypeAlarmOperationValue", + "codes": [ + "FAILED_TO_OPERATE", + "INSUFFICIENT_SOURCE", + "NO_OCCUPANT_TO_NOTIFY", + "OPERATED_ALERTED_OCCUPANT", + "OPERATED_FAILED_TO_ALERT_OCCUPANT" + ] + }, + "alarm_other": { + "schema": "TypeAlarmOtherValue", + "codes": [ + "CARBON_MONOXIDE", + "HEAT_DETECTOR", + "NATURAL_GAS", + "OTHER_CHEMICAL_DETECTOR" + ] + }, + "alarm_smoke": { + "schema": "TypeAlarmSmokeValue", + "codes": [ + "BED_SHAKER", + "COMBINATION", + "HARDWIRED", + "HARD_OF_HEARING_WITH_STROBE", + "INTERCONNECTED", + "LONG_LIFE_BATTERY_POWERED", + "REPLACEABLE_BATTERY_POWERED", + "UNKNOWN" + ] + }, + "aspect": { + "schema": "TypeAspectValue", + "codes": [ + "EAST", + "NORTH", + "NORTH_EAST", + "NORTH_WEST", + "SOUTH", + "SOUTH_EAST", + "SOUTH_WEST", + "WEST" + ] + }, + "attached": { + "schema": "TypeAttachedValue", + "codes": [ + "CARPORT_LEANTO", + "FENCE", + "GARAGE", + "OTHER", + "PATIO_COVER_AWNING", + "PORCH_DECK" + ] + }, + "attached_material": { + "schema": "TypeAttachedMaterialValue", + "codes": [ + "COMBUSTIBLE", + "NON_COMBUSTIBLE", + "UNKNOWN" + ] + }, + "auto_body_style": { + "schema": "TypeAutoBodyStyleValue", + "codes": [ + "AMBULANCE", + "BUS", + "CONVERTIBLE", + "COUPE", + "FIRE_TRUCK", + "HARDTOP", + "HATCHBACK", + "HEARSE", + "LIMOUSINE", + "MINIVAN", + "MOTORIZED_HOME", + "OTHER", + "PICKUP", + "ROADSTER", + "SEDAN", + "STATION_WAGON", + "SUV", + "VAN" + ] + }, + "auto_make": { + "schema": "TypeAutoMakeValue", + "codes": [ + "ACURA", + "AGCO", + "ALFA_ROMEO", + "ALPINA", + "ALPINE", + "AMC", + "ARIEL", + "ASTON_MARTIN", + "AUDI", + "BENTLEY_MOTORS", + "BMW", + "BOLLINGER_MOTORS", + "BUGATTI", + "BUICK", + "BYD", + "CADILLAC", + "CANOO", + "CASE_IH", + "CATERHAM", + "CHEVROLET", + "CHRYSLER", + "CITROEN", + "CLAAS", + "CUPRA", + "CZINGER", + "DACIA", + "DAEWOO", + "DAF", + "DAIHATSU", + "DATSUN", + "DELOREAN_MOTOR_COMPANY", + "DEUTZ_FAHR", + "DODGE", + "DONKERVOORT", + "EAGLE", + "FARADAY_FUTURE", + "FENDT", + "FERRARI", + "FIAT", + "FISKER", + "FORD", + "FREIGHTLINER", + "GENESIS", + "GEO", + "GMC", + "HARLEY_DAVIDSON", + "HENNESSEY", + "HONDA", + "HUMMER", + "HYUNDAI", + "INEOS", + "INFINITI", + "INTERNATIONAL", + "ISUZU", + "IVECO", + "JAGUAR", + "JEEP", + "JOHN_DEERE", + "KARMA", + "KIA", + "KOENIGSEGG", + "KUBOTA", + "LADA", + "LAMBORGHINI", + "LANCIA", + "LAND_ROVER", + "LEXUS", + "LINCOLN", + "LORDSTOWN_MOTORS", + "LOTUS", + "LUCID_MOTORS", + "MAHINDRA_AND_MAHINDRA", + "MAN", + "MASERATI", + "MASSEY_FERGUSON", + "MAYBACH", + "MAZDA", + "MCLAREN", + "MERCEDES_AMG", + "MERCEDES_BENZ", + "MERCURY", + "MG", + "MINI", + "MITSUBISHI", + "MORGAN", + "MULLEN", + "NEW_HOLLAND", + "NISSAN", + "OLDSMOBILE", + "OPEL", + "OTHER", + "PAGANI", + "PEUGEOT", + "PLYMOUTH", + "POLARIS", + "POLESTAR", + "PONTIAC", + "PORSCHE", + "PROTON", + "RAM", + "RENAULT", + "RIMAC", + "RIVIAN", + "ROLLS_ROYCE", + "SAAB", + "SALEEN", + "SATURN", + "SCANIA", + "SCION", + "SEAT", + "SKODA", + "SMART", + "SPYKER", + "SRT", + "SSC_NORTH_AMERICA", + "SUBARU", + "SUZUKI", + "TESLA", + "TOYOTA", + "TVR", + "VAUXHALL", + "VINFAST", + "VOLKSWAGEN", + "VOLVO", + "YAMAHA", + "YUGO" + ] + }, + "battery_cell": { + "schema": "TypeBatteryCellValue", + "codes": [ + "BUTTON_COIN", + "CYLINDRICAL", + "OTHER", + "POUCH_POLYMER", + "PRISMATIC", + "UNKNOWN" + ] + }, + "battery_chemistry": { + "schema": "TypeBatteryChemistryValue", + "codes": [ + "ALKALINE", + "LEAD_ACID", + "LITHIUM_ION", + "LITHIUM_IRON_PHOSPHATE", + "LITHIUM_METAL", + "LITHIUM_SULPHUR", + "NICKEL_METAL_HYDRIDE", + "OTHER", + "SODIUM_ION", + "UNKNOWN" + ] + }, + "casualty_action": { + "schema": "TypeCasualtyActionValue", + "codes": [ + "ADVANCING_OPERATING_HOSELINE", + "CARRYING_SETTINGUP_EQUIPMENT", + "DURING_INCIDENT_RESPONSE", + "EMS_PATIENT_CARE", + "FORCIBLE_ENTRY", + "INCIDENT_COMMAND", + "OTHER", + "PUMP_OPERATIONS", + "SCENE_SAFETY_DIRECTING_TRAFFIC", + "SEARCH_RESCUE", + "STANDBY", + "VEHICLE_EXTRICATION", + "VENTILATION" + ] + }, + "casualty_cause": { + "schema": "TypeCasualtyCauseValue", + "codes": [ + "CAUGHT_TRAPPED_BY_FIRE_EXPLOSION", + "CAUGHT_TRAPPED_BY_OBJECT", + "COLLAPSE", + "EXPOSURE", + "FALL_JUMP", + "OTHER", + "STRESS_OVEREXERTION", + "STRUCK_CONTACT_WITH_OBJECT", + "VEHICLE_COLLISION" + ] + }, + "casualty_ppe": { + "schema": "TypeCasualtyPpeValue", + "codes": [ + "3_4_BOOTS", + "BRUSH_GEAR", + "BUNKER_PANTS", + "FACE_SHIELD_GOGGLES", + "GLOVES", + "HELMET", + "NONE", + "OTHER_SPECIAL_EQUIPMENT", + "PASS_DEVICE", + "PROTECTIVE_HOOD", + "REFLECTIVE_VEST", + "RUBBER_KNEE_BOOTS", + "SCBA", + "TURNOUT_COAT" + ] + }, + "casualty_timeline": { + "schema": "TypeCasualtyTimelineValue", + "codes": [ + "AFTER_CONCLUSION_OF_INCIDENT", + "CONTINUING_OPERATIONS", + "EXTENDED_OPERATIONS", + "INITIAL_RESPONSE", + "RESPONDING", + "UNKNOWN" + ] + }, + "construction": { + "schema": "TypeConstructionValue", + "codes": [ + "TYPE_IA", + "TYPE_IB", + "TYPE_IIA", + "TYPE_IIB", + "TYPE_IIIA", + "TYPE_IIIB", + "TYPE_IV", + "TYPE_VA", + "TYPE_VB", + "UNKNOWN" + ] + }, + "consumer_product": { + "schema": "TypeConsumerProductValue", + "codes": [ + "ALARM_ESCAPE_PROTECTION_DEVICES", + "CHILD_NURSERY_EQUIPMENT", + "FARM_SUPPLIES_EQUIPMENT", + "FURNISHINGS_FIXTURES", + "GENERAL_HOUSEHOLD_APPLIANCE", + "HEATING_COOLING_VENT_APPLIANCE", + "HOME_COMM_ENTERTAINMENT_HOBBY_EQUIPMENT", + "HOME_FAMILY_MAINTENANCE_PRODUCT", + "HOUSEHOLD_PACKAGING_CONTAINER", + "HOUSEWARE_NON_POWERED", + "KITCHEN_APPLIANCE", + "MISC_PRODUCT", + "PERSONAL_USE_ITEM", + "REGULATED_PRODUCTS", + "SPORTS_RECREATION", + "STRUCTURES_CONSTRUCTION", + "TOYS", + "TOYS_MOBILITY_OTHER", + "WORKSHOP_TOOL_APPARATUS", + "YARD_GARDEN_EQUIPMENT" + ] + }, + "contributing_hazards": { + "schema": "TypeContributingHazardsValue", + "codes": [ + "CIRCUITS_TRIPPED", + "CO_DETECTED", + "GAS_FUEL_ON", + "OTHER" + ] + }, + "country": { + "schema": "TypeLocCspCountryValue", + "codes": [ + "AD", + "AE", + "AF", + "AG", + "AI", + "AL", + "AM", + "AO", + "AQ", + "AR", + "AS", + "AT", + "AU", + "AW", + "AX", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BL", + "BM", + "BN", + "BO", + "BQ", + "BR", + "BS", + "BT", + "BV", + "BW", + "BY", + "BZ", + "CA", + "CC", + "CD", + "CF", + "CG", + "CH", + "CI", + "CK", + "CL", + "CM", + "CN", + "CO", + "CR", + "CU", + "CV", + "CW", + "CX", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "EH", + "ER", + "ES", + "ET", + "FI", + "FJ", + "FK", + "FM", + "FO", + "FR", + "GA", + "GB", + "GD", + "GE", + "GF", + "GG", + "GH", + "GI", + "GL", + "GM", + "GN", + "GP", + "GQ", + "GR", + "GS", + "GT", + "GU", + "GW", + "GY", + "HK", + "HM", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IM", + "IN", + "IO", + "IQ", + "IR", + "IS", + "IT", + "JE", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KP", + "KR", + "KW", + "KY", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MF", + "MG", + "MH", + "MK", + "ML", + "MM", + "MN", + "MO", + "MP", + "MQ", + "MR", + "MS", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NC", + "NE", + "NF", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NU", + "NZ", + "OM", + "PA", + "PE", + "PF", + "PG", + "PH", + "PK", + "PL", + "PM", + "PN", + "PR", + "PS", + "PT", + "PW", + "PY", + "QA", + "RE", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SD", + "SE", + "SG", + "SH", + "SI", + "SJ", + "SK", + "SL", + "SM", + "SN", + "SO", + "SR", + "SS", + "ST", + "SV", + "SX", + "SY", + "SZ", + "TC", + "TD", + "TF", + "TG", + "TH", + "TJ", + "TK", + "TL", + "TM", + "TN", + "TO", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "UM", + "US", + "UY", + "UZ", + "VA", + "VC", + "VE", + "VG", + "VI", + "VN", + "VU", + "WF", + "WS", + "YE", + "YT", + "ZA", + "ZM", + "ZW" + ] + }, + "cross_street_modifier": { + "schema": "TypeLocationCrossStreetValue", + "codes": [ + "CLOSEST", + "INCIDENT_IN_INTERSECTION", + "SECOND_CLOSEST" + ] + }, + "deck_porch_grade": { + "schema": "TypeDeckPorchGradeValue", + "codes": [ + "BELOW_GRADE_SUNKEN", + "ELEVATED", + "ON_GRADE" + ] + }, + "dins_origin_cause": { + "schema": "TypeDinsOriginCauseValue", + "codes": [ + "DIRECT_FLAME_IMPINGEMENT", + "EMBERS", + "OTHER", + "RADIANT_HEAT", + "UNKNOWN" + ] + }, + "dins_origin_location": { + "schema": "TypeDinsOriginLocationValue", + "codes": [ + "ATTACHED_FENCE", + "ATTACHED_PATIO_COVER_CARPORT", + "DECK_ELEVATED", + "DECK_ON_GRADE", + "EAVES", + "OTHER", + "ROOF", + "SIDING", + "UNKNOWN", + "VENT", + "WINDOW" + ] + }, + "dispatch_protocol_fire": { + "schema": "TypeDispProtoFireValue", + "codes": [ + "APCO", + "IAED", + "OTHER", + "PROQA" + ] + }, + "dispatch_protocol_medical": { + "schema": "TypeDispProtoMedValue", + "codes": [ + "APCO", + "IAED", + "OTHER", + "PROQA" + ] + }, + "displace_cause": { + "schema": "TypeDisplaceCauseValue", + "codes": [ + "COLLAPSE", + "FIRE", + "HAZARDOUS_SITUATION", + "OTHER", + "SMOKE", + "UTILITIES", + "WATER" + ] + }, + "distance_unit": { + "schema": "TypeDistanceUnitValue", + "codes": [ + "FEET", + "MILES" + ] + }, + "duty": { + "schema": "TypeDutyValue", + "codes": [ + "AFTER_INCIDENT", + "OTHER_ON_DUTY_INCIDENT", + "RESPONDING_TO_EMERGENCY_INCIDENT", + "RETURNING_FROM_EMERGENCY_INCIDENT", + "TRAINING", + "WORKING_AT_SCENE_OF_FIRE_INCIDENT", + "WORKING_AT_SCENE_OF_NONFIRE_INCIDENT" + ] + }, + "eaves": { + "schema": "TypeEavesValue", + "codes": [ + "ENCLOSED", + "NO_EAVES", + "UNENCLOSED", + "UNKNOWN" + ] + }, + "emerghaz_elec": { + "schema": "TypeEmerghazElecValue", + "codes": [ + "CONSUMER_PRODUCTS||APPLIANCE_TOOL", + "CONSUMER_PRODUCTS||CELL_PHONE", + "CONSUMER_PRODUCTS||COMPUTER_TABLET", + "CONSUMER_PRODUCTS||ELECTRONIC_CIGARETTE", + "CONSUMER_PRODUCTS||OTHER", + "CONSUMER_PRODUCTS||POWER_BANK", + "CONSUMER_PRODUCTS||TOY", + "ELECTRIC_VEHICLE||BOAT_COMMERCIAL||FUEL_CELL", + "ELECTRIC_VEHICLE||BOAT_COMMERCIAL||FULL_ELECTRIC", + "ELECTRIC_VEHICLE||BOAT_COMMERCIAL||HYBRID", + "ELECTRIC_VEHICLE||BOAT_COMMERCIAL||PLUG_IN_HYBRID", + "ELECTRIC_VEHICLE||BOAT_RECREATIONAL||FUEL_CELL", + "ELECTRIC_VEHICLE||BOAT_RECREATIONAL||FULL_ELECTRIC", + "ELECTRIC_VEHICLE||BOAT_RECREATIONAL||HYBRID", + "ELECTRIC_VEHICLE||BOAT_RECREATIONAL||PLUG_IN_HYBRID", + "ELECTRIC_VEHICLE||BUS_RR||FUEL_CELL", + "ELECTRIC_VEHICLE||BUS_RR||FULL_ELECTRIC", + "ELECTRIC_VEHICLE||BUS_RR||HYBRID", + "ELECTRIC_VEHICLE||BUS_RR||PLUG_IN_HYBRID", + "ELECTRIC_VEHICLE||CAR_RR||FUEL_CELL", + "ELECTRIC_VEHICLE||CAR_RR||FULL_ELECTRIC", + "ELECTRIC_VEHICLE||CAR_RR||HYBRID", + "ELECTRIC_VEHICLE||CAR_RR||PLUG_IN_HYBRID", + "ELECTRIC_VEHICLE||LIGHT_EV_NRR||FUEL_CELL", + "ELECTRIC_VEHICLE||LIGHT_EV_NRR||FULL_ELECTRIC", + "ELECTRIC_VEHICLE||LIGHT_EV_NRR||HYBRID", + "ELECTRIC_VEHICLE||LIGHT_EV_NRR||PLUG_IN_HYBRID", + "ELECTRIC_VEHICLE||OTHER||FUEL_CELL", + "ELECTRIC_VEHICLE||OTHER||FULL_ELECTRIC", + "ELECTRIC_VEHICLE||OTHER||HYBRID", + "ELECTRIC_VEHICLE||OTHER||PLUG_IN_HYBRID", + "ELECTRIC_VEHICLE||TRUCK_COMMERCIAL_RR||FUEL_CELL", + "ELECTRIC_VEHICLE||TRUCK_COMMERCIAL_RR||FULL_ELECTRIC", + "ELECTRIC_VEHICLE||TRUCK_COMMERCIAL_RR||HYBRID", + "ELECTRIC_VEHICLE||TRUCK_COMMERCIAL_RR||PLUG_IN_HYBRID", + "ELECTRIC_VEHICLE||TRUCK_PASSENGER_RR||FUEL_CELL", + "ELECTRIC_VEHICLE||TRUCK_PASSENGER_RR||FULL_ELECTRIC", + "ELECTRIC_VEHICLE||TRUCK_PASSENGER_RR||HYBRID", + "ELECTRIC_VEHICLE||TRUCK_PASSENGER_RR||PLUG_IN_HYBRID", + "ENERGY_STORAGE_SYSTEM||BATTERY", + "ENERGY_STORAGE_SYSTEM||COMPRESSED_AIR", + "ENERGY_STORAGE_SYSTEM||FLYWHEEL", + "ENERGY_STORAGE_SYSTEM||HYDROELECTRIC", + "ENERGY_STORAGE_SYSTEM||OTHER", + "E_MOBILITY||ELECTRIC_SCOOTER_MOPED", + "E_MOBILITY||OTHER", + "E_MOBILITY||PERSONAL_MOBILITY_ASSIST", + "E_MOBILITY||POWER_ASSISTED_BICYCLE" + ] + }, + "emerghaz_pv": { + "schema": "TypeEmerghazPvValue", + "codes": [ + "OTHER", + "PANEL_POWER_GENERATION", + "PANEL_WATER_HEATING", + "THIN_FILM_POWER_GENERATION", + "TILE_POWER_GENERATION" + ] + }, + "emerghaz_pv_ign": { + "schema": "TypeEmerghazPvIgnValue", + "codes": [ + "SOURCE", + "TARGET" + ] + }, + "emerghaz_suppression": { + "schema": "TypeEmerghazSuppressionValue", + "codes": [ + "BATTERY_PENETRATION", + "FIRE_BLANKET", + "RUN_COURSE", + "SUBMERGE_BURY", + "SUPPRESSION_WATER_ADDITIVE", + "SUPPRESSION_WATER_ONLY" + ] + }, + "exposure_damage": { + "schema": "TypeExposureDamageValue", + "codes": [ + "MAJOR_DAMAGE", + "MINOR_DAMAGE", + "MODERATE_DAMAGE", + "NO_DAMAGE" + ] + }, + "exposure_item": { + "schema": "TypeExposureItemValue", + "codes": [ + "OBJECT_OTHER", + "OUTDOOR_ENVIRONMENT", + "STRUCTURE", + "VEHICLE" + ] + }, + "exterior_finish": { + "schema": "TypeExteriorFinishValue", + "codes": [ + "ASHPHALT", + "BRICK_STONE", + "CONCRETE", + "EIFS", + "FIBER_CEMENT", + "METAL", + "STUCCO", + "VINYL", + "WOOD" + ] + }, + "fire_bldg_damage": { + "schema": "TypeFireBldgDamageValue", + "codes": [ + "MAJOR_DAMAGE", + "MINOR_DAMAGE", + "MODERATE_DAMAGE", + "NO_DAMAGE" + ] + }, + "fire_cause_general": { + "schema": "TypeGeneralFireCauseValue", + "codes": [ + "ACCIDENTAL", + "INCENDIARY", + "NATURAL", + "UNDETERMINED" + ] + }, + "fire_cause_indoor": { + "schema": "TypeFireCauseInValue", + "codes": [ + "ACT_OF_NATURE", + "BATTERY_POWER_STORAGE", + "CHEMICAL", + "COOKING", + "ELECTRICAL", + "EXPLOSIVES_FIREWORKS", + "HEAT_FROM_ANOTHER_OBJECT", + "INCENDIARY", + "OPEN_FLAME", + "OPERATING_EQUIPMENT", + "OTHER_HEAT_SOURCE", + "SMOKING_MATERIALS_ILLICIT_DRUGS", + "UNABLE_TO_BE_DETERMINED" + ] + }, + "fire_cause_outdoor": { + "schema": "TypeFireCauseOutValue", + "codes": [ + "BATTERY_POWER_STORAGE", + "DEBRIS_OPEN_BURNING", + "EQUIPMENT_VEHICLE_USE", + "FIREARMS_EXPLOSIVES", + "FIREWORKS", + "INCENDIARY", + "NATURAL", + "POWER_GEN_TRANS_DIST", + "RAILROAD_OPS_MAINTENANCE", + "RECREATION_CEREMONY", + "SMOKING_MATERIALS_ILLICIT_DRUGS", + "SPREAD_FROM_CONTROLLED_BURN", + "STRUCTURE", + "UNABLE_TO_BE_DETERMINED" + ] + }, + "fire_condition_arrival": { + "schema": "TypeFireConditionArrivalValue", + "codes": [ + "FIRE_OUT_UPON_ARRIVAL", + "FIRE_SPREAD_BEYOND_STRUCTURE", + "NO_SMOKE_FIRE_SHOWING", + "SMOKE_FIRE_SHOWING", + "SMOKE_SHOWING", + "STRUCTURE_INVOLVED" + ] + }, + "fire_invest": { + "schema": "TypeFireInvestValue", + "codes": [ + "INVESTIGATED_BY_ARSON_FIRE_INVESTIGATOR", + "INVESTIGATED_BY_INSURANCE", + "INVESTIGATED_BY_NONFIRE_LAW_ENFORCEMENT", + "INVESTIGATED_BY_OTHER", + "INVESTIGATED_BY_OUTSIDE_AGENCY", + "INVESTIGATED_BY_STATE_FIRE_MARSHAL", + "INVESTIGATED_ON_SCENE_RESOURCE", + "NONE" + ] + }, + "fire_invest_need": { + "schema": "TypeFireInvestNeedValue", + "codes": [ + "NO", + "NOT_APPLICABLE", + "NOT_EVALUATED", + "NO_CAUSE_OBVIOUS", + "OTHER", + "YES" + ] + }, + "fire_spread": { + "schema": "TypeFireSpreadValue", + "codes": [ + "BEYOND_BUILDING", + "BUILDING", + "FLOOR", + "OBJECT", + "ROOM" + ] + }, + "foundation": { + "schema": "TypeFoundationValue", + "codes": [ + "CONCRETE_PANELS", + "CRAWL_SPACE", + "FULL_BASEMENT", + "INSULATED_CONCRETE", + "PIER_AND_BEAM_PILE", + "POURED_CONCRETE_SLAB", + "SLAB_ON_GRADE", + "STONE", + "WOOD" + ] + }, + "fuel_arrangement": { + "schema": "TypeFuelArrangementValue", + "codes": [ + "CROWN_FUELS", + "GROUND_FUELS", + "SURFACE_FUELS" + ] + }, + "fuel_distribution": { + "schema": "TypeFuelDistributionValue", + "codes": [ + "CONTINUOUS_HORIZONTAL", + "LADDER_VERTICAL" + ] + }, + "fuel_size": { + "schema": "TypeFuelSizeValue", + "codes": [ + "025_TO_1", + "1_TO_3", + "3_TO_8", + "GREATER_THAN_8", + "LESS_THAN_025" + ] + }, + "full_partial": { + "schema": "TypeFullPartialValue", + "codes": [ + "EXTENT_UNKNOWN", + "FULL", + "PARTIAL" + ] + }, + "gender": { + "schema": "TypeGenderValue", + "codes": [ + "FEMALE", + "MALE", + "OTHER_GENDER_IDENTITY", + "TRANSGENDER_FEMALE_MALE_TO_FEMALE", + "TRANSGENDER_MALE_FEMALE_TO_MALE", + "UNKNOWN" + ] + }, + "hazard": { + "schema": "TypeHazardValue", + "codes": [ + "ENVIRONMENTAL_DAMAGE", + "EXPLOSION", + "FIRE", + "LEAK", + "MATERIAL_ENTERED_WATERWAY", + "SOLID_DISPERSION", + "SPILL", + "VAPOR_GAS_DISPERSION" + ] + }, + "hazard_cause": { + "schema": "TypeHazardCauseValue", + "codes": [ + "ACT_OF_NATURE", + "CAUSE_UNDER_INVESTIGATION", + "CONTAINER_CONTAINMENT_FAILURE", + "INTENTIONAL", + "UNINTENTIONAL" + ] + }, + "hazard_disposition": { + "schema": "TypeHazardDispositionValue", + "codes": [ + "COMPLETED_FIRE_SERVICE_ONLY", + "COMPLETED_WITH_FIRE_SERVICE_PRESENT", + "RELEASED_TO_COUNTY_AGENCY", + "RELEASED_TO_FEDERAL_AGENCY", + "RELEASED_TO_LOCAL_AGENCY", + "RELEASED_TO_PRIVATE_AGENCY", + "RELEASED_TO_PROPERTY_OWNER", + "RELEASED_TO_STATE_AGENCY" + ] + }, + "hazard_dot": { + "schema": "TypeHazardDotValue", + "codes": [ + "CORROSIVES", + "EXPLOSIVES", + "FLAMMABLE_LIQUIDS", + "FLAMMABLE_SOLIDS", + "GASES", + "MISCELLANEOUS_DANGEROUS_SUBSTANCES", + "OXIDIZERS", + "POISONS_AND_ETIOLOGIC_MATERIALS", + "RADIOACTIVE_MATERIALS" + ] + }, + "hazard_physical_state": { + "schema": "TypeHazardPhysicalStateValue", + "codes": [ + "GAS", + "LIQUID", + "RADIOACTIVE", + "SOLID", + "UNKNOWN" + ] + }, + "hazard_released_into": { + "schema": "TypeHazardReleasedIntoValue", + "codes": [ + "AIR", + "GROUND", + "WATER" + ] + }, + "hazard_unit": { + "schema": "TypeHazardUnitValue", + "codes": [ + "GAS_CUBIC_CENTIMETER", + "GAS_CUBIC_FOOT", + "GAS_CUBIC_INCH", + "GAS_CUBIC_METER", + "GAS_POUND", + "LIQUID_CUBIC_CENTIMETER", + "LIQUID_CUP", + "LIQUID_GALLON", + "LIQUID_GRAM", + "LIQUID_KILOGRAM", + "LIQUID_LITER", + "LIQUID_MILLIGRAM", + "LIQUID_MILLILITER", + "LIQUID_OUNCE", + "LIQUID_PINT", + "LIQUID_POUND", + "LIQUID_QUART", + "LIQUID_TABLESPOON", + "LIQUID_TEASPOON", + "RADIOACTIVE_CURIE", + "RADIOACTIVE_MEGABECQUEREL", + "RADIOACTIVE_MICROCURIE", + "RADIOACTIVE_MILLICURIE", + "RADIOACTIVE_TERABECQUEREL", + "SOLID_GRAM", + "SOLID_KILOGRAM", + "SOLID_LITER", + "SOLID_MILLIGRAM", + "SOLID_OUNCE", + "SOLID_POUND", + "SOLID_TABLESPOON", + "SOLID_TEASPOON", + "SOLID_TON" + ] + }, + "hazsit_release_factors": { + "schema": "TypeHazsitReleaseFactorsValue", + "codes": [ + "DESIGN_CONSTRUCTION_INSTALLATION_DEFICIENCY||CONSTRUCTION_DEFICIENCY", + "DESIGN_CONSTRUCTION_INSTALLATION_DEFICIENCY||DESIGN_DEFICIENCY", + "DESIGN_CONSTRUCTION_INSTALLATION_DEFICIENCY||INSTALLATION_DEFICIENCY", + "FAILURE_TO_CONTROL_HAZARDOUS_MATERIAL||ABANDONED_OR_DISCARDED", + "FAILURE_TO_CONTROL_HAZARDOUS_MATERIAL||IMPROPER_ENVIRONMENT_STOAGE", + "FAILURE_TO_CONTROL_HAZARDOUS_MATERIAL||IMPROPER_TRANSFER", + "MECHANICAL_FAILURE_MALFUNCTION||AUTOMATIC_CONTROL_FAILURE", + "MECHANICAL_FAILURE_MALFUNCTION||ELECTRICAL_FAILURE", + "MECHANICAL_FAILURE_MALFUNCTION||LACK_OF_MAINTENANCE", + "MECHANICAL_FAILURE_MALFUNCTION||MANUAL_CONTROL_FAILURE", + "MISUSE_OF_HAZARDOUS_MATERIALS||IMPROPER_CONTAINER", + "MISUSE_OF_HAZARDOUS_MATERIALS||IMPROPER_MIXING", + "MISUSE_OF_HAZARDOUS_MATERIALS||IMPROPER_USE", + "NATURAL_CONDITION||EARTHQUAKE", + "NATURAL_CONDITION||FLOOD", + "NATURAL_CONDITION||HIGH_TEMPERATURE", + "NATURAL_CONDITION||HIGH_WIND", + "NATURAL_CONDITION||LIGHTNING", + "NATURAL_CONDITION||LOWER_TEMPERATURE", + "OPERATIONAL_DEFICIENCY||ACCIDENTAL_IMPROPER_POWER_ON", + "OPERATIONAL_DEFICIENCY||ACCIDENTAL_IMPROPER_SHUTDOWN", + "OPERATIONAL_DEFICIENCY||COLLISION_OVERTURN_KNOCKDOWN", + "OPERATIONAL_DEFICIENCY||EQUIPMENT_UNATTENDED" + ] + }, + "human_factors": { + "schema": "TypeHumanFactorsValue", + "codes": [ + "ALCOHOL_USE", + "ASLEEP", + "CULTURAL_RELIGIOUS_BEHAVIOR", + "ELDERLY_AGING", + "HOARDING_DISORDER", + "HOMELESSNESS", + "ILLICIT_DRUG_USE", + "INTELLECTUAL_DISABILITY", + "JUVENILE_BEHAVIOR", + "MEDICAL_CONDITION", + "MEDICAL_OXYGEN", + "MENTAL_HEALTH", + "NONE", + "OTHER", + "PHYSICAL_DISABILITY" + ] + }, + "incident_complexity": { + "schema": "TypeIncidentComplexityValue", + "codes": [ + "ICS_TYPE_1", + "ICS_TYPE_2", + "ICS_TYPE_3", + "ICS_TYPE_4", + "ICS_TYPE_5" + ] + }, + "incident_status": { + "schema": "TypeIncidentStatusValue", + "codes": [ + "APPROVED", + "DELETED", + "FAILED", + "PENDING_APPROVAL", + "PENDING_INCIDENT_DATA", + "REJECTED", + "SUBMITTED" + ] + }, + "incident_status_payload": { + "schema": "TypeIncidentStatusPayloadValue", + "codes": [ + "APPROVED", + "REJECTED" + ] + }, + "incident_type": { + "schema": "TypeIncidentValue", + "codes": [ + "FIRE||OUTSIDE_FIRE||CONSTRUCTION_WASTE", + "FIRE||OUTSIDE_FIRE||DUMPSTER_OUTDOOR_CONTAINER_FIRE", + "FIRE||OUTSIDE_FIRE||OTHER_OUTSIDE_FIRE", + "FIRE||OUTSIDE_FIRE||OUTSIDE_TANK_FIRE", + "FIRE||OUTSIDE_FIRE||TRASH_RUBBISH_FIRE", + "FIRE||OUTSIDE_FIRE||UTILITY_INFRASTRUCTURE_FIRE", + "FIRE||OUTSIDE_FIRE||VEGETATION_GRASS_FIRE", + "FIRE||OUTSIDE_FIRE||WILDFIRE_URBAN_INTERFACE", + "FIRE||OUTSIDE_FIRE||WILDFIRE_WILDLAND", + "FIRE||SPECIAL_FIRE||ESS_FIRE", + "FIRE||SPECIAL_FIRE||EXPLOSION", + "FIRE||SPECIAL_FIRE||INFRASTRUCTURE_FIRE", + "FIRE||STRUCTURE_FIRE||CHIMNEY_FIRE", + "FIRE||STRUCTURE_FIRE||CONFINED_COOKING_APPLIANCE_FIRE", + "FIRE||STRUCTURE_FIRE||ROOM_AND_CONTENTS_FIRE", + "FIRE||STRUCTURE_FIRE||STRUCTURAL_INVOLVEMENT_FIRE", + "FIRE||TRANSPORTATION_FIRE||AIRCRAFT_FIRE", + "FIRE||TRANSPORTATION_FIRE||BOAT_PERSONAL_WATERCRAFT_BARGE_FIRE", + "FIRE||TRANSPORTATION_FIRE||POWERED_MOBILITY_DEVICE_FIRE", + "FIRE||TRANSPORTATION_FIRE||TRAIN_RAIL_FIRE", + "FIRE||TRANSPORTATION_FIRE||VEHICLE_FIRE_COMMERCIAL", + "FIRE||TRANSPORTATION_FIRE||VEHICLE_FIRE_FOOD_TRUCK", + "FIRE||TRANSPORTATION_FIRE||VEHICLE_FIRE_PASSENGER", + "FIRE||TRANSPORTATION_FIRE||VEHICLE_FIRE_RV", + "HAZSIT||HAZARDOUS_MATERIALS||BIOLOGICAL_RELEASE_INCIDENT", + "HAZSIT||HAZARDOUS_MATERIALS||CARBON_MONOXIDE_RELEASE", + "HAZSIT||HAZARDOUS_MATERIALS||FUEL_SPILL_ODOR", + "HAZSIT||HAZARDOUS_MATERIALS||GAS_LEAK_ODOR", + "HAZSIT||HAZARDOUS_MATERIALS||HAZMAT_RELEASE_FACILITY", + "HAZSIT||HAZARDOUS_MATERIALS||HAZMAT_RELEASE_TRANSPORT", + "HAZSIT||HAZARDOUS_MATERIALS||RADIOACTIVE_RELEASE_INCIDENT", + "HAZSIT||HAZARD_NONCHEM||BOMB_THREAT_RESPONSE_SUSPICIOUS_PACKAGE", + "HAZSIT||HAZARD_NONCHEM||ELEC_HAZARD_SHORT_CIRCUIT", + "HAZSIT||HAZARD_NONCHEM||ELEC_POWER_LINE_DOWN_ARCHING_MALFUNC", + "HAZSIT||HAZARD_NONCHEM||MOTOR_VEHICLE_COLLISION", + "HAZSIT||INVESTIGATION||ODOR", + "HAZSIT||INVESTIGATION||SMOKE_INVESTIGATION", + "HAZSIT||OVERPRESSURE||NO_RUPTURE", + "HAZSIT||OVERPRESSURE||RUPTURE_WITHOUT_FIRE", + "LAWENFORCE", + "MEDICAL||ILLNESS", + "MEDICAL||ILLNESS||ABDOMINAL_PAIN", + "MEDICAL||ILLNESS||ALLERGIC_REACTION_STINGS", + "MEDICAL||ILLNESS||ALTERED_MENTAL_STATUS", + "MEDICAL||ILLNESS||BACK_PAIN_NON_TRAUMA", + "MEDICAL||ILLNESS||BREATHING_PROBLEMS", + "MEDICAL||ILLNESS||CARDIAC_ARREST", + "MEDICAL||ILLNESS||CHEST_PAIN_NON_TRAUMA", + "MEDICAL||ILLNESS||CONVULSIONS_SEIZURES", + "MEDICAL||ILLNESS||DIABETIC_PROBLEMS", + "MEDICAL||ILLNESS||HEADACHE", + "MEDICAL||ILLNESS||HEART_PROBLEMS", + "MEDICAL||ILLNESS||NAUSEA_VOMITING", + "MEDICAL||ILLNESS||NO_APPROPRIATE_CHOICE", + "MEDICAL||ILLNESS||OVERDOSE", + "MEDICAL||ILLNESS||PANDEMIC_EPIDEMIC_OUTBREAK", + "MEDICAL||ILLNESS||PREGNANCY_CHILDBIRTH", + "MEDICAL||ILLNESS||PSYCHOLOGICAL_BEHAVIOR_ISSUES", + "MEDICAL||ILLNESS||SICK_CASE", + "MEDICAL||ILLNESS||STROKE_CVA", + "MEDICAL||ILLNESS||UNCONSCIOUS_VICTIM", + "MEDICAL||ILLNESS||UNKNOWN_PROBLEM", + "MEDICAL||ILLNESS||WELL_PERSON_CHECK", + "MEDICAL||INJURY", + "MEDICAL||INJURY||ANIMAL_BITES", + "MEDICAL||INJURY||ASSAULT", + "MEDICAL||INJURY||BURNS_EXPLOSION", + "MEDICAL||INJURY||CARBON_MONOXIDE_OTHER_INHALATION_INJURY", + "MEDICAL||INJURY||CHOKING", + "MEDICAL||INJURY||DROWNING_DIVING_SCUBA_ACCIDENT", + "MEDICAL||INJURY||ELECTROCUTION", + "MEDICAL||INJURY||EYE_TRAUMA", + "MEDICAL||INJURY||FALL", + "MEDICAL||INJURY||GUNSHOT_WOUND", + "MEDICAL||INJURY||HEAT_COLD_EXPOSURE", + "MEDICAL||INJURY||HEMORRHAGE_LACERATION", + "MEDICAL||INJURY||INDUSTRIAL_INACCESSIBLE_ENTRAPMENT", + "MEDICAL||INJURY||MOTOR_VEHICLE_COLLISION", + "MEDICAL||INJURY||OTHER_TRAUMATIC_INJURY", + "MEDICAL||INJURY||POISONING", + "MEDICAL||INJURY||STAB_PENETRATING_TRAUMA", + "MEDICAL||OTHER||AIRMEDICAL_TRANSPORT", + "MEDICAL||OTHER||COMMUNITY_PUBLIC_HEALTH", + "MEDICAL||OTHER||HEALTHCARE_PROFESSIONAL_ADMISSION", + "MEDICAL||OTHER||INTERCEPT_OTHER_UNIT", + "MEDICAL||OTHER||MEDICAL_ALARM", + "MEDICAL||OTHER||STANDBY_REQUEST", + "MEDICAL||OTHER||TRANSFER_INTERFACILITY", + "NOEMERG||CANCELLED", + "NOEMERG||FALSE_ALARM||ACCIDENTAL_ALARM", + "NOEMERG||FALSE_ALARM||BOMB_SCARE", + "NOEMERG||FALSE_ALARM||INTENTIONAL_FALSE_ALARM", + "NOEMERG||FALSE_ALARM||MALFUNCTIONING_ALARM", + "NOEMERG||FALSE_ALARM||OTHER_FALSE_CALL", + "NOEMERG||GOOD_INTENT||CONTROLLED_BURNING_AUTHORIZED", + "NOEMERG||GOOD_INTENT||INVESTIGATE_HAZARDOUS_RELEASE", + "NOEMERG||GOOD_INTENT||NO_INCIDENT_FOUND_LOCATION_ERROR", + "NOEMERG||GOOD_INTENT||SMOKE_FROM_NONHOSTILE_SOURCE", + "PUBSERV||ALARMS_NONMED||CO_ALARM", + "PUBSERV||ALARMS_NONMED||FIRE_ALARM", + "PUBSERV||ALARMS_NONMED||GAS_ALARM", + "PUBSERV||ALARMS_NONMED||OTHER_ALARM", + "PUBSERV||CITIZEN_ASSIST||CITIZEN_ASSIST_SERVICE_CALL", + "PUBSERV||CITIZEN_ASSIST||LIFT_ASSIST", + "PUBSERV||CITIZEN_ASSIST||LOST_PERSON", + "PUBSERV||CITIZEN_ASSIST||PERSON_IN_DISTRESS", + "PUBSERV||DISASTER_WEATHER||DAMAGE_ASSESSMENT", + "PUBSERV||DISASTER_WEATHER||WEATHER_RESPONSE", + "PUBSERV||OTHER||DAMAGED_HYDRANT", + "PUBSERV||OTHER||MOVE_UP", + "PUBSERV||OTHER||STANDBY", + "RESCUE||OUTSIDE||BACKCOUNTRY_RESCUE", + "RESCUE||OUTSIDE||CONFINED_SPACE_RESCUE", + "RESCUE||OUTSIDE||EXTRICATION_ENTRAPPED", + "RESCUE||OUTSIDE||HIGH_ANGLE_RESCUE", + "RESCUE||OUTSIDE||LIMITED_NO_ACCESS", + "RESCUE||OUTSIDE||LOW_ANGLE_RESCUE", + "RESCUE||OUTSIDE||STEEP_ANGLE_RESCUE", + "RESCUE||OUTSIDE||TRENCH", + "RESCUE||STRUCTURE||BUILDING_STRUCTURE_COLLAPSE", + "RESCUE||STRUCTURE||CONFINED_SPACE_RESCUE", + "RESCUE||STRUCTURE||ELEVATOR_ESCALATOR_RESCUE", + "RESCUE||STRUCTURE||EXTRICATION_ENTRAPPED", + "RESCUE||TRANSPORTATION||AVIATION_COLLISION_CRASH", + "RESCUE||TRANSPORTATION||AVIATION_STANDBY", + "RESCUE||TRANSPORTATION||MOTOR_VEHICLE_EXTRICATION_ENTRAPPED", + "RESCUE||TRANSPORTATION||TRAIN_RAIL_COLLISION_DERAILMENT", + "RESCUE||WATER||PERSON_IN_WATER_STANDING", + "RESCUE||WATER||PERSON_IN_WATER_SWIFTWATER", + "RESCUE||WATER||WATERCRAFT_IN_DISTRESS" + ] + }, + "indoor_cause": { + "schema": "TypeIndoorCauseValue", + "codes": [ + "ACT_OF_NATURE||GLASS_REFRACTION_MAGNIFYING_GLASS", + "ACT_OF_NATURE||LIGHTNING", + "ACT_OF_NATURE||OTHER", + "ACT_OF_NATURE||SPONTANEOUS_COMBUSTION", + "ACT_OF_NATURE||UNKNOWN", + "ACT_OF_NATURE||VOLCANO", + "BATTERY_POWER_STORAGE||BATTERY_CONSUMER_PRODUCT", + "BATTERY_POWER_STORAGE||ENERGY_STORAGE_SYSTEM", + "CHEMICAL||CHEMICAL_LABORATORY", + "COOKING||DRY_COOKING", + "COOKING||KITCHEN_APPLIANCE_OVEN", + "COOKING||OIL_GREASE", + "ELECTRICAL||ELECTRICAL_EQUIPMENT", + "ELECTRICAL||ELECTRICAL_TRANSMISSION_DISTRIBUTION_SYSTEMS", + "ELECTRICAL||ELECTRIC_FENCE", + "ELECTRICAL||OTHER", + "ELECTRICAL||SOLAR_UTILITY_SYSTEM", + "ELECTRICAL||UNKNOWN", + "EXPLOSIVES_FIREWORKS||AMMONIUM_NITRATE_FUEL_OIL", + "EXPLOSIVES_FIREWORKS||BLACK_POWDER_MUZZLE_LOADING", + "EXPLOSIVES_FIREWORKS||BLASTING", + "EXPLOSIVES_FIREWORKS||CONSUMER_AERIAL||COMETS_MINES_SHELLS", + "EXPLOSIVES_FIREWORKS||CONSUMER_AERIAL||HELICOPTERS", + "EXPLOSIVES_FIREWORKS||CONSUMER_AERIAL||MISSILES", + "EXPLOSIVES_FIREWORKS||CONSUMER_AERIAL||OTHER_SPECIALTY_ITEMS", + "EXPLOSIVES_FIREWORKS||CONSUMER_AERIAL||RELOADABLE_TUBE_AERIAL", + "EXPLOSIVES_FIREWORKS||CONSUMER_AERIAL||ROCKETS", + "EXPLOSIVES_FIREWORKS||CONSUMER_AERIAL||ROMAN_CANDLES", + "EXPLOSIVES_FIREWORKS||CONSUMER_NON_AERIAL||CHASERS", + "EXPLOSIVES_FIREWORKS||CONSUMER_NON_AERIAL||COMBINATION_ITEMS", + "EXPLOSIVES_FIREWORKS||CONSUMER_NON_AERIAL||FIRECRACKERS", + "EXPLOSIVES_FIREWORKS||CONSUMER_NON_AERIAL||FOUNTAINS", + "EXPLOSIVES_FIREWORKS||CONSUMER_NON_AERIAL||GROUND_SPINNERS", + "EXPLOSIVES_FIREWORKS||CONSUMER_NON_AERIAL||HANDHELD_SPARKLERS", + "EXPLOSIVES_FIREWORKS||CONSUMER_NON_AERIAL||OTHER_SPECIALTY_ITEMS", + "EXPLOSIVES_FIREWORKS||CONSUMER_NON_AERIAL||WHEELS", + "EXPLOSIVES_FIREWORKS||DEREGULATED_NOVELTIES||PARTY_POPPERS", + "EXPLOSIVES_FIREWORKS||DEREGULATED_NOVELTIES||SMOKE_BALLS", + "EXPLOSIVES_FIREWORKS||DEREGULATED_NOVELTIES||SNAKES", + "EXPLOSIVES_FIREWORKS||DEREGULATED_NOVELTIES||SNAP_CAPS", + "EXPLOSIVES_FIREWORKS||DEREGULATED_NOVELTIES||WIRE_CORE_SPARKLERS", + "EXPLOSIVES_FIREWORKS||DETONATING_CORD", + "EXPLOSIVES_FIREWORKS||EXPLODING_TARGET_SHOOTING", + "EXPLOSIVES_FIREWORKS||EXPLOSIVES||1_4_STICK", + "EXPLOSIVES_FIREWORKS||EXPLOSIVES||ALTERED_CONSUMER_FIREWORKS", + "EXPLOSIVES_FIREWORKS||EXPLOSIVES||CHERRY_BOMB", + "EXPLOSIVES_FIREWORKS||EXPLOSIVES||HOMEMADE_EXPLOSIVE_DEVICE", + "EXPLOSIVES_FIREWORKS||EXPLOSIVES||M_100", + "EXPLOSIVES_FIREWORKS||EXPLOSIVES||M_1000", + "EXPLOSIVES_FIREWORKS||EXPLOSIVES||M_80", + "EXPLOSIVES_FIREWORKS||EXPLOSIVES||SILVER_SALUTE", + "EXPLOSIVES_FIREWORKS||EXPLOSIVES||SPARKLER_BOMB", + "EXPLOSIVES_FIREWORKS||FLARES_FUSES", + "EXPLOSIVES_FIREWORKS||IMPROVISED_EXPLOSIVE_DEVICE", + "EXPLOSIVES_FIREWORKS||INERT_TARGET_SHOOTING", + "EXPLOSIVES_FIREWORKS||MILITARY_ORDINANCE", + "EXPLOSIVES_FIREWORKS||MODEL_AMATEUR_ROCKETS", + "EXPLOSIVES_FIREWORKS||NON_MILITARY_TRACER", + "EXPLOSIVES_FIREWORKS||OTHER", + "EXPLOSIVES_FIREWORKS||OTHER_EXPLOSIVES", + "EXPLOSIVES_FIREWORKS||PIPE_BOMB", + "EXPLOSIVES_FIREWORKS||PROFESSIONAL_DISPLAY||INDOOR_PROXIMATE", + "EXPLOSIVES_FIREWORKS||PROFESSIONAL_DISPLAY||OUTDOOR_AERIAL_SHELL", + "EXPLOSIVES_FIREWORKS||PROFESSIONAL_DISPLAY||OUTDOOR_GROUND_LEVEL", + "EXPLOSIVES_FIREWORKS||PROFESSIONAL_DISPLAY||OUTDOOR_LOW_LEVEL", + "EXPLOSIVES_FIREWORKS||PROFESSIONAL_DISPLAY||OUTDOOR_PROXIMATE", + "EXPLOSIVES_FIREWORKS||UNKNOWN", + "HEAT_FROM_ANOTHER_OBJECT||SPREAD_FROM_OUTSIDE_STRUCTURE", + "INCENDIARY||DEVICE", + "INCENDIARY||FLINT_FRICTION", + "INCENDIARY||GLASS_REFRACTION_MAGNIFYING_GLASS", + "INCENDIARY||HOT_SET", + "INCENDIARY||LIGHTER_MATCHES", + "INCENDIARY||OTHER", + "INCENDIARY||UNKNOWN", + "OPEN_FLAME||BARBEQUE_SMOKER", + "OPEN_FLAME||EXPOSURE_FIRE_PIT_OPEN_FLAME", + "OPEN_FLAME||LUMINARY_CANDLES_INCENSE", + "OPEN_FLAME||OTHER", + "OPEN_FLAME||UNKNOWN", + "OPERATING_EQUIPMENT||HEATING_COOLING_EQUIPMENT", + "OPERATING_EQUIPMENT||HOT_WORK", + "OPERATING_EQUIPMENT||OTHER", + "OPERATING_EQUIPMENT||OTHER_SMALL_ENGINE_EQUIPMENT", + "OPERATING_EQUIPMENT||UNKNOWN", + "OTHER_HEAT_SOURCE||OTHER", + "SMOKING_MATERIALS_ILLICIT_DRUGS||CIGAR_CIGARETTE_PIPE", + "SMOKING_MATERIALS_ILLICIT_DRUGS||DRUG_PARAPHERNALIA", + "SMOKING_MATERIALS_ILLICIT_DRUGS||ELECTRONIC_CIGARETTE", + "SMOKING_MATERIALS_ILLICIT_DRUGS||ILLEGAL_SUBSTANCE_MANUFACTURE", + "SMOKING_MATERIALS_ILLICIT_DRUGS||OTHER", + "SMOKING_MATERIALS_ILLICIT_DRUGS||UNKNOWN", + "UNABLE_TO_BE_DETERMINED||NOT_INVESTIGATED", + "UNABLE_TO_BE_DETERMINED||ORIGIN_AND_OR_CAUSE_NOT_IDENTIFIED", + "UNABLE_TO_BE_DETERMINED||ORIGIN_DESTROYED", + "UNABLE_TO_BE_DETERMINED||UNDER_INVESTIGATION" + ] + }, + "indoor_outdoor": { + "schema": "TypeIndoorOutdoorValue", + "codes": [ + "INDOOR", + "OUTDOOR" + ] + }, + "initial_detection": { + "schema": "TypeInitialDetectionValue", + "codes": [ + "ALERTED_BY_PERSON", + "AUDIBLE_NOISE", + "AUTOMATIC_SUPPRESSION", + "HEAT_ALARM", + "MANUAL_ACTIVATION", + "NO_INITIAL_DETECTION", + "ODOR", + "OTHER", + "PET", + "SMOKE_ALARM", + "SPECIALTY_DETECTOR", + "UNKNOWN", + "VISUAL_SIGHTING" + ] + }, + "interstitial_space": { + "schema": "TypeInterstitialSpaceValue", + "codes": [ + "ATTIC", + "CEILING", + "CRAWL_SPACE", + "DUCT", + "FLOOR_ASSEMBLY", + "OTHER" + ] + }, + "item_first_ignited": { + "schema": "TypeItemFirstIgnitedValue", + "codes": [ + "ALARM_ESCAPE_PROTECTION_DEVICES", + "CHILD_NURSERY_EQUIPMENT", + "CLOTHING_FABRIC", + "COOKING_MATERIALS", + "FARM_SUPPLIES_EQUIPMENT", + "FLAMMABLE_CHEMICALS", + "FURNISHINGS_FIXTURES", + "GENERAL_HOUSEHOLD_APPLIANCE", + "HEATING_COOLING_VENT_APPLIANCE", + "HOME_COMM_ENTERTAINMENT_HOBBY_EQUIPMENT", + "HOME_FAMILY_MAINTENANCE_PRODUCT", + "HOUSEHOLD_PACKAGING_CONTAINER", + "HOUSEWARE_NON_POWERED", + "KITCHEN_APPLIANCE", + "MEDICAL_EQUIPMENT", + "MISC_PRODUCT", + "PERSONAL_USE_ITEM", + "POWER_GEN_TRANS_DIST", + "REGULATED_PRODUCTS", + "RUBBISH_TRASH", + "SPORTS_RECREATION", + "STRUCTURAL_COMPONENTS", + "STRUCTURES_CONSTRUCTION", + "TOYS", + "VEGETATION", + "WORKSHOP_TOOL_APPARATUS", + "YARD_GARDEN_EQUIPMENT" + ] + }, + "job_classification": { + "schema": "TypeJobClassificationValue", + "codes": [ + "CAREER", + "INDUSTRIAL", + "PAID_ON_CALL", + "PART_TIME", + "VOLUNTEER", + "WILDLAND_CONTRACT", + "WILDLAND_FULL_TIME", + "WILDLAND_PART_TIME" + ] + }, + "location_place": { + "schema": "TypeLocPlaceValue", + "codes": [ + "AIRCRAFT", + "AIRPORT", + "ARENA", + "AUTOMOBILE", + "BANK", + "BAR", + "BICYCLE", + "BUS", + "BUS_STATION", + "CAFE", + "CLASSROOM", + "CLUB", + "CONSTRUCTION", + "CONVENTION_CENTER", + "GOVERNMENT", + "HOSPITAL", + "HOTEL", + "INDUSTRIAL", + "LIBRARY", + "MOTORCYCLE", + "MUSEUM", + "OFFICE", + "OTHER", + "OUTDOORS", + "PARKING", + "PLACE_OF_WORSHIP", + "PRISON", + "PUBLIC", + "PUBLIC_TRANSPORT", + "RESIDENCE", + "RESTAURANT", + "SCHOOL", + "SHOPPING_AREA", + "STADIUM", + "STORE", + "STREET", + "THEATER", + "TRAIN", + "TRAIN_STATION", + "TRUCK", + "UNDERWAY", + "UNKNOWN", + "WAREHOUSE", + "WATER", + "WATERCRAFT" + ] + }, + "location_use": { + "schema": "TypeLocationUseValue", + "codes": [ + "AGRICULTURE_STRUCT||ANIMAL_PROCESSING", + "AGRICULTURE_STRUCT||AUCTION_FEEDLOT", + "AGRICULTURE_STRUCT||FARM_BUILDING", + "AGRICULTURE_STRUCT||STORAGE_SILO", + "AGRICULTURE_STRUCT||VETERINARY_LIVESTOCK", + "ASSEMBLY||COMMUNITY_CENTER", + "ASSEMBLY||CONVENTION_CENTER", + "ASSEMBLY||INDOOR_ARENA", + "ASSEMBLY||MUSEUM_EXHIBIT_HALL_LIBRARY", + "ASSEMBLY||OUTDOOR_ARENA_AMPHITHEATER_PARK", + "ASSEMBLY||RELIGIOUS", + "ASSEMBLY||TEMP_OUTDOOR_STRUCT_EVENT", + "COMMERCIAL||BAR_NIGHTCLUB", + "COMMERCIAL||ENTERTAINMENT_RECREATION", + "COMMERCIAL||OFFICE_OTHER_TECHNICAL_SERVICES", + "COMMERCIAL||RESTAURANT_CAFE", + "COMMERCIAL||RETAIL_WHOLESALE_TRADE", + "COMMERCIAL||THEATERS_STUDIO", + "COMMERCIAL||VEHICLE_FUELING_CHARGING_STATION", + "COMMERCIAL||VEHICLE_REPAIR_SERVICES", + "COMMERCIAL||VETERINARY_PET", + "EDUCATION||COLLEGES_UNIVERSITIES", + "EDUCATION||DORMITORY_HOUSING", + "EDUCATION||K_12_SCHOOLS", + "EDUCATION||OTHER_EDUCATIONAL_BUILDINGS", + "EDUCATION||PREK_DAYCARE", + "GOVERNMENT||FIRE_MEDICAL_STATION", + "GOVERNMENT||GENERAL_SERVICES", + "GOVERNMENT||JAIL_PRISON_REFORMATORY", + "GOVERNMENT||NON_CIVILIAN_STRUCTURES", + "GOVERNMENT||POLICE_EMERGENCY_STATION", + "HEALTH_CARE||ALCOHOL_DRUG_REHABILITATION_CENTER", + "HEALTH_CARE||HOSPITAL_24_HOUR_MEDICAL_FACILITIES", + "HEALTH_CARE||MEDICAL_OFFICE_CLINIC", + "HEALTH_CARE||NURSING_HOME_ASSISTED_LIVING_RESIDENCE_ONSITE", + "INDUSTRIAL||CHEMICAL", + "INDUSTRIAL||COLD_STORAGE", + "INDUSTRIAL||FOOD_DRUGS", + "INDUSTRIAL||HEAVY", + "INDUSTRIAL||LIGHT", + "INDUSTRIAL||METALS_MINERALS_PROCESSING", + "OUTDOOR_INDUSTRIAL||CONSTRUCTION_SITE", + "OUTDOOR_INDUSTRIAL||DUMP_LANDFILL", + "OUTDOOR_INDUSTRIAL||INDUSTRIAL_YARD", + "OUTDOOR_INDUSTRIAL||MINE", + "OUTDOOR||CAMP_SITE", + "OUTDOOR||FOREST_GRASSLANDS_WOODLAND_WILDLAND_AREAS", + "OUTDOOR||GROUND_VACANT_LAND", + "OUTDOOR||HIKING_TRAIL", + "OUTDOOR||OPEN_WATER", + "OUTDOOR||ORCHARD_CROPS_FARMLAND", + "OUTDOOR||PLAYGROUND_PARK_RECREATIONAL_AREA", + "OUTDOOR||WATERFRONT", + "RESIDENTIAL||ATTACHED_SINGLE_FAMILY_DWELLING", + "RESIDENTIAL||CONGREGATE_HOUSING", + "RESIDENTIAL||DETATCHED_GARAGE", + "RESIDENTIAL||DETATCHED_SINGLE_FAMILY_DWELLING", + "RESIDENTIAL||MANUFACTURED_MOBILE_HOME", + "RESIDENTIAL||MULTI_FAMILY_HIGHRISE_DWELLING", + "RESIDENTIAL||MULTI_FAMILY_LOWRISE_DWELLING", + "RESIDENTIAL||MULTI_FAMILY_MIDRISE_DWELLING", + "RESIDENTIAL||TEMPORARY_LODGING_HOTEL_MOTEL", + "RESIDENTIAL||UNHOUSED_TEMPORARY_SHELTER", + "ROADWAY_ACCESS||BRIDGE", + "ROADWAY_ACCESS||HIGHWAY_INTERSTATE", + "ROADWAY_ACCESS||LIMITED_ACCESS_HIGHWAY_INTERSTATE", + "ROADWAY_ACCESS||PARKING_LOT_GARAGE", + "ROADWAY_ACCESS||RAILROAD_RAILYARD", + "ROADWAY_ACCESS||SIDEWALK", + "ROADWAY_ACCESS||STREET", + "ROADWAY_ACCESS||TUNNEL", + "STORAGE||STORAGE_MULTI_TENANT", + "STORAGE||STORAGE_PORTABLE_BUILDING", + "STORAGE||STORAGE_SINGLE_TENANT", + "UNCLASSIFIED||UNCLASSIFIED", + "UTILITY_MISC||ENERGY_FACILITY_INFRASTRUCTURE", + "UTILITY_MISC||TRANSPORTATION_STATION_HUB_AREA", + "UTILITY_MISC||TRASH_RECYCLING_FACILITY", + "UTILITY_MISC||WATER_SANITATION_FACILITY_INFRASTRUCTURE" + ] + }, + "medical_patient_care": { + "schema": "TypeMedicalPatientCareValue", + "codes": [ + "PATIENT_DEAD_ON_ARRIVAL", + "PATIENT_EVALUATED_CARE_PROVIDED", + "PATIENT_EVALUATED_NO_CARE_REQUIRED", + "PATIENT_EVALUATED_REFUSED_CARE", + "PATIENT_REFUSED_EVALUATION_CARE", + "PATIENT_SUPPORT_SERVICES_PROVIDED" + ] + }, + "medical_patient_status": { + "schema": "TypeMedicalPatientStatusValue", + "codes": [ + "IMPROVED", + "UNCHANGED", + "WORSE" + ] + }, + "medical_transport": { + "schema": "TypeMedicalTransportValue", + "codes": [ + "NONPATIENT_TRANSPORT", + "NO_TRANSPORT", + "OTHER_AGENCY_TRANSPORT", + "PATIENT_REFUSED_TRANSPORT", + "TRANSPORT_BY_EMS_UNIT" + ] + }, + "outdoor_cause": { + "schema": "TypeOutdoorCauseValue", + "codes": [ + "DEBRIS_OPEN_BURNING||BRANDING", + "DEBRIS_OPEN_BURNING||BURNING_PERSONAL_ITEMS", + "DEBRIS_OPEN_BURNING||BURN_BARREL", + "DEBRIS_OPEN_BURNING||DISTRESS_SIGNAL_FIRE", + "DEBRIS_OPEN_BURNING||DITCH_FENCE_LINE_BURNING", + "DEBRIS_OPEN_BURNING||ESCAPED_PRESCRIBED_BURN", + "DEBRIS_OPEN_BURNING||FIELD_AGRICULTURAL_BURNING", + "DEBRIS_OPEN_BURNING||HAND_PILE_SLASH", + "DEBRIS_OPEN_BURNING||MACHINE_PILE_SLASH", + "DEBRIS_OPEN_BURNING||OPEN_TRASH_BURNING", + "DEBRIS_OPEN_BURNING||OTHER", + "DEBRIS_OPEN_BURNING||OTHER_LAND_CLEARING", + "DEBRIS_OPEN_BURNING||PEST_CONTROL_DETERRENT_SMOKE_OUT", + "DEBRIS_OPEN_BURNING||RIGHT_OF_WAY_CLEARING", + "DEBRIS_OPEN_BURNING||UNKNOWN", + "DEBRIS_OPEN_BURNING||YARD_DEBRIS", + "EQUIPMENT_VEHICLE_USE||AIRCRAFT", + "EQUIPMENT_VEHICLE_USE||CHAINSAW_BRUSH_SAW_WEED_TRIMMER", + "EQUIPMENT_VEHICLE_USE||COMMERCIAL_TRANSPORT_VEHICLE", + "EQUIPMENT_VEHICLE_USE||ELECTRIC_MOTOR_POWER_TOOLS_BATTERY", + "EQUIPMENT_VEHICLE_USE||HEAVY_EQUIPMENT_IMPLEMENTS", + "EQUIPMENT_VEHICLE_USE||HOT_WORK", + "EQUIPMENT_VEHICLE_USE||OHV_ATV_MOTORCYCLE", + "EQUIPMENT_VEHICLE_USE||OTHER", + "EQUIPMENT_VEHICLE_USE||OTHER_SMALL_ENGINE_EQUIPMENT", + "EQUIPMENT_VEHICLE_USE||PASSENGER_VEHICLE_MOTORIZED_RV", + "EQUIPMENT_VEHICLE_USE||TRACTOR_MOWER_BRUSH_HOG", + "EQUIPMENT_VEHICLE_USE||TRAILER", + "EQUIPMENT_VEHICLE_USE||UAS_MODEL_ROCKETS_AIRPLANES", + "EQUIPMENT_VEHICLE_USE||UNKNOWN", + "EQUIPMENT_VEHICLE_USE||WATERCRAFT", + "FIREARMS_EXPLOSIVES||AMMONIUM_NITRATE_FUEL_OIL", + "FIREARMS_EXPLOSIVES||BLACK_POWDER_MUZZLE_LOADING", + "FIREARMS_EXPLOSIVES||BLASTING", + "FIREARMS_EXPLOSIVES||DETONATED_CORD", + "FIREARMS_EXPLOSIVES||EXPLODING_TARGET_SHOOTING", + "FIREARMS_EXPLOSIVES||FLARES_FUSES", + "FIREARMS_EXPLOSIVES||IMPROVISED_EXPLOSIVE_DEVICE", + "FIREARMS_EXPLOSIVES||INERT_TARGET_SHOOTING", + "FIREARMS_EXPLOSIVES||MILITARY_ORDINANCE", + "FIREARMS_EXPLOSIVES||NON_MILITARY_TRACER", + "FIREARMS_EXPLOSIVES||OTHER", + "FIREARMS_EXPLOSIVES||OTHER_EXPLOSIVES", + "FIREARMS_EXPLOSIVES||PIPE_BOMB", + "FIREARMS_EXPLOSIVES||UNKNOWN", + "FIREWORKS||CONSUMER_AERIAL||COMETS_MINES_SHELLS", + "FIREWORKS||CONSUMER_AERIAL||HELICOPTERS", + "FIREWORKS||CONSUMER_AERIAL||MISSILES", + "FIREWORKS||CONSUMER_AERIAL||OTHER_SPECIALTY_ITEMS", + "FIREWORKS||CONSUMER_AERIAL||RELOADABLE_TUBE_AERIAL", + "FIREWORKS||CONSUMER_AERIAL||ROCKETS", + "FIREWORKS||CONSUMER_AERIAL||ROMAN_CANDLES", + "FIREWORKS||CONSUMER_NON_AERIAL||CHASERS", + "FIREWORKS||CONSUMER_NON_AERIAL||COMBINATION_ITEMS", + "FIREWORKS||CONSUMER_NON_AERIAL||FIRECRACKERS", + "FIREWORKS||CONSUMER_NON_AERIAL||FOUNTAINS", + "FIREWORKS||CONSUMER_NON_AERIAL||GROUND_SPINNERS", + "FIREWORKS||CONSUMER_NON_AERIAL||HANDHELD_SPARKLERS", + "FIREWORKS||CONSUMER_NON_AERIAL||OTHER_SPECIALTY_ITEMS", + "FIREWORKS||CONSUMER_NON_AERIAL||WHEELS", + "FIREWORKS||DEREGULATED_NOVELTIES||PARTY_POPPERS", + "FIREWORKS||DEREGULATED_NOVELTIES||SMOKE_BALLS", + "FIREWORKS||DEREGULATED_NOVELTIES||SNAKES", + "FIREWORKS||DEREGULATED_NOVELTIES||SNAP_CAPS", + "FIREWORKS||DEREGULATED_NOVELTIES||WIRE_CORE_SPARKLERS", + "FIREWORKS||EXPLOSIVES||1_4_STICK", + "FIREWORKS||EXPLOSIVES||ALTERED_CONSUMER_FIREWORKS", + "FIREWORKS||EXPLOSIVES||CHERRY_BOMB", + "FIREWORKS||EXPLOSIVES||HOMEMADE_EXPLOSIVE_DEVICE", + "FIREWORKS||EXPLOSIVES||M_100", + "FIREWORKS||EXPLOSIVES||M_1000", + "FIREWORKS||EXPLOSIVES||M_80", + "FIREWORKS||EXPLOSIVES||SILVER_SALUTE", + "FIREWORKS||EXPLOSIVES||SPARKLER_BOMB", + "FIREWORKS||MODEL_AMATEUR_ROCKETS", + "FIREWORKS||OTHER", + "FIREWORKS||PROFESSIONAL_DISPLAY||INDOOR_PROXIMATE", + "FIREWORKS||PROFESSIONAL_DISPLAY||OUTDOOR_AERIAL_SHELL", + "FIREWORKS||PROFESSIONAL_DISPLAY||OUTDOOR_GROUND_LEVEL", + "FIREWORKS||PROFESSIONAL_DISPLAY||OUTDOOR_LOW_LEVEL", + "FIREWORKS||PROFESSIONAL_DISPLAY||OUTDOOR_PROXIMATE", + "FIREWORKS||UNKNOWN", + "INCENDIARY||DEVICE", + "INCENDIARY||FLINT_FRICTION", + "INCENDIARY||GLASS_REFRACTION_MAGNIFYING_GLASS", + "INCENDIARY||HOT_SET", + "INCENDIARY||LIGHTER_MATCHES", + "INCENDIARY||OTHER", + "INCENDIARY||UNKNOWN", + "NATURAL||ANIMAL", + "NATURAL||GLASS_REFRACTION_MAGNIFYING_GLASS", + "NATURAL||LIGHTNING", + "NATURAL||OTHER", + "NATURAL||SPONTANEOUS_COMBUSTION", + "NATURAL||UNKNOWN", + "NATURAL||VOLCANO", + "POWER_GEN_TRANS_DIST||ELECTRICAL_TRANSMISSION_DISTRIBUTION_SYSTEMS", + "POWER_GEN_TRANS_DIST||ENERGY_STORAGE_SYSTEM", + "POWER_GEN_TRANS_DIST||OIL_GAS_PRODUCTION_TRANSPORTATION", + "POWER_GEN_TRANS_DIST||OTHER", + "POWER_GEN_TRANS_DIST||SOLAR_UTILITY_SYSTEM", + "POWER_GEN_TRANS_DIST||UNKNOWN", + "POWER_GEN_TRANS_DIST||WIND_TURBINE_WINDMILLS_UTILITY_SYSTEM", + "RAILROAD_OPS_MAINTENANCE||BRAKES", + "RAILROAD_OPS_MAINTENANCE||DERAILMENT", + "RAILROAD_OPS_MAINTENANCE||DYNAMIC_GRID_FAILURE", + "RAILROAD_OPS_MAINTENANCE||EXHAUST_PARTICLES", + "RAILROAD_OPS_MAINTENANCE||HOT_WORK", + "RAILROAD_OPS_MAINTENANCE||OTHER", + "RAILROAD_OPS_MAINTENANCE||OTHER_MECHANICAL_FAILURE", + "RAILROAD_OPS_MAINTENANCE||RAIL_GRINDING", + "RAILROAD_OPS_MAINTENANCE||RIGHT_OF_WAY_VEGETATION_MAINTENANCE", + "RAILROAD_OPS_MAINTENANCE||TRACK_REPLACEMENT", + "RAILROAD_OPS_MAINTENANCE||UNKNOWN", + "RECREATION_CEREMONY||BARBECUE_SMOKER", + "RECREATION_CEREMONY||BONFIRE_PARTY_FIRE", + "RECREATION_CEREMONY||CAMPFIRE", + "RECREATION_CEREMONY||CEREMONIAL_FIRE", + "RECREATION_CEREMONY||GAS_COOKING_WARMING_LIGHTING_DEVICE", + "RECREATION_CEREMONY||LUMINARY", + "RECREATION_CEREMONY||OTHER", + "RECREATION_CEREMONY||OUTDOOR_OVEN_FIREPLACE_METAL_FIRE_RING", + "RECREATION_CEREMONY||UNKNOWN", + "SMOKING_MATERIALS_ILLICIT_DRUGS||CIGAR_CIGARETTE_PIPE", + "SMOKING_MATERIALS_ILLICIT_DRUGS||DRUG_PARAPHERNALIA", + "SMOKING_MATERIALS_ILLICIT_DRUGS||ELECTRONIC_CIGARETTE", + "SMOKING_MATERIALS_ILLICIT_DRUGS||ILLEGAL_SUBSTANCE_MANUFACTURE", + "SMOKING_MATERIALS_ILLICIT_DRUGS||OTHER", + "SMOKING_MATERIALS_ILLICIT_DRUGS||UNKNOWN", + "SPREAD_FROM_CONTROLLED_BURN", + "STRUCTURE||ELECTRIC_FENCE", + "STRUCTURE||SPREAD_FROM_STRUCTURE", + "UNABLE_TO_BE_DETERMINED||NOT_INVESTIGATED", + "UNABLE_TO_BE_DETERMINED||ORIGIN_AND_OR_CAUSE_NOT_IDENTIFIED", + "UNABLE_TO_BE_DETERMINED||ORIGIN_DESTROYED", + "UNABLE_TO_BE_DETERMINED||UNDER_INVESTIGATION" + ] + }, + "powertrain": { + "schema": "TypePowertrainValue", + "codes": [ + "BATTERY_ELECTRIC_VEHICLE", + "FUEL_CELL_ELECTRIC_VEHICLE", + "HYBRID_ELECTRIC_VEHICLE", + "INTERNAL_COMBUSTION_ENGINE", + "PLUG_IN_HYBRID_ELECTRIC_VEHICLE" + ] + }, + "product_contribution": { + "schema": "TypeProductContributionValue", + "codes": [ + "IGNITION", + "RELEASE", + "SPREAD" + ] + }, + "race": { + "schema": "TypeRaceValue", + "codes": [ + "AMERICAN_INDIAN_ALASKA_NATIVE", + "ASIAN", + "BLACK_AFRICAN_AMERICAN", + "HISPANIC_LATINO", + "MIDDLE_EASTERN_NORTH_AFRICAN", + "NATIVE_HAWAIIAN_PACIFIC_ISLANDER", + "OTHER", + "UNKNOWN", + "WHITE" + ] + }, + "rate_of_spread": { + "schema": "TypeRateOfSpreadValue", + "codes": [ + "CRITICAL", + "DANGEROUS", + "MODERATE", + "SLOW", + "UNKNOWN" + ] + }, + "relative_position": { + "schema": "TypeRelativePositionValue", + "codes": [ + "LOWER_SLOPE", + "MID_SLOPE", + "RIDGE_TOP", + "UNKNOWN", + "UPPER_SLOPE", + "VALLEY_BOTTOM" + ] + }, + "rescue_action": { + "schema": "TypeRescueActionValue", + "codes": [ + "BRACE_WALL_INFRASTRUCTURE", + "BREAK_BREACH_WALL", + "HYDRAULIC_TOOL_USE", + "NONE", + "ROPE_RIGGING", + "SUPPLY_AIR", + "TRENCH_SHORING", + "UNDERWATER_DIVE", + "VENTILATION" + ] + }, + "rescue_elevation": { + "schema": "TypeRescueElevationValue", + "codes": [ + "ON_BED", + "ON_FLOOR", + "ON_FURNITURE", + "OTHER" + ] + }, + "rescue_impediment": { + "schema": "TypeRescueImpedimentValue", + "codes": [ + "ACCESS_LIMITATIONS", + "HOARDING_CONDITIONS", + "IMPAIRED_PERSON", + "NONE", + "OTHER", + "PHYSICAL_MEDICAL_CONDITIONS_PERSON" + ] + }, + "rescue_mode": { + "schema": "TypeRescueModeValue", + "codes": [ + "DISENTANGLEMENT", + "EXTRICATION", + "OTHER", + "RECOVERY", + "REMOVAL_FROM_STRUCTURE" + ] + }, + "rescue_path": { + "schema": "TypeRescuePathValue", + "codes": [ + "REMOVAL_ALONG_ALT_PATH", + "REMOVAL_ALONG_PRIMARY_PATH" + ] + }, + "rescue_presence_known": { + "schema": "TypeRescuePresenceKnownValue", + "codes": [ + "KNOWN_ARRIVAL", + "KNOWN_DISPATCH", + "KNOWN_DURING" + ] + }, + "response_mode": { + "schema": "TypeResponseModeValue", + "codes": [ + "EMERGENT", + "NON_EMERGENT" + ] + }, + "roof_material": { + "schema": "TypeRoofMaterialValue", + "codes": [ + "ASPHALT_SHINGLES", + "CLAY_TILES", + "COMPOSITE_SHINGLES", + "CONCRETE_TILES", + "MEMBRANE", + "METAL", + "OTHER", + "SLATE", + "SOLAR_TILES", + "WOOD_SHINGLES" + ] + }, + "room": { + "schema": "TypeRoomValue", + "codes": [ + "ASSEMBLY", + "ATTIC", + "BALCONY_PORCH_DECK", + "BASEMENT", + "BATHROOM", + "BEDROOM", + "GARAGE", + "HALLWAY_FOYER", + "KITCHEN", + "LIVING_SPACE", + "OFFICE", + "OTHER", + "UNKNOWN", + "UTILITY_ROOM" + ] + }, + "serv_ems": { + "schema": "TypeServEmsValue", + "codes": [ + "AERO_TRANSPORT", + "ALS_NO_TRANSPORT", + "ALS_TRANSPORT", + "BLS_NO_TRANSPORT", + "BLS_TRANSPORT", + "COMMUNITY_MED", + "NO_MEDICAL" + ] + }, + "serv_fd": { + "schema": "TypeServFdValue", + "codes": [ + "ANIMAL_TECHRESCUE", + "ARFF_FIREFIGHTING", + "CAUSE_ORIGIN", + "CAVE_SAR", + "COLLAPSE_RESCUE", + "CONFINED_SPACE", + "DIVE_SAR", + "FLOOD_SAR", + "HAZMAT_OPS", + "HAZMAT_TECHNICIAN", + "HELO_SAR", + "HIGHRISE_FIREFIGHTING", + "ICE_RESCUE", + "MACHINERY_RESCUE", + "MARINE_FIREFIGHTING", + "MINE_SAR", + "PETROCHEM_FIREFIGHTING", + "REHABILITATION", + "ROPE_RESCUE", + "RRD_EXISTING", + "RRD_NEWCONST", + "RRD_PLANS", + "RRD_PUBLICED", + "STRUCTURAL_FIREFIGHTING", + "SURF_RESCUE", + "SWIFTWATER_SAR", + "TOWER_SAR", + "TRAINING_DRIVER", + "TRAINING_ELF", + "TRAINING_OD", + "TRAINING_VETFF", + "TRENCH_RESCUE", + "VEHICLE_RESCUE", + "WATERCRAFT_RESCUE", + "WATER_SAR", + "WILDERNESS_SAR", + "WILDLAND_FIREFIGHTING" + ] + }, + "serv_invest": { + "schema": "TypeServInvestValue", + "codes": [ + "COMPANY_LEVEL", + "DEDICATED", + "K9_DETECT", + "LAW_ENFORCEMENT", + "YOUTH_FIRESETTER" + ] + }, + "source_target": { + "schema": "TypeSourceTargetValue", + "codes": [ + "SOURCE", + "TARGET", + "UNKNOWN" + ] + }, + "special_modifier": { + "schema": "TypeSpecialModifierValue", + "codes": [ + "ACTIVE_ASSAILANT", + "COUNTY_LOCAL_DECLARED_DISASTER", + "FEDERAL_DECLARED_DISASTER", + "MCI", + "STATE_DECLARED_DISASTER", + "URBAN_CONFLAGRATION", + "VIOLENCE_AGAINST_RESPONDER", + "WORLD_CUP_2026" + ] + }, + "state": { + "schema": "StatesTerrs", + "codes": [ + "AL", + "AK", + "AS", + "AZ", + "AR", + "CA", + "CO", + "CT", + "DE", + "DC", + "FL", + "FM", + "GA", + "GU", + "HI", + "ID", + "IL", + "IN", + "IA", + "KS", + "KY", + "LA", + "ME", + "MD", + "MA", + "MI", + "MH", + "MN", + "MS", + "MO", + "MP", + "MT", + "NA", + "NE", + "NV", + "NH", + "NJ", + "NM", + "NY", + "NC", + "ND", + "OH", + "OK", + "OR", + "PA", + "PR", + "PW", + "RI", + "SC", + "SD", + "TN", + "TX", + "UM", + "UT", + "VT", + "VA", + "VI", + "WA", + "WV", + "WI", + "WY", + "AA", + "AE", + "AP", + "AB", + "BC", + "MB", + "NB", + "NL", + "NS", + "NT", + "NU", + "ON", + "PE", + "QC", + "SK", + "YT", + "AGU", + "BCN", + "BCS", + "CAM", + "CHP", + "CHH", + "CMX", + "COA", + "COL", + "DUR", + "GUA", + "GRO", + "HID", + "JAL", + "MIC", + "MOR", + "MEX", + "NAY", + "NLE", + "OAX", + "PUE", + "QUE", + "ROO", + "SLP", + "SIN", + "SON", + "TAB", + "TAM", + "TLA", + "VER", + "YUC", + "ZAC" + ] + }, + "street_direction": { + "schema": "TypeLocSnDirectionValue", + "codes": [ + "EASTBOUND", + "NORTHBOUND", + "SOUTHBOUND", + "WESTBOUND" + ] + }, + "street_prefix_postfix": { + "schema": "TypeLocSnPrePostValue", + "codes": [ + "ABBEY", + "ACCESS", + "ACCESS ROAD", + "ACRES", + "AIRPORT", + "ALCOVE", + "ALLE", + "ALLEY", + "ANNEX", + "APPROACH", + "ARC", + "ARCADE", + "ARCH", + "AVENIDA", + "AVENUE", + "AVENUE CIRCLE", + "AVENUE COURT", + "AVENUE LOOP", + "AVENUE PATH", + "AVENUE PLACE", + "AVENUE WAY", + "BANK", + "BAY", + "BAYOU", + "BAYWAY", + "BEACH", + "BEND", + "BLUFF", + "BLUFFS", + "BOARDWALK", + "BOTTOM", + "BOULEVARD", + "BRANCH", + "BRIDGE", + "BROOK", + "BROOKS", + "BUREAU OF INDIAN AFFAIRS ROUTE", + "BURG", + "BURGS", + "BYPASS", + "CALLE", + "CALLEJON", + "CAMINO", + "CAMP", + "CANYON", + "CAPE", + "CARTWAY", + "CAUSEWAY", + "CENTER", + "CENTERS", + "CENTRE", + "CHANNEL", + "CHASE", + "CHEMIN", + "CIRCLE", + "CIRCLES", + "CIRCUS", + "CLIFF", + "CLIFFS", + "CLOSE", + "CLUB", + "CLUSTER", + "COAST HIGHWAY", + "COMMON", + "COMMONS", + "CONCESSION ROAD", + "CONCOURSE", + "CONNECT", + "CONNECTOR", + "CORNER", + "CORNERS", + "CORRIDOR", + "CORSO", + "CORTE", + "COUNTY FOREST ROAD", + "COUNTY HIGHWAY", + "COUNTY ROAD", + "COUNTY ROUTE", + "COUNTY STATE AID HIGHWAY", + "COURS", + "COURSE", + "COURT", + "COURTS", + "COVE", + "COVES", + "CREEK", + "CRESCENT", + "CREST", + "CROSS", + "CROSSING", + "CROSSINGS", + "CROSSOVER", + "CROSSROAD", + "CROSSROADS", + "CROSSWAY", + "CURVE", + "CUSTER COUNTY ROAD", + "CUTOFF", + "CUTTING", + "DALE", + "DAM", + "DAWSON COUNTY ROAD", + "DELL", + "DIVIDE", + "DOCK", + "DOWN", + "DOWNS", + "DRAW", + "DRIFT", + "DRIVE", + "DRIVES", + "DRIVEWAY", + "DUGWAY", + "ECHO", + "EDGE", + "END", + "ENTRANCE", + "ENTRY", + "ESPLANADE", + "ESTATE", + "ESTATES", + "EXCHANGE", + "EXIT", + "EXPRESSWAY", + "EXTENSION", + "EXTENSIONS", + "FALL", + "FALLS", + "FARE", + "FARM", + "FARM TO MARKET", + "FEDERAL-AID SECONDARY HIGHWAY", + "FERRY", + "FIELD", + "FIELDS", + "FLAT", + "FLATS", + "FLOWAGE", + "FLYWAY", + "FORD", + "FORDS", + "FOREST", + "FOREST HIGHWAY", + "FOREST ROAD", + "FORGE", + "FORGES", + "FORK", + "FORKS", + "FORT", + "FREEWAY", + "FRONT", + "FRONTAGE ROAD", + "GABLES", + "GARDEN", + "GARDENS", + "GARTH", + "GATE", + "GATES", + "GATEWAY", + "GLADE", + "GLEN", + "GLENS", + "GORGE", + "GRADE", + "GREEN", + "GREENS", + "GREENWAY", + "GROVE", + "GROVES", + "HARBOR", + "HARBORS", + "HARBOUR", + "HAUL ROAD", + "HAVEN", + "HEATH", + "HEIGHTS", + "HIDEAWAY", + "HIGHWAY", + "HILL", + "HILLS", + "HOLLOW", + "HORN", + "HORSESHOE", + "INDIAN SERVICE ROAD", + "INLET", + "INTERSTATE", + "INTERVAL", + "ISLAND", + "ISLANDS", + "ISLE", + "ISLES", + "J-TURN", + "JUNCTION", + "JUNCTIONS", + "KEEP", + "KEY", + "KEYS", + "KNOLL", + "KNOLLS", + "LAIR", + "LAKE", + "LAKES", + "LAND", + "LANDING", + "LANE", + "LANE CIRCLE", + "LANE COURT", + "LANE ROAD", + "LATERAL", + "LEDGE", + "LIGHT", + "LIGHTS", + "LINE", + "LOAF", + "LOCK", + "LOCKS", + "LODGE", + "LOOKOUT", + "LOOP", + "LOOP ROAD", + "LUGAR", + "MALL", + "MANOR", + "MANORS", + "MARKET", + "MEADOW", + "MEADOWS", + "MEWS", + "MILL", + "MILLS", + "MISSION", + "MONTANA HIGHWAY", + "MOTORWAY", + "MOUNT", + "MOUNTAIN", + "MOUNTAINS", + "NARROWS", + "NATIONAL FOREST DEVELOPMENT ROAD", + "NECK", + "NOOK", + "NORTH CAROLINA HIGHWAY", + "OAKS", + "OLD COUNTY ROAD", + "ORCHARD", + "OVAL", + "OVERLOOK", + "OVERPASS", + "OVI", + "PARK", + "PARKE", + "PARKS", + "PARKWAY", + "PARKWAYS", + "PASEO", + "PASS", + "PASSAGE", + "PATH", + "PATHWAY", + "PIAZZA", + "PIKE", + "PINE", + "PINES", + "PLACE", + "PLACITA", + "PLAIN", + "PLAINS", + "PLATZ", + "PLAZA", + "POINT", + "POINTE", + "POINTS", + "PORT", + "PORTS", + "PRAIRIE", + "PRIVATE ROAD", + "PROMENADE", + "PUBLIC ACCESS", + "QUARTER", + "QUAY", + "RADIAL", + "RAMP", + "RANCH", + "RANCHO", + "RAPID", + "RAPIDS", + "REACH", + "RECREATIONAL ROAD", + "REST", + "RETREAT", + "RIDGE", + "RIDGES", + "RISE", + "RIVER", + "RIVER ROAD", + "ROAD", + "ROADS", + "ROUND", + "ROUTE", + "ROW", + "RUE", + "RUN", + "RUNNE", + "RUNWAY", + "SHOAL", + "SHOALS", + "SHORE", + "SHORES", + "SIDEROAD", + "SKIES", + "SKYWAY", + "SLIP", + "SPRING", + "SPRINGS", + "SPUR", + "SPURS", + "SQUARE", + "SQUARES", + "STATE HIGHWAY", + "STATE PARK ROAD", + "STATE PARKWAY", + "STATE ROAD", + "STATE ROUTE", + "STATE SECONDARY", + "STATE SPUR", + "STATION", + "STRAND", + "STRASSE", + "STRAVENUE", + "STREAM", + "STREET", + "STREET CIRCLE", + "STREET COURT", + "STREET LOOP", + "STREET PATH", + "STREET PLACE", + "STREET WAY", + "STREETS", + "STRIP", + "SUMMIT", + "TAXIWAY", + "TERMINAL", + "TERN", + "TERRACE", + "THROUGHWAY", + "THRUWAY", + "TIMBER ROAD", + "TOWN ROAD", + "TOWNLINE", + "TOWNSHIP ROAD", + "TRACE", + "TRACK", + "TRAFFICWAY", + "TRAIL", + "TRAILER", + "TRIANGLE", + "TRUCK TRAIL", + "TUNNEL", + "TURN", + "TURNPIKE", + "UNDERPASS", + "UNION", + "UNIONS", + "UNITED STATES FOREST SERVICE ROAD", + "UNITED STATES HIGHWAY", + "UUNYE", + "VALLEY", + "VALLEYS", + "VIA", + "VIADUCT", + "VIEW", + "VIEWS", + "VILLA", + "VILLAGE", + "VILLAGES", + "VILLE", + "VISTA", + "VOG", + "WADDY", + "WALK", + "WALKS", + "WALL", + "WAY", + "WAYS", + "WEEG", + "WELL", + "WELLS", + "WOODS", + "WYE", + "WYND" + ] + }, + "street_separator": { + "schema": "TypeLocSnPreSepValue", + "codes": [ + "AT", + "DE", + "DEL", + "DES", + "DE_LA", + "DE_LAS", + "IN_THE", + "OF", + "OF_THE", + "ON_THE", + "TO", + "TO_THE" + ] + }, + "suppress_appliance": { + "schema": "TypeSuppressApplianceValue", + "codes": [ + "AIRATTACK_HELITACK", + "BOOSTER_FIRE_HOSE", + "BUILDING_FDC", + "BUILDING_STANDPIPE", + "ELEVATED_MASTER_STREAM_STANDPIPE", + "FIRE_EXTINGUISHER", + "GROUND_MONITOR", + "MASTER_STREAM", + "MEDIUM_DIAMETER_FIRE_HOSE", + "NONE", + "OTHER", + "SMALL_DIAMETER_FIRE_HOSE" + ] + }, + "suppress_cooking": { + "schema": "TypeSuppressCookingValue", + "codes": [ + "COMMERCIAL_HOOD_SUPPRESSION", + "ELECTRIC_POWER_CUTOFF_DEVICE", + "OTHER", + "RESIDENTIAL_HOOD_MOUNTED", + "TEMPERATURE_LIMITING_STOVE" + ] + }, + "suppress_fire": { + "schema": "TypeSuppressFireValue", + "codes": [ + "CLEAN_AGENT_SYSTEM", + "DELUGE_SYSTEM", + "DRY_PIPE_SPRINKLER_SYSTEM", + "INDUSTRIAL_DRY_CHEM_SYSTEM", + "OTHER", + "PRE_ACTION_SYSTEM", + "UNKNOWN", + "WET_PIPE_SPRINKLER_SYSTEM" + ] + }, + "suppress_no_operation": { + "schema": "TypeSuppressNoOperationValue", + "codes": [ + "INSUFFICIENT_SOURCE", + "INSUFFICIENT_WATER_SUPPLY", + "SYSTEM_DAMAGED_COMPROMISED", + "SYSTEM_INOPERABLE", + "SYSTEM_NOT_SUITABLE", + "SYSTEM_SHUTOFF_DURING_INCIDENT", + "SYSTEM_SHUTOFF_PRIOR_TO_INCIDENT", + "UNABLE_TO_DETERMINE" + ] + }, + "suppress_operation": { + "schema": "TypeSuppressOperationValue", + "codes": [ + "NO_OPERATION", + "OPERATED_EFFECTIVE", + "OPERATED_NOT_EFFECTIVE" + ] + }, + "suppress_time": { + "schema": "TypeSuppressTimeValue", + "codes": [ + "DURING_SUPPRESSION", + "POST_SUPPRESSION", + "PRE_SUPPRESSION" + ] + }, + "unit_type": { + "schema": "TypeUnitValue", + "codes": [ + "AIR_EMS", + "AIR_LIGHT", + "AIR_RECON", + "AIR_TANKER", + "ALS_AMB", + "ARFF", + "ATV_EMS", + "ATV_FIRE", + "BLS_AMB", + "BOAT", + "BOAT_LARGE", + "CHIEF_STAFF_COMMAND", + "CREW", + "CREW_TRANS", + "DECON", + "DOZER", + "EMS_NOTRANS", + "EMS_SUPV", + "ENGINE_STRUCT", + "ENGINE_WUI", + "FOAM", + "HAZMAT", + "HELO_FIRE", + "HELO_GENERAL", + "HELO_RESCUE", + "INVEST", + "LADDER_QUINT", + "LADDER_SMALL", + "LADDER_TALL", + "LADDER_TILLER", + "MAB", + "MOBILE_COMMS", + "MOBILE_ICP", + "OTHER_GROUND", + "PLATFORM", + "PLATFORM_QUINT", + "POV", + "QUINT_TALL", + "REHAB", + "RESCUE_HEAVY", + "RESCUE_LIGHT", + "RESCUE_MEDIUM", + "RESCUE_USAR", + "RESCUE_WATER", + "SCBA", + "TENDER", + "UAS_FIRE", + "UAS_RECON", + "UTIL" + ] + }, + "vacancy": { + "schema": "TypeVacancyValue", + "codes": [ + "ABANDONED", + "DAMAGE_DECAY", + "FORECLOSURE", + "FOR_SALE_LEASE", + "NEW_CONSTRUCTION_REMODEL", + "SEASONAL_OCCASIONALLY_OCCUPIED", + "UNKNOWN" + ] + }, + "vehicle_damage": { + "schema": "TypeVehicleDamageValue", + "codes": [ + "DAMAGED_DRIVABLE", + "DAMAGED_NOT_DRIVABLE", + "NO_DAMAGE" + ] + }, + "vents": { + "schema": "TypeVentsValue", + "codes": [ + "MESH_SCREEN_GREATER_THAN_1_8_INCH", + "MESH_SCREEN_LESS_THAN_1_8_INCH", + "NO_VENTS", + "UNSCREENED" + ] + }, + "water_supply": { + "schema": "TypeWaterSupplyValue", + "codes": [ + "DRAFT_FROM_STATIC_SOURCE", + "FOAM_ADDITIVE", + "HYDRANT_GREATER_500", + "HYDRANT_LESS_500", + "NONE", + "NURSE_OTHER_APPARATUS", + "SUPPLY_FROM_FIRE_BOAT", + "TANK_WATER", + "WATER_TENDER_SHUTTLE" + ] + }, + "window_panes": { + "schema": "TypeWindowPanesValue", + "codes": [ + "DOUBLE_PANE", + "NO_WINDOWS", + "SINGLE_PANE", + "TRIPLE_PANE", + "UNKNOWN" + ] + }, + "yes_no_unknown": { + "schema": "TypeYesNoUnknownValue", + "codes": [ + "NO", + "UNKNOWN", + "YES" + ] + } } - } -} \ No newline at end of file +} diff --git a/Providers/Resgrid.Providers.Neris/NerisApiClient.cs b/Providers/Resgrid.Providers.Neris/NerisApiClient.cs index 33cba8ef..7402f812 100644 --- a/Providers/Resgrid.Providers.Neris/NerisApiClient.cs +++ b/Providers/Resgrid.Providers.Neris/NerisApiClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -67,6 +67,22 @@ public Task GetStatusAsync(RmsNerisProfile profile, Neri return SendAsync(profile, credential, HttpMethod.Get, () => $"/incident/{Entity(profile)}/{Uri.EscapeDataString(nerisIncidentId ?? string.Empty)}", null, StatusOutcome(nerisIncidentId), cancellationToken); } + public Task CreateIncidentAnalysisAsync(RmsNerisProfile profile, NerisCredential credential, string nerisIncidentId, string payloadJson, CancellationToken cancellationToken = default) + { + // The analysis is posted against the incident, not the entity: /incident_analysis/{neris_id_incident}. + return SendAsync(profile, credential, HttpMethod.Post, () => $"/incident_analysis/{Uri.EscapeDataString(nerisIncidentId ?? string.Empty)}", payloadJson, CreateAnalysisOutcome, cancellationToken); + } + + public Task UpdateIncidentAnalysisAsync(RmsNerisProfile profile, NerisCredential credential, string nerisAnalysisId, string payloadJson, CancellationToken cancellationToken = default) + { + return SendAsync(profile, credential, HttpMethod.Put, () => $"/incident_analysis/{Entity(profile)}/{Uri.EscapeDataString(nerisAnalysisId ?? string.Empty)}", payloadJson, UpdateOutcome(nerisAnalysisId), cancellationToken); + } + + public Task GetIncidentAnalysisStatusAsync(RmsNerisProfile profile, NerisCredential credential, string nerisAnalysisId, CancellationToken cancellationToken = default) + { + return SendAsync(profile, credential, HttpMethod.Get, () => $"/incident_analysis/{Entity(profile)}/{Uri.EscapeDataString(nerisAnalysisId ?? string.Empty)}", null, StatusOutcome(nerisAnalysisId), cancellationToken); + } + private static string Entity(RmsNerisProfile profile) => Uri.EscapeDataString(profile.NerisEntityId); private async Task SendAsync(RmsNerisProfile profile, NerisCredential credential, HttpMethod method, Func pathFactory, string body, @@ -88,7 +104,7 @@ private async Task SendAsync(RmsNerisProfile profile, Ne { return Fatal(ex.Message); } - catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is OperationCanceledException && !cancellationToken.IsCancellationRequested) + catch (Exception ex) when (IsTransientTransportFailure(ex, cancellationToken)) { return Transient("NERIS token endpoint unreachable: " + ex.Message); } @@ -115,7 +131,7 @@ private async Task SendAsync(RmsNerisProfile profile, Ne return interpret(response.StatusCode, text); } - catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is OperationCanceledException && !cancellationToken.IsCancellationRequested) + catch (Exception ex) when (IsTransientTransportFailure(ex, cancellationToken)) { return Transient("NERIS unreachable: " + ex.Message); } @@ -148,6 +164,27 @@ private static NerisSubmissionOutcome CreateOutcome(HttpStatusCode status, strin return WithStatus(Transient($"Unexpected NERIS create reply {(int)status}."), status, text); } + /// Analysis creation replies with the analysis's own id and status block (RMS-3). + private static NerisSubmissionOutcome CreateAnalysisOutcome(HttpStatusCode status, string text) + { + if (status == HttpStatusCode.Created || status == HttpStatusCode.OK) + { + var json = TryParse(text); + var outcome = new NerisSubmissionOutcome + { + Kind = NerisOutcomeKind.Created, + ExternalId = (string)json?["neris_id"], + ExternalStatus = (string)json?["incident_analysis_status"]?["status"] ?? (string)json?["incident_status"]?["status"] + }; + return WithStatus(Promote(outcome), status, text); + } + if ((int)status == 422) + return WithStatus(Rejected(text), status, text); + if (status == HttpStatusCode.NotFound) + return WithStatus(Transient("NERIS does not hold the incident yet; the analysis waits for it."), status, text); + return WithStatus(Transient($"Unexpected NERIS analysis create reply {(int)status}."), status, text); + } + private static Func UpdateOutcome(string nerisIncidentId) { return (status, text) => @@ -237,25 +274,44 @@ public static NerisSubmissionOutcome Rejected(string text) return outcome; } + /// + /// A transport failure worth retrying. Cancellation is deliberately excluded: HttpClient raises + /// (which derives from ) both for + /// its own timeout and for a caller cancellation, so a worker shutdown would otherwise be recorded as a + /// failed delivery attempt and push the row towards MaxAttempts. + /// + private static bool IsTransientTransportFailure(Exception ex, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested && ex is OperationCanceledException) + return false; + + return ex is HttpRequestException || ex is OperationCanceledException; + } + private async Task GetTokenAsync(RmsNerisProfile profile, NerisCredential credential, CancellationToken cancellationToken) { var key = TokenKey(profile); if (Tokens.TryGetValue(key, out var cached) && cached.ExpiresOn > DateTime.UtcNow.AddSeconds(60)) return cached.AccessToken; - var form = new Dictionary { ["grant_type"] = profile.GrantType == NerisGrantTypes.Password ? "password" : "client_credentials" }; - if (profile.GrantType == NerisGrantTypes.Password) + var isPassword = profile.GrantType == NerisGrantTypes.Password; + var form = new Dictionary { ["grant_type"] = isPassword ? "password" : "client_credentials" }; + if (isPassword) { form["username"] = credential.Username ?? string.Empty; form["password"] = credential.Password ?? string.Empty; } - else - { - form["username"] = credential.ClientId ?? string.Empty; - form["password"] = credential.ClientSecret ?? string.Empty; - } using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrlFor(profile) + "/token") { Content = new FormUrlEncodedContent(form) }; + + // The pinned contract's TokenBody carries username/password for the password and MFA flows only; a + // client-credentials integration account authenticates with HTTP Basic, so sending the id and secret as + // form fields fails against the destination. + if (!isPassword) + { + var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credential.ClientId}:{credential.ClientSecret}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); + } using var response = await _http.SendAsync(request, cancellationToken); var text = response.Content == null ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken); var json = TryParse(text); diff --git a/Providers/Resgrid.Providers.Neris/NerisMappingService.cs b/Providers/Resgrid.Providers.Neris/NerisMappingService.cs index 4d779d60..512b59ff 100644 --- a/Providers/Resgrid.Providers.Neris/NerisMappingService.cs +++ b/Providers/Resgrid.Providers.Neris/NerisMappingService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -102,9 +102,317 @@ public string BuildIncidentPayloadJson(NerisIncidentSnapshot snapshot, RmsNerisP if (snapshot.Units.Count > 0) payload["unit_responses"] = new JArray(snapshot.Units.OrderBy(u => u.Ordinal).Select(MapUnit)); + if (snapshot.Exposures.Count > 0) + payload["exposures"] = new JArray(snapshot.Exposures.OrderBy(e => e.Ordinal).Select(MapExposure)); + + if (snapshot.Casualties.Count > 0) + payload["casualty_rescues"] = new JArray(snapshot.Casualties.OrderBy(c => c.Ordinal).Select(MapCasualtyRescue)); + + ApplyModules(payload, snapshot.Modules, analysis: false); + + return payload.ToString(Formatting.None); + } + + public string BuildIncidentAnalysisPayloadJson(NerisIncidentAnalysisSnapshot snapshot, RmsNerisProfile profile) + { + if (snapshot?.Analysis == null) throw new ArgumentNullException(nameof(snapshot)); + if (profile == null) throw new ArgumentNullException(nameof(profile)); + + var analysis = snapshot.Analysis; + var payload = new JObject + { + ["base"] = Compact(new JObject + { + ["department_neris_id"] = profile.NerisEntityId, + ["incident_neris_id"] = Blank(snapshot.Report?.NerisIncidentId), + ["general_cause"] = Blank(analysis.GeneralCause), + ["investigation_types"] = Csv(analysis.InvestigationTypesCsv) + }) + }; + + if (snapshot.Properties.Count > 0) + payload["properties"] = new JArray(snapshot.Properties.OrderBy(p => p.Ordinal).Select(MapProperty)); + + if (snapshot.Vehicles.Count > 0) + payload["vehicles"] = new JArray(snapshot.Vehicles.OrderBy(v => v.Ordinal).Select(MapVehicle)); + + ApplyModules(payload, snapshot.Modules, analysis: true); + return payload.ToString(Formatting.None); } + /// + /// Writes each conditional section into the payload location its catalog descriptor names. A section whose + /// body will not parse is skipped rather than sent as a string: the destination would reject it, and local + /// validation has already raised the issue against the row. + /// + private static void ApplyModules(JObject payload, List modules, bool analysis) + { + foreach (var group in modules.Where(m => m != null).GroupBy(m => (RmsIncidentModuleKind)m.ModuleKind)) + { + var descriptor = RmsIncidentModuleCatalog.Get(group.Key); + if (descriptor == null || descriptor.BelongsToAnalysis != analysis) + continue; + + var bodies = group.OrderBy(m => m.Ordinal).Select(ParseDetail).Where(b => b != null).ToList(); + if (bodies.Count == 0) + continue; + + SetAtPath(payload, descriptor.PayloadPath, descriptor.IsCollection ? (JToken)new JArray(bodies) : bodies[0]); + } + } + + private static JObject ParseDetail(RmsIncidentModule module) => ParseDetail(module.DetailJson); + + /// Sets a dotted path, creating the intermediate objects a nested section needs. + private static void SetAtPath(JObject root, string path, JToken value) + { + var segments = path.Split('.'); + var current = root; + for (var i = 0; i < segments.Length - 1; i++) + { + if (current[segments[i]] is JObject existing) + { + current = existing; + continue; + } + + var created = new JObject(); + current[segments[i]] = created; + current = created; + } + + current[segments[segments.Length - 1]] = value; + } + + private static JObject MapExposure(RmsExposure exposure) + { + var body = ParseDetail(exposure.DetailJson) ?? new JObject(); + + body["damage_type"] = Blank(exposure.DamageType); + body["people_present"] = exposure.PeoplePresent; + body["displacement_count"] = exposure.DisplacementCount; + body["location_detail"] = NullIfEmpty(Compact(new JObject { ["type"] = Blank(exposure.LocationKind), ["item_type"] = Blank(exposure.ItemType) })); + body["location"] = NullIfEmpty(Compact(new JObject + { + ["street"] = Blank(exposure.Street), + ["incorporated_municipality"] = Blank(exposure.Municipality), + ["state"] = Blank(exposure.State), + ["postal_code"] = Blank(exposure.PostalCode), + ["additional_info"] = Blank(exposure.AddressText) + })); + + if (!string.IsNullOrWhiteSpace(exposure.LocationUse)) + body["location_use"] = new JObject { ["use_type"] = exposure.LocationUse }; + + var causes = Csv(exposure.DisplacementCausesCsv); + if (causes != null) + body["displacement_causes"] = causes; + + if (exposure.Latitude.HasValue && exposure.Longitude.HasValue) + body["point"] = Point(exposure.Latitude.Value, exposure.Longitude.Value); + + return Compact(body); + } + + /// + /// The casualty/rescue entry. The department's own personnel link never leaves Resgrid — the destination + /// gets the reported demographics it asks for and nothing that identifies the member in our system. + /// + private static JObject MapCasualtyRescue(RmsCasualtyRescue casualty) + { + var body = ParseDetail(casualty.DetailJson) ?? new JObject(); + + body["type"] = Blank(casualty.PersonType); + body["rank"] = Blank(casualty.Rank); + body["years_of_service"] = casualty.YearsOfService; + body["birth_month_year"] = Blank(casualty.BirthMonthYear); + body["gender"] = Blank(casualty.Gender); + body["race"] = Blank(casualty.Race); + + if ((RmsCasualtyRescueKind)casualty.Kind == RmsCasualtyRescueKind.Casualty) + body["casualty"] = new JObject { ["injury_or_noninjury"] = MapInjury(casualty) }; + else + body["rescue"] = MapRescue(casualty); + + return Compact(body); + } + + /// + /// The contract's casualty branch is discriminated on type: UNINJURED for a documented non-injury, + /// INJURED_FATAL or INJURED_NONFATAL otherwise. Everything the fire service records about a firefighter + /// injury lives under ff_injury_details, not on the injury itself. + /// + private static JObject MapInjury(RmsCasualtyRescue casualty) + { + if (casualty.WasInjured == false) + return new JObject { ["type"] = "UNINJURED" }; + + var injury = new JObject + { + ["type"] = casualty.WasFatal ? "INJURED_FATAL" : "INJURED_NONFATAL", + ["cause"] = Blank(casualty.CasualtyCause) + }; + + // The stored detail carries whatever the contract models that Resgrid does not hold as a column + // (unit continuity, incident command); the columns win over it for the fields we do own. + var details = ParseDetail(casualty.InjuryDetailJson) ?? new JObject(); + details["job_classification"] = Blank(casualty.JobClassification); + details["duty_type"] = Blank(casualty.DutyType); + details["action_type"] = Blank(casualty.CasualtyAction); + details["incident_stage"] = Blank(casualty.CasualtyTimeline); + var ppe = Csv(casualty.PpeCsv); + if (ppe != null) + details["ppe_items"] = ppe; + + var compacted = Compact(details); + if (compacted.HasValues) + injury["ff_injury_details"] = compacted; + + return Compact(injury); + } + + /// + /// The rescue branch nests twice: RescuePayload holds the firefighter/non-firefighter discrimination, and + /// the firefighter branch holds the removal/non-removal discrimination. The stored rescue mode is that + /// second discriminator — REMOVAL_FROM_STRUCTURE is a removal, every other mode is not. + /// + private static JObject MapRescue(RmsCasualtyRescue casualty) + { + var rescue = new JObject(); + + if (!string.IsNullOrWhiteSpace(casualty.PresenceKnown)) + rescue["presence_known"] = new JObject { ["presence_known_type"] = casualty.PresenceKnown }; + + var type = Blank(casualty.RescueType); + if (type != null && !FirefighterRescueTypes.Contains(type)) + { + // A non-firefighter rescue carries only its type; the fireground detail does not apply. + rescue["ffrescue_or_nonffrescue"] = new JObject { ["type"] = type }; + return Compact(rescue); + } + + var ff = new JObject { ["type"] = type ?? "RESCUED_BY_FIREFIGHTER" }; + var actions = Csv(casualty.RescueActionsCsv); + if (actions != null) + ff["actions"] = actions; + var impediments = Csv(casualty.RescueImpedimentsCsv); + if (impediments != null) + ff["impediments"] = impediments; + + var mode = Blank(casualty.RescueMode); + if (string.Equals(mode, RemovalMode, StringComparison.Ordinal)) + { + ff["removal_or_nonremoval"] = Compact(new JObject + { + ["type"] = RemovalMode, + ["elevation_type"] = Blank(casualty.RescueElevation), + ["rescue_path_type"] = Blank(casualty.RescuePath) + }); + } + else + { + ff["removal_or_nonremoval"] = new JObject { ["type"] = mode ?? "OTHER" }; + } + + rescue["ffrescue_or_nonffrescue"] = Compact(ff); + return Compact(rescue); + } + + private const string RemovalMode = "REMOVAL_FROM_STRUCTURE"; + + private static readonly HashSet FirefighterRescueTypes = new HashSet(StringComparer.Ordinal) + { + "RESCUED_BY_FIREFIGHTER", "RESCUED_BY_FF_RIT", "EVAC_ASSISTED_BY_FIREFIGHTER" + }; + + private static JObject MapProperty(RmsIncidentProperty property) + { + var body = ParseDetail(property.DetailJson) ?? new JObject(); + + body["location_use"] = Blank(property.LocationUse); + body["construction_type"] = Blank(property.ConstructionType); + body["foundation"] = Blank(property.Foundation); + body["exterior_finish"] = Blank(property.ExteriorFinish); + body["roof_material"] = Blank(property.RoofMaterial); + body["stories_above_grade"] = property.StoriesAboveGrade; + body["stories_below_grade"] = property.StoriesBelowGrade; + body["year_built"] = property.YearBuilt; + body["vacancy"] = Blank(property.Vacancy); + body["damage_type"] = Blank(property.DamageType); + body["fire_spread"] = Blank(property.FireSpread); + body["estimated_value"] = property.EstimatedValue; + body["estimated_loss"] = property.EstimatedLoss; + body["contents_value"] = property.ContentsValue; + body["contents_loss"] = property.ContentsLoss; + + return Compact(body); + } + + private static JObject MapVehicle(RmsIncidentVehicle vehicle) + { + var body = ParseDetail(vehicle.DetailJson) ?? new JObject(); + + body["type"] = Blank(vehicle.VehicleKind); + body["make"] = Blank(vehicle.Make); + body["model"] = Blank(vehicle.Model); + body["model_year"] = vehicle.ModelYear; + body["body_style"] = Blank(vehicle.BodyStyle); + body["powertrain"] = Blank(vehicle.Powertrain); + body["damage_type"] = Blank(vehicle.DamageType); + body["vin"] = Blank(vehicle.Vin); + body["license_plate"] = Blank(vehicle.LicensePlate); + body["license_state"] = Blank(vehicle.LicenseState); + body["occupied"] = vehicle.WasOccupied ? true : (bool?)null; + body["estimated_value"] = vehicle.EstimatedValue; + body["estimated_loss"] = vehicle.EstimatedLoss; + + return Compact(body); + } + + private static JObject ParseDetail(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JObject.Parse(json); + } + catch (JsonReaderException) + { + return null; + } + } + + /// An all-null nested block is absent, not an empty object the destination would have to interpret. + private static JToken NullIfEmpty(JObject value) + { + return value == null || !value.HasValues ? null : value; + } + + private static JArray Csv(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var codes = value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(c => c.Trim()).Where(c => c.Length > 0).Distinct().ToList(); + return codes.Count == 0 ? null : new JArray(codes); + } + + private static JObject Point(decimal latitude, decimal longitude) + { + return new JObject + { + ["crs"] = Wgs84, + ["geometry"] = new JObject + { + ["type"] = GeoPointType, + ["coordinates"] = new JArray((double)longitude, (double)latitude) + } + }; + } + private static JObject MapUnit(RmsUnitResponse unit) { return Compact(new JObject diff --git a/Providers/Resgrid.Providers.Neris/NerisProfileService.cs b/Providers/Resgrid.Providers.Neris/NerisProfileService.cs index dc35b10a..ed42ec35 100644 --- a/Providers/Resgrid.Providers.Neris/NerisProfileService.cs +++ b/Providers/Resgrid.Providers.Neris/NerisProfileService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -66,9 +66,15 @@ public async Task SaveProfileAsync(RmsNerisProfile profile, Ner target.NerisEntityId = string.IsNullOrWhiteSpace(profile.NerisEntityId) ? null : profile.NerisEntityId.Trim().ToUpperInvariant(); target.EntityName = profile.EntityName?.Trim(); - target.Environment = profile.Environment == NerisEnvironments.Sandbox ? NerisEnvironments.Sandbox : NerisEnvironments.Production; - target.BaseUrlOverride = string.IsNullOrWhiteSpace(profile.BaseUrlOverride) ? null : profile.BaseUrlOverride.Trim(); - target.GrantType = profile.GrantType == NerisGrantTypes.Password ? NerisGrantTypes.Password : NerisGrantTypes.ClientCredentials; + // The settings screen posts these as free strings, so canonicalize case-insensitively: "Sandbox" or a + // padded " sandbox " must not silently persist as Production and file a live incident against it. + target.Environment = string.Equals(profile.Environment?.Trim(), NerisEnvironments.Sandbox, StringComparison.OrdinalIgnoreCase) + ? NerisEnvironments.Sandbox + : NerisEnvironments.Production; + target.BaseUrlOverride = NormalizeBaseUrl(profile.BaseUrlOverride); + target.GrantType = string.Equals(profile.GrantType?.Trim(), NerisGrantTypes.Password, StringComparison.OrdinalIgnoreCase) + ? NerisGrantTypes.Password + : NerisGrantTypes.ClientCredentials; target.ContractVersion = ContractVersion; target.AutoSubmitOnFinalize = profile.AutoSubmitOnFinalize; target.IsEnabled = profile.IsEnabled; @@ -89,6 +95,26 @@ public async Task SaveProfileAsync(RmsNerisProfile profile, Ner return await _profiles.UpdateAsync(target, cancellationToken, true); } + /// + /// The base URL is a server-side request destination, so an unchecked override is an SSRF sink: a department + /// administrator could point the submission worker at an internal host. Self-hosted and sandbox deployments + /// legitimately need their own host, so the rule is absolute HTTPS with no embedded credentials rather than a + /// fixed allow-list. + /// + private static string NormalizeBaseUrl(string value) + { + var trimmed = string.IsNullOrWhiteSpace(value) ? null : value.Trim().TrimEnd('/'); + if (trimmed == null) + return null; + + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) + || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrEmpty(uri.UserInfo)) + throw new ArgumentException("The NERIS base URL override must be an absolute https:// address without embedded credentials.", nameof(value)); + + return trimmed; + } + public async Task GetCredentialAsync(RmsNerisProfile profile) { if (profile == null || !profile.HasCredential) diff --git a/Providers/Resgrid.Providers.Neris/NerisSectionRules.cs b/Providers/Resgrid.Providers.Neris/NerisSectionRules.cs new file mode 100644 index 00000000..b914c3b4 --- /dev/null +++ b/Providers/Resgrid.Providers.Neris/NerisSectionRules.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model; + +namespace Resgrid.Providers.Neris +{ + /// + /// Progressive section rules (RMS plan section 4.2, RMS-3): which conditional sections an incident must carry, + /// decided from the incident types the author selected. "Progressive" means a section is only demanded once the + /// type that needs it is chosen — an author who has not yet said what kind of incident this was is not shown a + /// wall of required sections, and a medical run is never asked for a fire cause. + /// + /// Requirements come from the incident-type taxonomy of the pinned contract (FIRE, HAZSIT, MEDICAL, RESCUE, + /// PUBSERV, NOEMERG, LAWENFORCE at the first level). Anything the contract merely encourages is a warning, so + /// a report is never blocked on a section the destination would accept without. + /// + /// + public static class NerisSectionRules + { + public sealed class SectionRequirement + { + public SectionRequirement(RmsIncidentModuleKind kind, bool required, string reason) + { + Kind = kind; + Required = required; + Reason = reason; + } + + public RmsIncidentModuleKind Kind { get; } + + /// True blocks finalization; false surfaces as a warning the author may answer or ignore. + public bool Required { get; } + + /// Why the section applies, shown beside it in the authoring surface. + public string Reason { get; } + } + + public const string StructureFirePrefix = "FIRE||STRUCTURE_FIRE"; + public const string OutsideFirePrefix = "FIRE||OUTSIDE_FIRE"; + public const string TransportationFirePrefix = "FIRE||TRANSPORTATION_FIRE"; + public const string SpecialFirePrefix = "FIRE||SPECIAL_FIRE"; + public const string HazmatPrefix = "HAZSIT||HAZARDOUS_MATERIALS"; + public const string MedicalPrefix = "MEDICAL"; + public const string FirePrefix = "FIRE"; + + /// + /// The sections that apply to a set of incident type codes, most specific first. The result is what the + /// authoring surface renders and what checks; there is no second list. + /// + public static IReadOnlyList For(IEnumerable incidentTypeCodes) + { + var codes = (incidentTypeCodes ?? Enumerable.Empty()) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Select(c => c.Trim()) + .ToList(); + + var requirements = new List(); + void Add(RmsIncidentModuleKind kind, bool required, string reason) + { + if (requirements.All(r => r.Kind != kind)) + requirements.Add(new SectionRequirement(kind, required, reason)); + } + + bool Any(string prefix) => codes.Any(c => c.StartsWith(prefix, StringComparison.Ordinal)); + + if (Any(FirePrefix)) + Add(RmsIncidentModuleKind.Fire, true, "A fire incident type is selected."); + + if (Any(StructureFirePrefix)) + { + Add(RmsIncidentModuleKind.StructureFireLocation, true, "The fire was in a structure."); + Add(RmsIncidentModuleKind.SmokeAlarm, false, "Structure fires report whether a smoke alarm was present."); + Add(RmsIncidentModuleKind.FireAlarm, false, "Structure fires report whether a fire alarm system was present."); + Add(RmsIncidentModuleKind.FireSuppression, false, "Structure fires report whether automatic suppression was present."); + } + + if (Any(OutsideFirePrefix) || Any(SpecialFirePrefix) || Any(TransportationFirePrefix)) + Add(RmsIncidentModuleKind.OutsideFireLocation, true, "The fire was outside a structure."); + + if (Any(HazmatPrefix)) + { + Add(RmsIncidentModuleKind.Hazsit, true, "A hazardous-materials incident type is selected."); + Add(RmsIncidentModuleKind.Chemical, false, "Record each chemical released."); + } + + if (Any(MedicalPrefix)) + Add(RmsIncidentModuleKind.Medical, true, "A medical incident type is selected."); + + return requirements; + } + + /// + /// The structure and outside fire location sections are mutually exclusive: the contract's location_detail + /// is one discriminated value, so carrying both would make the payload ambiguous. + /// + public static bool Conflicts(RmsIncidentModuleKind a, RmsIncidentModuleKind b) + { + return (a == RmsIncidentModuleKind.StructureFireLocation && b == RmsIncidentModuleKind.OutsideFireLocation) + || (a == RmsIncidentModuleKind.OutsideFireLocation && b == RmsIncidentModuleKind.StructureFireLocation); + } + + /// The value set a section's headline code must belong to, or null when the section has no coded headline. + public static string PrimaryCodeSetFor(RmsIncidentModuleKind kind) + { + switch (kind) + { + case RmsIncidentModuleKind.Fire: return "water_supply"; + case RmsIncidentModuleKind.StructureFireLocation: return "fire_cause_indoor"; + case RmsIncidentModuleKind.OutsideFireLocation: return "fire_cause_outdoor"; + case RmsIncidentModuleKind.Hazsit: return "hazard_disposition"; + case RmsIncidentModuleKind.Chemical: return "hazard_physical_state"; + case RmsIncidentModuleKind.Medical: return "medical_patient_care"; + case RmsIncidentModuleKind.SmokeAlarm: return "alarm_smoke"; + case RmsIncidentModuleKind.FireAlarm: return "alarm_fire"; + case RmsIncidentModuleKind.OtherAlarm: return "alarm_other"; + case RmsIncidentModuleKind.FireSuppression: return "suppress_fire"; + case RmsIncidentModuleKind.CookingFireSuppression: return "suppress_cooking"; + case RmsIncidentModuleKind.ElectricHazard: return "emerghaz_elec"; + case RmsIncidentModuleKind.PowergenHazard: return "emerghaz_pv"; + case RmsIncidentModuleKind.MedicalOxygenHazard: return "yes_no_unknown"; + case RmsIncidentModuleKind.CsstHazard: return "yes_no_unknown"; + case RmsIncidentModuleKind.StructureFireOrigin: return "room"; + case RmsIncidentModuleKind.OutsideFire: return "fire_cause_outdoor"; + case RmsIncidentModuleKind.HazsitAnalysis: return "hazsit_release_factors"; + case RmsIncidentModuleKind.Product: return "consumer_product"; + case RmsIncidentModuleKind.Battery: return "battery_chemistry"; + default: return null; + } + } + + /// The value set a section's secondary code must belong to, or null when it has none. + public static string SecondaryCodeSetFor(RmsIncidentModuleKind kind) + { + switch (kind) + { + case RmsIncidentModuleKind.Fire: return "fire_invest_need"; + case RmsIncidentModuleKind.StructureFireLocation: return "fire_bldg_damage"; + case RmsIncidentModuleKind.SmokeAlarm: + case RmsIncidentModuleKind.FireAlarm: + case RmsIncidentModuleKind.OtherAlarm: return "alarm_operation"; + case RmsIncidentModuleKind.FireSuppression: + case RmsIncidentModuleKind.CookingFireSuppression: return "suppress_operation"; + case RmsIncidentModuleKind.Medical: return "medical_transport"; + case RmsIncidentModuleKind.Chemical: return "hazard_released_into"; + case RmsIncidentModuleKind.StructureFireOrigin: return "item_first_ignited"; + case RmsIncidentModuleKind.Battery: return "battery_cell"; + default: return null; + } + } + } +} diff --git a/Providers/Resgrid.Providers.Neris/NerisSubmissionService.cs b/Providers/Resgrid.Providers.Neris/NerisSubmissionService.cs index 01ee0751..91e57a25 100644 --- a/Providers/Resgrid.Providers.Neris/NerisSubmissionService.cs +++ b/Providers/Resgrid.Providers.Neris/NerisSubmissionService.cs @@ -45,5 +45,34 @@ public async Task CheckStatusAsync(RmsNerisProfile profi var credential = await _profiles.GetCredentialAsync(profile); return await _client.GetStatusAsync(profile, credential, nerisIncidentId, cancellationToken); } + + public async Task DeliverAnalysisAsync(RmsNerisProfile profile, RmsSubmission submission, string nerisIncidentId, string existingNerisAnalysisId, CancellationToken cancellationToken = default) + { + if (submission == null) throw new ArgumentNullException(nameof(submission)); + if (string.IsNullOrWhiteSpace(submission.PayloadJson)) + return new NerisSubmissionOutcome { Kind = NerisOutcomeKind.Fatal, Message = "The submission carries no payload." }; + + // The analysis files against an incident. Without the incident's id there is nothing to file against, + // and that is a wait, not a failure: the incident's own submission is still in flight. + if (string.IsNullOrWhiteSpace(nerisIncidentId) && string.IsNullOrWhiteSpace(existingNerisAnalysisId)) + return new NerisSubmissionOutcome { Kind = NerisOutcomeKind.Transient, Message = "The incident is not filed yet; the analysis waits for it." }; + + var credential = await _profiles.GetCredentialAsync(profile); + if (!string.IsNullOrWhiteSpace(existingNerisAnalysisId)) + { + var update = await _client.UpdateIncidentAnalysisAsync(profile, credential, existingNerisAnalysisId, submission.PayloadJson, cancellationToken); + if (update.Kind == NerisOutcomeKind.Fatal && update.StatusCode == 404) + return await _client.CreateIncidentAnalysisAsync(profile, credential, nerisIncidentId, submission.PayloadJson, cancellationToken); + return update; + } + + return await _client.CreateIncidentAnalysisAsync(profile, credential, nerisIncidentId, submission.PayloadJson, cancellationToken); + } + + public async Task CheckAnalysisStatusAsync(RmsNerisProfile profile, string nerisAnalysisId, CancellationToken cancellationToken = default) + { + var credential = await _profiles.GetCredentialAsync(profile); + return await _client.GetIncidentAnalysisStatusAsync(profile, credential, nerisAnalysisId, cancellationToken); + } } } diff --git a/Providers/Resgrid.Providers.Neris/NerisValidationService.cs b/Providers/Resgrid.Providers.Neris/NerisValidationService.cs index 7c609686..9c6b0224 100644 --- a/Providers/Resgrid.Providers.Neris/NerisValidationService.cs +++ b/Providers/Resgrid.Providers.Neris/NerisValidationService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -77,7 +77,10 @@ void Add(string rule, RmsValidationSeverity severity, string path, string messag Add("neris.location.place_type", RmsValidationSeverity.Error, "base.location.place_type", $"'{snapshot.Location.PlaceType}' is not a NERIS place type."); if (snapshot.Location.Latitude.HasValue != snapshot.Location.Longitude.HasValue) Add("neris.location.point", RmsValidationSeverity.Error, "base.point", "Latitude and longitude must both be present."); - if (snapshot.Location.Latitude.HasValue && (Math.Abs(snapshot.Location.Latitude.Value) > 90 || Math.Abs(snapshot.Location.Longitude.Value) > 180)) + // Both coordinates, or the range check dereferences the missing one and the whole validation run fails + // with an exception instead of reporting the paired-coordinate error above. + if (snapshot.Location.Latitude.HasValue && snapshot.Location.Longitude.HasValue + && (Math.Abs(snapshot.Location.Latitude.Value) > 90 || Math.Abs(snapshot.Location.Longitude.Value) > 180)) Add("neris.location.point.range", RmsValidationSeverity.Error, "base.point", "Coordinates are out of range."); } @@ -141,9 +144,287 @@ void Add(string rule, RmsValidationSeverity severity, string path, string messag foreach (var tactic in snapshot.Tactics.Where(t => !catalog.Contains("action_tactic", t.TacticCode))) Add("neris.tactic.code", RmsValidationSeverity.Error, "actions_tactics", $"'{tactic.TacticCode}' is not a NERIS action/tactic."); + // Non-unit resources are a department record: contract 1.4.78 has no incident-level resources field, so + // they are never submitted and there is no NERIS value set to check them against. + foreach (var resource in snapshot.Resources.Where(r => string.IsNullOrWhiteSpace(r.ResourceCode))) + Add("neris.resource.code", RmsValidationSeverity.Error, "resources", "A resource entry needs a code."); + + ValidateSections(Add, snapshot, catalog); + ValidateExposures(Add, snapshot, catalog); + ValidateCasualties(Add, snapshot, catalog); + + return issues; + } + + /// + /// Progressive section rules (RMS-3): the sections the selected incident types demand must be present and + /// coherent, and every section that is present must carry codes the pinned contract knows. + /// + private static void ValidateSections(Action add, NerisIncidentSnapshot snapshot, NerisValueSetCatalog catalog) + { + var present = snapshot.Modules.Select(m => (RmsIncidentModuleKind)m.ModuleKind).Distinct().ToList(); + + foreach (var requirement in NerisSectionRules.For(snapshot.Types.Select(t => t.TypeCode))) + { + if (present.Contains(requirement.Kind)) + continue; + + var descriptor = RmsIncidentModuleCatalog.Get(requirement.Kind); + add( + "neris.section." + requirement.Kind.ToString().ToLowerInvariant(), + requirement.Required ? RmsValidationSeverity.Error : RmsValidationSeverity.Warning, + descriptor?.PayloadPath ?? requirement.Kind.ToString(), + $"The {requirement.Kind} section is missing. {requirement.Reason}"); + } + + foreach (var kind in present) + { + foreach (var other in present.Where(o => NerisSectionRules.Conflicts(kind, o))) + { + add("neris.section.conflict", RmsValidationSeverity.Error, "fire_detail.location_detail", + $"A report cannot carry both the {kind} and {other} sections; the fire was either in a structure or outside it."); + break; + } + } + + foreach (var module in snapshot.Modules) + { + var kind = (RmsIncidentModuleKind)module.ModuleKind; + var descriptor = RmsIncidentModuleCatalog.Get(kind); + if (descriptor == null) + { + add("neris.section.unknown", RmsValidationSeverity.Error, kind.ToString(), $"'{kind}' is not a section of contract {catalog.ContractVersion}."); + continue; + } + + var path = descriptor.PayloadPath; + + if (!descriptor.IsCollection && snapshot.Modules.Count(m => m.ModuleKind == module.ModuleKind) > 1) + add("neris.section.cardinality", RmsValidationSeverity.Error, path, $"The {kind} section may appear only once."); + + if (!string.IsNullOrWhiteSpace(module.DetailJson) && !ParsesAsObject(module.DetailJson)) + add("neris.section.detail", RmsValidationSeverity.Error, path, $"The {kind} section body is not a JSON object and cannot be submitted."); + + var primarySet = NerisSectionRules.PrimaryCodeSetFor(kind); + if (primarySet != null && !string.IsNullOrWhiteSpace(module.PrimaryCode) && !catalog.Contains(primarySet, module.PrimaryCode)) + add("neris.section.primary_code", RmsValidationSeverity.Error, path, $"'{module.PrimaryCode}' is not a NERIS {primarySet} value."); + + var secondarySet = NerisSectionRules.SecondaryCodeSetFor(kind); + if (secondarySet != null && !string.IsNullOrWhiteSpace(module.SecondaryCode) && !catalog.Contains(secondarySet, module.SecondaryCode)) + add("neris.section.secondary_code", RmsValidationSeverity.Error, path, $"'{module.SecondaryCode}' is not a NERIS {secondarySet} value."); + + if (!string.IsNullOrWhiteSpace(module.QuantityUnit) && !catalog.Contains("hazard_unit", module.QuantityUnit)) + add("neris.section.quantity_unit", RmsValidationSeverity.Error, path, $"'{module.QuantityUnit}' is not a NERIS unit of measure."); + } + } + + private static void ValidateExposures(Action add, NerisIncidentSnapshot snapshot, NerisValueSetCatalog catalog) + { + var index = 0; + foreach (var exposure in snapshot.Exposures.OrderBy(e => e.Ordinal)) + { + var path = $"exposures[{index++}]"; + + // damage_type is required by the contract; the rest are checked only when supplied. + if (string.IsNullOrWhiteSpace(exposure.DamageType)) + add("neris.exposure.damage_type", RmsValidationSeverity.Error, path + ".damage_type", "Each exposure needs a damage rating."); + else if (!catalog.Contains("exposure_damage", exposure.DamageType)) + add("neris.exposure.damage_type.code", RmsValidationSeverity.Error, path + ".damage_type", $"'{exposure.DamageType}' is not a NERIS exposure damage rating."); + + if (!string.IsNullOrWhiteSpace(exposure.ItemType) && !catalog.Contains("exposure_item", exposure.ItemType)) + add("neris.exposure.item_type", RmsValidationSeverity.Error, path + ".location_detail", $"'{exposure.ItemType}' is not a NERIS exposure item type."); + + if (!string.IsNullOrWhiteSpace(exposure.LocationUse) && !catalog.Contains("location_use", exposure.LocationUse)) + add("neris.exposure.location_use", RmsValidationSeverity.Error, path + ".location_use", $"'{exposure.LocationUse}' is not a NERIS location use."); + + foreach (var cause in Split(exposure.DisplacementCausesCsv).Where(c => !catalog.Contains("displace_cause", c))) + add("neris.exposure.displace_cause", RmsValidationSeverity.Error, path + ".displacement_causes", $"'{cause}' is not a NERIS displacement cause."); + + if (exposure.Latitude.HasValue != exposure.Longitude.HasValue) + add("neris.exposure.point", RmsValidationSeverity.Error, path + ".point", "Latitude and longitude must both be present."); + } + } + + private static void ValidateCasualties(Action add, NerisIncidentSnapshot snapshot, NerisValueSetCatalog catalog) + { + var index = 0; + foreach (var casualty in snapshot.Casualties.OrderBy(c => c.Ordinal)) + { + var path = $"casualty_rescues[{index++}]"; + + if (casualty.PersonType != RmsCasualtyPersonTypes.Firefighter && casualty.PersonType != RmsCasualtyPersonTypes.Civilian) + add("neris.casualty.person_type", RmsValidationSeverity.Error, path + ".type", "Each casualty or rescue must say whether the person was a firefighter (FF) or not (NONFF)."); + + if (!string.IsNullOrWhiteSpace(casualty.Gender) && !catalog.Contains("gender", casualty.Gender)) + add("neris.casualty.gender", RmsValidationSeverity.Error, path + ".gender", $"'{casualty.Gender}' is not a NERIS gender value."); + + if (!string.IsNullOrWhiteSpace(casualty.Race) && !catalog.Contains("race", casualty.Race)) + add("neris.casualty.race", RmsValidationSeverity.Error, path + ".race", $"'{casualty.Race}' is not a NERIS race value."); + + if (!string.IsNullOrWhiteSpace(casualty.BirthMonthYear) && !BirthMonthYearPattern.IsMatch(casualty.BirthMonthYear)) + add("neris.casualty.birth_month_year", RmsValidationSeverity.Error, path + ".birth_month_year", "Birth month and year must be written as YYYY-MM."); + + if ((RmsCasualtyRescueKind)casualty.Kind == RmsCasualtyRescueKind.Casualty) + { + if (!string.IsNullOrWhiteSpace(casualty.CasualtyCause) && !catalog.Contains("casualty_cause", casualty.CasualtyCause)) + add("neris.casualty.cause", RmsValidationSeverity.Error, path + ".casualty", $"'{casualty.CasualtyCause}' is not a NERIS casualty cause."); + if (!string.IsNullOrWhiteSpace(casualty.CasualtyAction) && !catalog.Contains("casualty_action", casualty.CasualtyAction)) + add("neris.casualty.action", RmsValidationSeverity.Error, path + ".casualty", $"'{casualty.CasualtyAction}' is not a NERIS casualty action."); + if (!string.IsNullOrWhiteSpace(casualty.CasualtyTimeline) && !catalog.Contains("casualty_timeline", casualty.CasualtyTimeline)) + add("neris.casualty.timeline", RmsValidationSeverity.Error, path + ".casualty", $"'{casualty.CasualtyTimeline}' is not a NERIS casualty timeline."); + if (!string.IsNullOrWhiteSpace(casualty.DutyType) && !catalog.Contains("duty", casualty.DutyType)) + add("neris.casualty.duty", RmsValidationSeverity.Error, path + ".casualty", $"'{casualty.DutyType}' is not a NERIS duty type."); + foreach (var ppe in Split(casualty.PpeCsv).Where(p => !catalog.Contains("casualty_ppe", p))) + add("neris.casualty.ppe", RmsValidationSeverity.Error, path + ".casualty", $"'{ppe}' is not a NERIS PPE item."); + + // A responder casualty without duty context cannot be analysed; the contract accepts it, the fire service should not. + if (casualty.PersonType == RmsCasualtyPersonTypes.Firefighter && string.IsNullOrWhiteSpace(casualty.DutyType)) + add("neris.casualty.ff_duty", RmsValidationSeverity.Warning, path + ".casualty", "A firefighter casualty should record what the member was doing at the time."); + } + else + { + if (string.IsNullOrWhiteSpace(casualty.RescueType)) + add("neris.rescue.type", RmsValidationSeverity.Error, path + ".rescue", "Each rescue needs a rescue type."); + foreach (var action in Split(casualty.RescueActionsCsv).Where(a => !catalog.Contains("rescue_action", a))) + add("neris.rescue.action", RmsValidationSeverity.Error, path + ".rescue", $"'{action}' is not a NERIS rescue action."); + foreach (var impediment in Split(casualty.RescueImpedimentsCsv).Where(i => !catalog.Contains("rescue_impediment", i))) + add("neris.rescue.impediment", RmsValidationSeverity.Error, path + ".rescue", $"'{impediment}' is not a NERIS rescue impediment."); + if (!string.IsNullOrWhiteSpace(casualty.RescueMode) && !catalog.Contains("rescue_mode", casualty.RescueMode)) + add("neris.rescue.mode", RmsValidationSeverity.Error, path + ".rescue", $"'{casualty.RescueMode}' is not a NERIS rescue mode."); + if (!string.IsNullOrWhiteSpace(casualty.RescuePath) && !catalog.Contains("rescue_path", casualty.RescuePath)) + add("neris.rescue.path", RmsValidationSeverity.Error, path + ".rescue", $"'{casualty.RescuePath}' is not a NERIS rescue path."); + if (!string.IsNullOrWhiteSpace(casualty.RescueElevation) && !catalog.Contains("rescue_elevation", casualty.RescueElevation)) + add("neris.rescue.elevation", RmsValidationSeverity.Error, path + ".rescue", $"'{casualty.RescueElevation}' is not a NERIS rescue elevation."); + } + } + } + + /// + /// Local validation of the separate incident-analysis filing (RMS-3). It is checked on its own because it + /// is submitted on its own; an analysis problem must never block the incident it belongs to. + /// + public List ValidateAnalysisLocal(NerisIncidentAnalysisSnapshot snapshot, RmsNerisProfile profile) + { + var issues = new List(); + if (snapshot?.Analysis == null) + return issues; + + var analysis = snapshot.Analysis; + var catalog = NerisValueSetCatalog.Instance; + void Add(string rule, RmsValidationSeverity severity, string path, string message) + { + issues.Add(new RmsValidationIssue + { + RmsValidationIssueId = Guid.NewGuid().ToString(), + DepartmentId = analysis.DepartmentId, + RecordId = analysis.RmsIncidentAnalysisId, + ProfileVersion = catalog.ContractVersion, + RuleKey = rule, + Severity = (int)severity, + FieldPath = path, + Message = message, + Source = (int)RmsValidationSource.Local, + CreatedOn = DateTime.UtcNow + }); + } + + if (profile == null || string.IsNullOrWhiteSpace(profile.NerisEntityId)) + Add("neris.profile.entity", RmsValidationSeverity.Error, "base.department_neris_id", "The department has no NERIS entity ID configured."); + + if (string.IsNullOrWhiteSpace(snapshot.Report?.NerisIncidentId)) + Add("neris.analysis.incident", RmsValidationSeverity.Error, "base.incident_neris_id", "The incident must be filed with NERIS before its analysis can be."); + + if (!string.IsNullOrWhiteSpace(analysis.GeneralCause) && !catalog.Contains("fire_cause_general", analysis.GeneralCause)) + Add("neris.analysis.general_cause", RmsValidationSeverity.Error, "base.general_cause", $"'{analysis.GeneralCause}' is not a NERIS general fire cause."); + + foreach (var investigation in Split(analysis.InvestigationTypesCsv).Where(i => !catalog.Contains("fire_invest", i))) + Add("neris.analysis.investigation", RmsValidationSeverity.Error, "base.investigation_types", $"'{investigation}' is not a NERIS investigation type."); + + var index = 0; + foreach (var property in snapshot.Properties.OrderBy(p => p.Ordinal)) + { + var path = $"properties[{index++}]"; + if (!string.IsNullOrWhiteSpace(property.LocationUse) && !catalog.Contains("location_use", property.LocationUse)) + Add("neris.analysis.property.location_use", RmsValidationSeverity.Error, path + ".location_use", $"'{property.LocationUse}' is not a NERIS location use."); + if (!string.IsNullOrWhiteSpace(property.ConstructionType) && !catalog.Contains("construction", property.ConstructionType)) + Add("neris.analysis.property.construction", RmsValidationSeverity.Error, path + ".construction_type", $"'{property.ConstructionType}' is not a NERIS construction type."); + if (!string.IsNullOrWhiteSpace(property.DamageType) && !catalog.Contains("fire_bldg_damage", property.DamageType)) + Add("neris.analysis.property.damage", RmsValidationSeverity.Error, path + ".damage_type", $"'{property.DamageType}' is not a NERIS building damage rating."); + if (!string.IsNullOrWhiteSpace(property.FireSpread) && !catalog.Contains("fire_spread", property.FireSpread)) + Add("neris.analysis.property.fire_spread", RmsValidationSeverity.Error, path + ".fire_spread", $"'{property.FireSpread}' is not a NERIS fire spread value."); + if (property.EstimatedLoss.HasValue && property.EstimatedValue.HasValue && property.EstimatedLoss > property.EstimatedValue) + Add("neris.analysis.property.loss", RmsValidationSeverity.Warning, path + ".estimated_loss", "The reported loss is greater than the reported pre-incident value."); + } + + index = 0; + foreach (var vehicle in snapshot.Vehicles.OrderBy(v => v.Ordinal)) + { + var path = $"vehicles[{index++}]"; + if (!string.IsNullOrWhiteSpace(vehicle.Make) && !catalog.Contains("auto_make", vehicle.Make)) + Add("neris.analysis.vehicle.make", RmsValidationSeverity.Error, path + ".make", $"'{vehicle.Make}' is not a NERIS vehicle make."); + if (!string.IsNullOrWhiteSpace(vehicle.BodyStyle) && !catalog.Contains("auto_body_style", vehicle.BodyStyle)) + Add("neris.analysis.vehicle.body_style", RmsValidationSeverity.Error, path + ".body_style", $"'{vehicle.BodyStyle}' is not a NERIS body style."); + if (!string.IsNullOrWhiteSpace(vehicle.Powertrain) && !catalog.Contains("powertrain", vehicle.Powertrain)) + Add("neris.analysis.vehicle.powertrain", RmsValidationSeverity.Error, path + ".powertrain", $"'{vehicle.Powertrain}' is not a NERIS powertrain."); + if (!string.IsNullOrWhiteSpace(vehicle.DamageType) && !catalog.Contains("vehicle_damage", vehicle.DamageType)) + Add("neris.analysis.vehicle.damage", RmsValidationSeverity.Error, path + ".damage_type", $"'{vehicle.DamageType}' is not a NERIS vehicle damage rating."); + } + + foreach (var module in snapshot.Modules) + { + var kind = (RmsIncidentModuleKind)module.ModuleKind; + var descriptor = RmsIncidentModuleCatalog.Get(kind); + if (descriptor == null || !descriptor.BelongsToAnalysis) + { + Add("neris.analysis.section.unknown", RmsValidationSeverity.Error, kind.ToString(), $"'{kind}' is not a section of the incident analysis."); + continue; + } + + if (!string.IsNullOrWhiteSpace(module.DetailJson) && !ParsesAsObject(module.DetailJson)) + Add("neris.analysis.section.detail", RmsValidationSeverity.Error, descriptor.PayloadPath, $"The {kind} section body is not a JSON object and cannot be submitted."); + + var primarySet = NerisSectionRules.PrimaryCodeSetFor(kind); + if (primarySet != null && !string.IsNullOrWhiteSpace(module.PrimaryCode) && !catalog.Contains(primarySet, module.PrimaryCode)) + Add("neris.analysis.section.primary_code", RmsValidationSeverity.Error, descriptor.PayloadPath, $"'{module.PrimaryCode}' is not a NERIS {primarySet} value."); + } + return issues; } + private static readonly Regex BirthMonthYearPattern = new Regex(@"^\d{4}-(0[1-9]|1[0-2])$", RegexOptions.Compiled); + + private static IEnumerable Split(string csv) + { + if (string.IsNullOrWhiteSpace(csv)) + return Enumerable.Empty(); + + return csv.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(v => v.Trim()).Where(v => v.Length > 0); + } + + private static bool ParsesAsObject(string json) + { + try + { + Newtonsoft.Json.Linq.JObject.Parse(json); + return true; + } + catch (Newtonsoft.Json.JsonReaderException) + { + return false; + } + } + + public IReadOnlyList GetSectionRequirements(IEnumerable incidentTypeCodes) + { + return NerisSectionRules.For(incidentTypeCodes) + .Select(r => new NerisSectionRequirement + { + Kind = r.Kind, Required = r.Required, Reason = r.Reason, + PrimaryCodeSet = NerisSectionRules.PrimaryCodeSetFor(r.Kind), SecondaryCodeSet = NerisSectionRules.SecondaryCodeSetFor(r.Kind) + }) + .ToList(); + } + public async Task> ValidateRemoteAsync(RmsNerisProfile profile, string payloadJson, CancellationToken cancellationToken = default) { var credential = await _profiles.GetCredentialAsync(profile); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 60514564..b514f3ce 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -332,6 +332,20 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-3 conditional sections, restricted casualty/exposure classes and the incident-analysis filing (registry M0167-M0168) + 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(); + // RMS-3 due state and legal holds (registry M0170) + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 311e9da8..d1acc71b 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -285,6 +285,20 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-3 conditional sections, restricted casualty/exposure classes and the incident-analysis filing (registry M0167-M0168) + 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(); + // RMS-3 due state and legal holds (registry M0170) + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs index 9c043978..e2519d4a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -57,38 +57,82 @@ public Task GetByNerisIncidentIdAsync(int departmentId, strin new { DepartmentId = departmentId, NerisId = nerisIncidentId }); } + public Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take) + { + // Closed states only: an open filing is never a retention candidate, however old. + var states = new[] { (int)RmsRecordState.Finalized, (int)RmsRecordState.Amended, (int)RmsRecordState.Accepted, (int)RmsRecordState.Voided, (int)RmsRecordState.Cancelled }; + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("States", InListValue(states)); + parameters.Add("Cutoff", cutoffUtc); + parameters.Add("Skip", 0); + parameters.Add("Take", Math.Clamp(take, 1, 10000)); + return QueryAsync( + $@"SELECT * FROM {Tbl("RmsIncidentReports")} + WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("State", "States")} + AND {Col("FinalizedOn")} IS NOT NULL AND {Col("FinalizedOn")} < {P}Cutoff AND {Col("DeletedOn")} IS NULL + ORDER BY {Col("FinalizedOn")} {Paging()}", parameters); + } + + /// + /// The predicate for a report query. Columns are aliased to r so the group-scope EXISTS can correlate + /// back to the outer row: RmsRecordGroupScopes also has a DepartmentId, and an unqualified reference inside + /// the subquery would bind to the subquery's own column and quietly drop the department correlation. + /// private (string where, DynamicParameters parameters) Filter(int departmentId, RmsIncidentReportQuery query) { - var where = new StringBuilder($"{Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL"); + const string R = "r"; + string C(string column) => R + "." + Col(column); + + var where = new StringBuilder($"{C("DepartmentId")} = {P}DepartmentId AND {C("DeletedOn")} IS NULL"); var parameters = new DynamicParameters(); parameters.Add("DepartmentId", departmentId); if (query.States != null && query.States.Count > 0) { - where.Append($" AND {InList("State", "States")}"); + where.Append($" AND {InList("State", "States", R)}"); parameters.Add("States", InListValue(query.States)); } if (query.Year.HasValue) { - where.Append($" AND {YearOf(Col("CreatedOn"))} = {P}Year"); + where.Append($" AND {YearOf(C("CreatedOn"))} = {P}Year"); parameters.Add("Year", query.Year.Value); } if (query.CallId.HasValue) { - where.Append($" AND {Col("CallId")} = {P}CallId"); + where.Append($" AND {C("CallId")} = {P}CallId"); parameters.Add("CallId", query.CallId.Value); } if (!string.IsNullOrWhiteSpace(query.OwnerUserId)) { - where.Append($" AND {Col("OwnerUserId")} = {P}OwnerUserId"); + where.Append($" AND {C("OwnerUserId")} = {P}OwnerUserId"); parameters.Add("OwnerUserId", query.OwnerUserId); } if (query.StationGroupId.HasValue) { - where.Append($" AND {Col("StationGroupId")} = {P}StationGroupId"); + where.Append($" AND {C("StationGroupId")} = {P}StationGroupId"); parameters.Add("StationGroupId", query.StationGroupId.Value); } + if (query.VisibleGroupIds != null) + { + // Mirrors RecordsAuthorizationService.CanUserViewRecordAsync for an incident report: always visible to + // the author, owner or reviewer, otherwise only through an intersecting group scope. A member in no + // groups still sees their own reports, which is why the group clause is appended conditionally. + var viewer = query.ViewerUserId ?? string.Empty; + where.Append(" AND (") + .Append($"{C("AuthorUserId")} = {P}Viewer OR {C("OwnerUserId")} = {P}Viewer OR {C("ReviewerUserId")} = {P}Viewer"); + parameters.Add("Viewer", viewer); + + if (query.VisibleGroupIds.Count > 0) + { + where.Append($" OR EXISTS (SELECT 1 FROM {Tbl("RmsRecordGroupScopes")} s WHERE s.{Col("DepartmentId")} = {C("DepartmentId")} AND s.{Col("RecordId")} = {C("RmsIncidentReportId")} AND {InList("DepartmentGroupId", "VisibleGroupIds", "s")})"); + parameters.Add("VisibleGroupIds", InListValue(query.VisibleGroupIds)); + } + + where.Append(")"); + } + return (where.ToString(), parameters); } @@ -99,13 +143,13 @@ public Task> QueryAsync(int departmentId, RmsInci parameters.Add("Skip", Math.Max(0, query.Skip)); parameters.Add("Take", Math.Clamp(query.Take, 1, 10000)); return QueryAsync( - $"SELECT * FROM {Tbl("RmsIncidentReports")} WHERE {where} ORDER BY {Col("CreatedOn")} DESC {Paging()}", parameters); + $"SELECT r.* FROM {Tbl("RmsIncidentReports")} r WHERE {where} ORDER BY r.{Col("CreatedOn")} DESC {Paging()}", parameters); } public Task CountAsync(int departmentId, RmsIncidentReportQuery query) { var (where, parameters) = Filter(departmentId, query ?? new RmsIncidentReportQuery()); - return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsIncidentReports")} WHERE {where}", parameters); + return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsIncidentReports")} r WHERE {where}", parameters); } public Task> GetYearsAsync(int departmentId) @@ -198,6 +242,83 @@ public class RmsNarrativesRepository : RmsIncidentChildRepository, public RmsNarrativesRepository(IConnectionProvider c, SqlConfiguration s, IUnitOfWork u, IQueryFactory q) : base("RmsNarratives", "CreatedOn", c, s, u, q) { } } + public class RmsIncidentModulesRepository : RmsIncidentChildRepository, IRmsIncidentModulesRepository + { + public RmsIncidentModulesRepository(IConnectionProvider c, SqlConfiguration s, IUnitOfWork u, IQueryFactory q) : base("RmsIncidentModules", "Ordinal", c, s, u, q) { } + + public Task> GetForRecordByKindAsync(int departmentId, string recordId, string revisionId, RmsIncidentModuleKind kind) + { + var revisionClause = revisionId == null ? $"{Col("RevisionId")} IS NULL" : $"{Col("RevisionId")} = {P}RevisionId"; + return QueryAsync( + $"SELECT * FROM {Tbl("RmsIncidentModules")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {revisionClause} AND {Col("ModuleKind")} = {P}ModuleKind ORDER BY {Col("Ordinal")}", + new { DepartmentId = departmentId, RecordId = recordId, RevisionId = revisionId, ModuleKind = (int)kind }); + } + } + + public class RmsIncidentResourcesRepository : RmsIncidentChildRepository, IRmsIncidentResourcesRepository + { + public RmsIncidentResourcesRepository(IConnectionProvider c, SqlConfiguration s, IUnitOfWork u, IQueryFactory q) : base("RmsIncidentResources", "Ordinal", c, s, u, q) { } + } + + public class RmsCasualtyRescuesRepository : RmsIncidentChildRepository, IRmsCasualtyRescuesRepository + { + public RmsCasualtyRescuesRepository(IConnectionProvider c, SqlConfiguration s, IUnitOfWork u, IQueryFactory q) : base("RmsCasualtyRescues", "Ordinal", c, s, u, q) { } + } + + public class RmsExposuresRepository : RmsIncidentChildRepository, IRmsExposuresRepository + { + public RmsExposuresRepository(IConnectionProvider c, SqlConfiguration s, IUnitOfWork u, IQueryFactory q) : base("RmsExposures", "Ordinal", c, s, u, q) { } + } + + public class RmsIncidentPropertiesRepository : RmsIncidentChildRepository, IRmsIncidentPropertiesRepository + { + public RmsIncidentPropertiesRepository(IConnectionProvider c, SqlConfiguration s, IUnitOfWork u, IQueryFactory q) : base("RmsIncidentProperties", "Ordinal", c, s, u, q) { } + } + + public class RmsIncidentVehiclesRepository : RmsIncidentChildRepository, IRmsIncidentVehiclesRepository + { + public RmsIncidentVehiclesRepository(IConnectionProvider c, SqlConfiguration s, IUnitOfWork u, IQueryFactory q) : base("RmsIncidentVehicles", "Ordinal", c, s, u, q) { } + } + + /// The separate incident-analysis filing (registry M0167): one per incident report, own lifecycle and submissions. + public class RmsIncidentAnalysesRepository : RmsRepositoryBase, IRmsIncidentAnalysesRepository + { + public RmsIncidentAnalysesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string analysisId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentAnalysisId")} = {P}AnalysisId", + new { DepartmentId = departmentId, AnalysisId = analysisId }); + } + + public Task GetForReportAsync(int departmentId, string incidentReportId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IncidentReportId")} = {P}ReportId AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, ReportId = incidentReportId }); + } + + public Task> GetAwaitingIncidentAsync(int departmentId, int take) + { + // Finalized but never filed: the incident had no NERIS id when the analysis was finalized. + return QueryAsync( + $@"SELECT * FROM {Tbl("RmsIncidentAnalyses")} + WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {P}State + AND {Col("NerisAnalysisId")} IS NULL AND {Col("DeletedOn")} IS NULL + ORDER BY {Col("FinalizedOn")} {Paging()}", + new { DepartmentId = departmentId, State = (int)RmsIncidentAnalysisState.Finalized, Skip = 0, Take = Math.Clamp(take, 1, 1000) }); + } + + public Task CountByStateAsync(int departmentId, RmsIncidentAnalysisState state) + { + return ScalarAsync( + $"SELECT COUNT(1) FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {P}State AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, State = (int)state }); + } + } + public class RmsValidationIssuesRepository : RmsRepositoryBase, IRmsValidationIssuesRepository { public RmsValidationIssuesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) @@ -276,9 +397,11 @@ public async Task> ClaimDueBatchAsync(string leaseOwn return claimed; } - public Task CountByStateAsync(int state) + public Task CountByStateAsync(int departmentId, int state) { - return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsSubmissions")} WHERE {Col("State")} = {P}State", new { State = state }); + return ScalarAsync( + $"SELECT COUNT(1) FROM {Tbl("RmsSubmissions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {P}State", + new { DepartmentId = departmentId, State = state }); } public Task SupersedeOpenForRecordAsync(int departmentId, string recordId, string exceptSubmissionId, DateTime utcNow, CancellationToken cancellationToken = default) diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index 60c6172b..1fa9a8c3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data.Common; using System.Linq; @@ -209,6 +209,18 @@ public Task> GetFinalizedSinceAsync(int depart new { DepartmentId = departmentId, States = InListValue(states), Since = sinceUtc }); } + public Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take) + { + // Closed states only: a Record still being authored or reviewed is never a retention candidate, however old. + var states = new[] { (int)RmsRecordState.Finalized, (int)RmsRecordState.Amended, (int)RmsRecordState.Voided, (int)RmsRecordState.Cancelled }; + return QueryAsync( + $@"SELECT * FROM {Tbl("RmsOperationalRecords")} + WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("State", "States")} + AND {Col("FinalizedOn")} IS NOT NULL AND {Col("FinalizedOn")} < {P}Cutoff AND {Col("DeletedOn")} IS NULL + ORDER BY {Col("FinalizedOn")} {Paging()}", + new { DepartmentId = departmentId, States = InListValue(states), Cutoff = cutoffUtc, Skip = 0, Take = Math.Clamp(take, 1, 10000) }); + } + public Task CountAllAsync(int departmentId) { return ScalarAsync( @@ -406,6 +418,16 @@ public Task> GetMetadataForRecordAsync(int depa new { DepartmentId = departmentId, RecordId = recordId }); } + public Task> GetPendingScanAsync(int departmentId, int take) + { + // Metadata only: the rescan loads bytes one attachment at a time so a sweep never holds a batch of them. + return QueryAsync( + $@"SELECT {Cols(MetadataColumns)} FROM {Tbl("RmsRecordAttachments")} + WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ScanState")} = {P}ScanState AND {Col("DeletedOn")} IS NULL + ORDER BY {Col("UploadedOn")} {Paging()}", + new { DepartmentId = departmentId, ScanState = (int)RmsAttachmentScanState.Pending, Skip = 0, Take = Math.Clamp(take, 1, 1000) }); + } + public Task GetByIdForDepartmentAsync(int departmentId, string attachmentId) { return QueryFirstOrDefaultAsync( @@ -605,7 +627,7 @@ public Task> GetYearsAsync(int departmentId) /// Visibility is a join, never a post-filter, so paging counts cannot leak (plan section 5.7.1). /// The always-visible cases (author, owner, reviewer, named participant) resolve inside the query. /// - public Task> GetModifiedSinceAsync(int departmentId, DateTime? since, int take) + public Task> GetModifiedSinceAsync(int departmentId, DateTime? since, int take, string sinceId = null) { var parameters = new DynamicParameters(); parameters.Add("DepartmentId", departmentId); @@ -614,7 +636,18 @@ public Task> GetModifiedSinceAsync(int de var sinceClause = string.Empty; if (since.HasValue) { - sinceClause = $" AND {Col("ModifiedOn")} > {P}Since"; + // The predicate has to match the ordering, tie-breaker included, or the rows that share the cursor's + // timestamp and sort after it are dropped on the next page. + if (string.IsNullOrWhiteSpace(sinceId)) + { + sinceClause = $" AND {Col("ModifiedOn")} > {P}Since"; + } + else + { + sinceClause = $" AND ({Col("ModifiedOn")} > {P}Since OR ({Col("ModifiedOn")} = {P}Since AND {Col("RmsRecordSearchProjectionId")} > {P}SinceId))"; + parameters.Add("SinceId", sinceId); + } + parameters.Add("Since", since.Value); } @@ -801,4 +834,227 @@ public Task> GetForRecordAsync(int departmentId, str new { DepartmentId = departmentId, RecordId = recordId }); } } + + /// + /// Immutable evidence artifacts (registry M0169, RMS-3). No update path: a correction supersedes rather than + /// edits, so a stored checksum keeps meaning what it meant when it was attested to. + /// + public class RmsEvidenceArtifactsRepository : RmsRepositoryBase, IRmsEvidenceArtifactsRepository + { + public RmsEvidenceArtifactsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string artifactId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsEvidenceArtifacts")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsEvidenceArtifactId")} = {P}ArtifactId", + new { DepartmentId = departmentId, ArtifactId = artifactId }); + } + + public Task> GetForRecordAsync(int departmentId, string recordId, string revisionId, bool includeSuperseded) + { + var revisionClause = revisionId == null ? $"{Col("RevisionId")} IS NULL" : $"{Col("RevisionId")} = {P}RevisionId"; + var supersededClause = includeSuperseded ? string.Empty : $" AND {Col("SupersededOn")} IS NULL"; + return QueryAsync( + $@"SELECT * FROM {Tbl("RmsEvidenceArtifacts")} + WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {revisionClause} + AND {Col("DeletedOn")} IS NULL{supersededClause} + ORDER BY {Col("Kind")}, {Col("CapturedOn")}", + new { DepartmentId = departmentId, RecordId = recordId, RevisionId = revisionId }); + } + + public Task GetCurrentDraftOfKindAsync(int departmentId, string recordId, RmsEvidenceKind kind, string sourceEntityId) + { + // A source-scoped kind (a promoted chat set, one run card activation) can have several current + // artifacts, one per source row; a whole-record kind has at most one. + var sourceClause = sourceEntityId == null ? string.Empty : $" AND {Col("SourceEntityId")} = {P}SourceEntityId"; + return QueryFirstOrDefaultAsync( + $@"SELECT * FROM {Tbl("RmsEvidenceArtifacts")} + WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("RevisionId")} IS NULL + AND {Col("Kind")} = {P}Kind AND {Col("SupersededOn")} IS NULL AND {Col("DeletedOn")} IS NULL{sourceClause}", + new { DepartmentId = departmentId, RecordId = recordId, Kind = (int)kind, SourceEntityId = sourceEntityId }); + } + + public Task BindDraftToRevisionAsync(int departmentId, string recordId, string revisionId, DateTime utcNow, CancellationToken cancellationToken = default) + { + return ExecuteAsync( + $@"UPDATE {Tbl("RmsEvidenceArtifacts")} SET {Col("RevisionId")} = {P}RevisionId, {Col("ModifiedOn")} = {P}Now + WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("RevisionId")} IS NULL AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, RecordId = recordId, RevisionId = revisionId, Now = utcNow }, cancellationToken); + } + + public Task CountForRecordAsync(int departmentId, string recordId) + { + return ScalarAsync( + $"SELECT COUNT(1) FROM {Tbl("RmsEvidenceArtifacts")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, RecordId = recordId }); + } + } + + /// Due state per (Record, obligation) — registry M0170, RMS-3. + public class RmsRecordDueStatesRepository : RmsRepositoryBase, IRmsRecordDueStatesRepository + { + public RmsRecordDueStatesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetAsync(int departmentId, string recordId, RmsRecordObligation obligation) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsRecordDueStates")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("Obligation")} = {P}Obligation", + new { DepartmentId = departmentId, RecordId = recordId, Obligation = (int)obligation }); + } + + public Task> GetForRecordAsync(int departmentId, string recordId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordDueStates")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId ORDER BY {Col("Obligation")}", + new { DepartmentId = departmentId, RecordId = recordId }); + } + + public Task> GetOpenForDepartmentAsync(int departmentId, int take) + { + return QueryAsync( + $@"SELECT * FROM {Tbl("RmsRecordDueStates")} + WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("LastEmittedState")} <> {P}Cleared + ORDER BY {Col("DueOn")} {Paging()}", + new { DepartmentId = departmentId, Cleared = (int)RmsDueState.Cleared, Skip = 0, Take = Math.Clamp(take, 1, 10000) }); + } + + public Task CountOverdueAsync(int departmentId) + { + return ScalarAsync( + $"SELECT COUNT(1) FROM {Tbl("RmsRecordDueStates")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("LastEmittedState")} = {P}Overdue", + new { DepartmentId = departmentId, Overdue = (int)RmsDueState.Overdue }); + } + + public Task ClearForRecordAsync(int departmentId, string recordId, DateTime utcNow, CancellationToken cancellationToken = default) + { + return ExecuteAsync( + $@"UPDATE {Tbl("RmsRecordDueStates")} SET {Col("LastEmittedState")} = {P}Cleared, {Col("ModifiedOn")} = {P}Now + WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("LastEmittedState")} <> {P}Cleared", + new { DepartmentId = departmentId, RecordId = recordId, Cleared = (int)RmsDueState.Cleared, Now = utcNow }, cancellationToken); + } + } + + /// Public-records requests — registry M0171, RMS-3. + public class RmsDisclosureRequestsRepository : RmsRepositoryBase, IRmsDisclosureRequestsRepository + { + public RmsDisclosureRequestsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string requestId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsDisclosureRequests")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsDisclosureRequestId")} = {P}RequestId", + new { DepartmentId = departmentId, RequestId = requestId }); + } + + public Task> GetForDepartmentAsync(int departmentId, IEnumerable states, int skip, int take) + { + var stateList = states?.ToList(); + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("Skip", Math.Max(0, skip)); + parameters.Add("Take", Math.Clamp(take, 1, 1000)); + + var where = $"{Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL"; + if (stateList != null && stateList.Count > 0) + { + where += $" AND {InList("State", "States")}"; + parameters.Add("States", InListValue(stateList)); + } + + // Oldest deadline first: the request closest to breaching its statutory clock is the one that matters. + return QueryAsync( + $"SELECT * FROM {Tbl("RmsDisclosureRequests")} WHERE {where} ORDER BY {Col("StatutoryDueOn")}, {Col("ReceivedOn")} {Paging()}", parameters); + } + + public Task CountByStateAsync(int departmentId, RmsDisclosureState state) + { + return ScalarAsync( + $"SELECT COUNT(1) FROM {Tbl("RmsDisclosureRequests")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {P}State AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, State = (int)state }); + } + + public Task CountOverdueAsync(int departmentId, DateTime utcNow) + { + return ScalarAsync( + $@"SELECT COUNT(1) FROM {Tbl("RmsDisclosureRequests")} + WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {Col("ClosedOn")} IS NULL + AND {Col("StatutoryDueOn")} IS NOT NULL AND {Col("StatutoryDueOn")} < {P}Now", + new { DepartmentId = departmentId, Now = utcNow }); + } + + public Task GetMaxRequestNumberSequenceAsync(int departmentId, string numberPrefix) + { + return ScalarAsync( + IsPostgres + ? $"SELECT COALESCE(MAX(CAST(SUBSTRING({Col("RequestNumber")} FROM '[0-9]+$') AS INTEGER)), 0) FROM {Tbl("RmsDisclosureRequests")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RequestNumber")} LIKE {P}Prefix" + : $"SELECT ISNULL(MAX(CAST(RIGHT({Col("RequestNumber")}, CHARINDEX('-', REVERSE({Col("RequestNumber")})) - 1) AS INT)), 0) FROM {Tbl("RmsDisclosureRequests")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RequestNumber")} LIKE {P}Prefix", + new { DepartmentId = departmentId, Prefix = numberPrefix + "%" }); + } + } + + /// Immutable produced sets — registry M0171, RMS-3. + public class RmsDisclosureProductionsRepository : RmsRepositoryBase, IRmsDisclosureProductionsRepository + { + public RmsDisclosureProductionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string productionId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsDisclosureProductions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsDisclosureProductionId")} = {P}ProductionId", + new { DepartmentId = departmentId, ProductionId = productionId }); + } + + public Task> GetForRequestAsync(int departmentId, string requestId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("RmsDisclosureProductions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DisclosureRequestId")} = {P}RequestId ORDER BY {Col("ProductionNumber")}", + new { DepartmentId = departmentId, RequestId = requestId }); + } + + public Task GetMaxProductionNumberAsync(int departmentId, string requestId) + { + return ScalarAsync( + $"SELECT COALESCE(MAX({Col("ProductionNumber")}), 0) FROM {Tbl("RmsDisclosureProductions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DisclosureRequestId")} = {P}RequestId", + new { DepartmentId = departmentId, RequestId = requestId }); + } + } + + /// Legal holds that suspend retention — registry M0170, RMS-3. + public class RmsRecordLegalHoldsRepository : RmsRepositoryBase, IRmsRecordLegalHoldsRepository + { + public RmsRecordLegalHoldsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string holdId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsRecordLegalHolds")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordLegalHoldId")} = {P}HoldId", + new { DepartmentId = departmentId, HoldId = holdId }); + } + + public Task> GetActiveForDepartmentAsync(int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordLegalHolds")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ReleasedOn")} IS NULL ORDER BY {Col("PlacedOn")} DESC", + new { DepartmentId = departmentId }); + } + + public Task> GetForRecordAsync(int departmentId, string recordId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordLegalHolds")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId ORDER BY {Col("PlacedOn")} DESC", + new { DepartmentId = departmentId, RecordId = recordId }); + } + + public Task> GetAllForDepartmentAsync(int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordLegalHolds")} WHERE {Col("DepartmentId")} = {P}DepartmentId ORDER BY {Col("PlacedOn")} DESC", + new { DepartmentId = departmentId }); + } + } } diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index f168e909..c2360095 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, @@ -61,5 +61,6 @@ "RecordSubmissionQueued": 108, "RecordSubmissionAccepted": 109, "RecordSubmissionRejected": 110, - "RecordSubmissionFailed": 111 + "RecordSubmissionFailed": 111, + "RecordOverdue": 112 } diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs index cbec9f23..459dfb83 100644 --- a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs +++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -114,6 +114,15 @@ private static Dictionary Load(string path) "Records|it|Checksum", // Italian keeps the technical term. "Records|sv|Definition", "Records|sv|Destination", "Records|sv|Revision", "Records|sv|Start", "Records|de|SearchOnline", "Records|de|SearchOffline", // "Online"/"Offline" are the German words too. + + // RMS-3 screens: same word in the target language, checked side by side. + "Records|de|DefinitionKey", // "Definition" is the German word too. + "Records|sv|DefinitionKey", // So is the Swedish one. + "Records|de|RequesterOrganization", // "Organisation" is German as well. + "Records|sv|RequesterOrganization", // And Swedish. + "Records|es|Error", // "Error" is the Spanish word. + "Records|fr|EvidenceSource", // "Source" is French to begin with. + "Records|pl|Model", // Polish spells it "Model" as well. "Records|pl|SearchOnline", "Records|pl|SearchOffline", // Polish uses them verbatim. "Records|sv|SearchOnline", "Records|sv|SearchOffline", // So does Swedish. diff --git a/Tests/Resgrid.Tests/Providers/NerisApiClientTests.cs b/Tests/Resgrid.Tests/Providers/NerisApiClientTests.cs index f49571f5..e27fad1c 100644 --- a/Tests/Resgrid.Tests/Providers/NerisApiClientTests.cs +++ b/Tests/Resgrid.Tests/Providers/NerisApiClientTests.cs @@ -53,7 +53,12 @@ public async Task Create_sends_the_payload_verbatim_with_a_bearer_token_and_read var token = _handler.Requests[0]; token.Method.Should().Be("POST"); token.Path.Should().Be("/v1/token"); - token.Body.Should().Contain("grant_type=client_credentials").And.Contain("username=client").And.Contain("password=secret"); + // A client-credentials integration account authenticates with HTTP Basic. The pinned contract's TokenBody + // declares username/password for the password and MFA flows only, so sending the id and secret there is + // rejected by the destination. + token.Body.Should().Contain("grant_type=client_credentials"); + token.Body.Should().NotContain("username").And.NotContain("password="); + token.Authorization.Should().Be("Basic " + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("client:secret"))); var create = _handler.Requests[1]; create.Authorization.Should().Be("Bearer tok-1"); diff --git a/Tests/Resgrid.Tests/Providers/NerisMappingTests.cs b/Tests/Resgrid.Tests/Providers/NerisMappingTests.cs index e8c569de..4e49f0b1 100644 --- a/Tests/Resgrid.Tests/Providers/NerisMappingTests.cs +++ b/Tests/Resgrid.Tests/Providers/NerisMappingTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -55,7 +55,46 @@ public static NerisIncidentSnapshot Snapshot() Tactics = new List { new RmsActionTactic { TacticCode = "COMMAND_AND_CONTROL||ESTABLISH_INCIDENT_COMMAND", OccurredOn = t0.AddMinutes(7), Ordinal = 0 } }, Narrative = new RmsNarrative { Narrative = "Dumpster fire extinguished with one line.", ImpedimentNarrative = "None", OutcomeNarrative = "Fire out, no extension.", SupplementalJson = "{\"dept_only\":\"never sent\"}" }, DispatchComments = new List { new NerisDispatchComment { Timestamp = t0.AddSeconds(30), Comment = "Caller reports flames behind store" } }, - SpecialModifiers = new List { "MCI" } + SpecialModifiers = new List { "MCI" }, + // RMS-3 conditional sections. The primary type is an outside fire, so the progressive rules demand + // the fire section and the outside-fire location detail; both are present here. + Modules = new List + { + new RmsIncidentModule + { + ModuleKind = (int)RmsIncidentModuleKind.Fire, SchemaName = "FirePayload", PrimaryCode = "HYDRANT_GREATER_500", SecondaryCode = "NO_CAUSE_OBVIOUS", Ordinal = 0, + DetailJson = "{\"water_supply\":\"HYDRANT_GREATER_500\",\"investigation_needed\":\"NO_CAUSE_OBVIOUS\",\"investigation_types\":[]}" + }, + new RmsIncidentModule + { + ModuleKind = (int)RmsIncidentModuleKind.OutsideFireLocation, SchemaName = "OutsideFireLocationDetailPayload", PrimaryCode = "DEBRIS_OPEN_BURNING", Quantity = 0.1m, Ordinal = 1, + DetailJson = "{\"type\":\"OUTSIDE\",\"acres_burned\":0.1,\"cause\":\"DEBRIS_OPEN_BURNING\"}" + } + }, + Resources = new List { new RmsIncidentResource { ResourceCode = "FOAM_CLASS_A", Quantity = 5, Detail = "gallons", Ordinal = 0 } }, + Exposures = new List + { + new RmsExposure + { + LocationKind = "EXTERNAL", ItemType = "STRUCTURE", DamageType = "MINOR_DAMAGE", LocationUse = "COMMERCIAL||RETAIL_WHOLESALE_TRADE", + PeoplePresent = false, DisplacementCount = 0, DisplacementCausesCsv = "SMOKE", Street = "102 Main St", Municipality = "Springfield", State = "IL", Ordinal = 0 + } + }, + Casualties = new List + { + new RmsCasualtyRescue + { + Kind = (int)RmsCasualtyRescueKind.Casualty, PersonType = RmsCasualtyPersonTypes.Firefighter, WasInjured = true, + CasualtyCause = "EXPOSURE", CasualtyAction = "ADVANCING_OPERATING_HOSELINE", CasualtyTimeline = "INITIAL_RESPONSE", + DutyType = "WORKING_AT_SCENE_OF_FIRE_INCIDENT", PpeCsv = "HELMET,GLOVES", Gender = "MALE", Race = "ASIAN", BirthMonthYear = "1990-04", Ordinal = 0 + }, + new RmsCasualtyRescue + { + Kind = (int)RmsCasualtyRescueKind.Rescue, PersonType = RmsCasualtyPersonTypes.Civilian, RescueType = "RESCUED_BY_FIREFIGHTER", + RescueActionsCsv = "NONE", RescueImpedimentsCsv = "NONE", RescueMode = "REMOVAL_FROM_STRUCTURE", RescuePath = "REMOVAL_ALONG_PRIMARY_PATH", + RescueElevation = "ON_FLOOR", Ordinal = 1 + } + } }; } @@ -197,8 +236,13 @@ private static void CheckAgainst(JObject spec, JObject propertySchema, JToken va if (alternatives != null) { // Pick the alternative that matches the token shape (object ref, array, or scalar); null alternatives never apply to emitted values. + // Several of the contract's unions are discriminated on a "type" const/enum, so prefer the branch + // whose discriminator the payload actually carries — otherwise every union silently checks against + // its first branch and the gate proves nothing. var candidates = alternatives.OfType().Where(a => (string)a["type"] != "null").ToList(); - var matching = candidates.FirstOrDefault(a => Matches(a, value)) ?? candidates.FirstOrDefault(); + var matching = candidates.FirstOrDefault(a => Discriminates(spec, a, value)) + ?? candidates.FirstOrDefault(a => Matches(a, value)) + ?? candidates.FirstOrDefault(); if (matching != null) CheckAgainst(spec, matching, value, path, problems); return; @@ -217,6 +261,30 @@ private static void CheckAgainst(JObject spec, JObject propertySchema, JToken va } } + /// True when the alternative is a schema whose "type" const/enum equals the value's own "type". + private static bool Discriminates(JObject spec, JObject alternative, JToken value) + { + var refName = RefName(alternative); + if (refName == null || !(value is JObject obj)) + return false; + + var discriminator = (string)obj["type"]; + if (discriminator == null) + return false; + + var schema = (JObject)spec["components"]?["schemas"]?[refName]; + var typeSchema = (JObject)schema?["properties"]?["type"]; + if (typeSchema == null) + return false; + + var constant = (string)typeSchema["const"]; + if (constant != null) + return constant == discriminator; + + var values = (JArray)typeSchema["enum"]; + return values != null && values.Select(v => (string)v).Contains(discriminator); + } + private static bool Matches(JObject alternative, JToken value) { var type = (string)alternative["type"]; diff --git a/Tests/Resgrid.Tests/Providers/NerisValidationTests.cs b/Tests/Resgrid.Tests/Providers/NerisValidationTests.cs index 1091608f..8554b306 100644 --- a/Tests/Resgrid.Tests/Providers/NerisValidationTests.cs +++ b/Tests/Resgrid.Tests/Providers/NerisValidationTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentAssertions; @@ -31,6 +31,30 @@ public void A_complete_report_has_no_errors() issues.Where(i => i.Severity == (int)RmsValidationSeverity.Error).Should().BeEmpty(string.Join("; ", issues.Select(i => i.RuleKey))); } + [Test] + public void A_latitude_without_a_longitude_is_reported_rather_than_throwing() + { + var snapshot = NerisMappingTests.Snapshot(); + snapshot.Location.Latitude = 41.88m; + snapshot.Location.Longitude = null; + + var issues = Service().ValidateLocal(snapshot, NerisMappingTests.Profile()); + + issues.Select(i => i.RuleKey).Should().Contain("neris.location.point"); + issues.Select(i => i.RuleKey).Should().NotContain("neris.location.point.range", + "the range check has nothing to compare and must not dereference the missing coordinate"); + } + + [Test] + public void Both_coordinates_present_and_out_of_range_is_still_reported() + { + var snapshot = NerisMappingTests.Snapshot(); + snapshot.Location.Latitude = 91m; + snapshot.Location.Longitude = 10m; + + Service().ValidateLocal(snapshot, NerisMappingTests.Profile()).Select(i => i.RuleKey).Should().Contain("neris.location.point.range"); + } + [Test] public void Missing_mandatory_facts_are_reported_with_stable_rule_keys() { diff --git a/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs b/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs index 44ff3d74..eb7cd8b7 100644 --- a/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs +++ b/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -30,6 +30,13 @@ public sealed class FakeIncidentStore public List Issues { get; } = new List(); public List Submissions { get; } = new List(); public List Signatures { get; } = new List(); + public List Modules { get; } = new List(); + public List Resources { get; } = new List(); + public List Casualties { get; } = new List(); + public List Exposures { get; } = new List(); + public List Analyses { get; } = new List(); + public List Properties { get; } = new List(); + public List Vehicles { get; } = new List(); public Mock ReportsRepo { get; } = new Mock(); public Mock FactsRepo { get; } = new Mock(); @@ -42,6 +49,13 @@ public sealed class FakeIncidentStore public Mock IssuesRepo { get; } = new Mock(); public Mock SubmissionsRepo { get; } = new Mock(); public Mock SignaturesRepo { get; } = new Mock(); + public Mock ModulesRepo { get; } = new Mock(); + public Mock ResourcesRepo { get; } = new Mock(); + public Mock CasualtiesRepo { get; } = new Mock(); + public Mock ExposuresRepo { get; } = new Mock(); + public Mock AnalysesRepo { get; } = new Mock(); + public Mock PropertiesRepo { get; } = new Mock(); + public Mock VehiclesRepo { get; } = new Mock(); public List Revisions => Shared.Revisions; public List Scopes => Shared.Scopes; @@ -65,10 +79,11 @@ public FakeIncidentStore() .ReturnsAsync((int d, int call) => Reports.Where(x => x.DepartmentId == d && x.CallId == call).ToList()); ReportsRepo.Setup(r => r.GetByNerisIncidentIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((int d, string id) => Reports.FirstOrDefault(x => x.DepartmentId == d && x.NerisIncidentId == id)); + // The state filter is honoured: a fake that ignores it would let a queue-count bug pass unnoticed. ReportsRepo.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync((int d, RmsIncidentReportQuery q) => Reports.Where(x => x.DepartmentId == d && x.DeletedOn == null).ToList()); + .ReturnsAsync((int d, RmsIncidentReportQuery q) => MatchReports(d, q).ToList()); ReportsRepo.Setup(r => r.CountAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync((int d, RmsIncidentReportQuery q) => Reports.Count(x => x.DepartmentId == d && x.DeletedOn == null)); + .ReturnsAsync((int d, RmsIncidentReportQuery q) => MatchReports(d, q).Count()); ReportsRepo.Setup(r => r.GetYearsAsync(It.IsAny())) .ReturnsAsync((int d) => Reports.Where(x => x.DepartmentId == d).Select(x => (x.CallCreatedOn ?? x.CreatedOn).Year).Distinct().ToList()); ReportsRepo.Setup(r => r.GetMaxRecordNumberSequenceAsync(It.IsAny(), It.IsAny())) @@ -93,6 +108,31 @@ public FakeIncidentStore() Wire(AidsRepo, Aids, x => x.RmsAidId, x => x.DepartmentId, x => x.RecordId, x => x.RevisionId); Wire(LocationsRepo, Locations, x => x.RmsLocationId, x => x.DepartmentId, x => x.RecordId, x => x.RevisionId); Wire(NarrativesRepo, Narratives, x => x.RmsNarrativeId, x => x.DepartmentId, x => x.RecordId, x => x.RevisionId); + Wire(ModulesRepo, Modules, x => x.RmsIncidentModuleId, x => x.DepartmentId, x => x.RecordId, x => x.RevisionId); + Wire(ResourcesRepo, Resources, x => x.RmsIncidentResourceId, x => x.DepartmentId, x => x.RecordId, x => x.RevisionId); + Wire(CasualtiesRepo, Casualties, x => x.RmsCasualtyRescueId, x => x.DepartmentId, x => x.RecordId, x => x.RevisionId); + Wire(ExposuresRepo, Exposures, x => x.RmsExposureId, x => x.DepartmentId, x => x.RecordId, x => x.RevisionId); + Wire(PropertiesRepo, Properties, x => x.RmsIncidentPropertyId, x => x.DepartmentId, x => x.RecordId, x => x.RevisionId); + Wire(VehiclesRepo, Vehicles, x => x.RmsIncidentVehicleId, x => x.DepartmentId, x => x.RecordId, x => x.RevisionId); + + ModulesRepo.Setup(r => r.GetForRecordByKindAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string rec, string rev, RmsIncidentModuleKind kind) => (IEnumerable)Modules + .Where(x => x.DepartmentId == d && x.RecordId == rec && x.RevisionId == rev && x.ModuleKind == (int)kind).OrderBy(x => x.Ordinal).ToList()); + + // Incident analysis (RMS-3): its own aggregate root, one per report + AnalysesRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsIncidentAnalysis e, CancellationToken c, bool f) => { Analyses.Add(e); return e; }); + AnalysesRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsIncidentAnalysis e, CancellationToken c, bool f) => { Analyses.RemoveAll(x => x.RmsIncidentAnalysisId == e.RmsIncidentAnalysisId); Analyses.Add(e); return e; }); + AnalysesRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => Analyses.FirstOrDefault(x => x.DepartmentId == d && x.RmsIncidentAnalysisId == id)); + AnalysesRepo.Setup(r => r.GetForReportAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string reportId) => Analyses.FirstOrDefault(x => x.DepartmentId == d && x.IncidentReportId == reportId && x.DeletedOn == null)); + AnalysesRepo.Setup(r => r.GetAwaitingIncidentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, int take) => (IEnumerable)Analyses + .Where(x => x.DepartmentId == d && x.State == (int)RmsIncidentAnalysisState.Finalized && x.NerisAnalysisId == null && x.DeletedOn == null).Take(take).ToList()); + AnalysesRepo.Setup(r => r.CountByStateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, RmsIncidentAnalysisState state) => Analyses.Count(x => x.DepartmentId == d && x.State == (int)state && x.DeletedOn == null)); // Validation issues: a run replaces every issue of its source IssuesRepo.Setup(r => r.GetForRecordAsync(It.IsAny(), It.IsAny())) @@ -123,8 +163,8 @@ public FakeIncidentStore() .ReturnsAsync((int d, string id) => Submissions.Where(x => x.DepartmentId == d && x.RecordId == id).OrderByDescending(x => x.QueuedOn).ToList()); SubmissionsRepo.Setup(r => r.GetByIdempotencyKeyAsync(It.IsAny())) .ReturnsAsync((string key) => Submissions.FirstOrDefault(x => x.IdempotencyKey == key)); - SubmissionsRepo.Setup(r => r.CountByStateAsync(It.IsAny())) - .ReturnsAsync((int state) => Submissions.Count(x => x.State == state)); + SubmissionsRepo.Setup(r => r.CountByStateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, int state) => Submissions.Count(x => x.DepartmentId == d && x.State == state)); SubmissionsRepo.Setup(r => r.SupersedeOpenForRecordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((int d, string id, string except, DateTime now, CancellationToken c) => { @@ -162,6 +202,20 @@ public FakeIncidentStore() .ReturnsAsync((int d, string rev, RmsSignatureIntent intent) => Signatures.FirstOrDefault(x => x.DepartmentId == d && x.RevisionId == rev && x.Intent == (int)intent)); } + private IEnumerable MatchReports(int departmentId, RmsIncidentReportQuery query) + { + var rows = Reports.Where(x => x.DepartmentId == departmentId && x.DeletedOn == null); + if (query?.States != null && query.States.Count > 0) + rows = rows.Where(x => query.States.Contains(x.State)); + if (query?.CallId != null) + rows = rows.Where(x => x.CallId == query.CallId.Value); + if (!string.IsNullOrWhiteSpace(query?.OwnerUserId)) + rows = rows.Where(x => x.OwnerUserId == query.OwnerUserId); + if (query?.StationGroupId != null) + rows = rows.Where(x => x.StationGroupId == query.StationGroupId.Value); + return rows; + } + public static bool IsOpen(int state) { return state == (int)RmsSubmissionState.Queued || state == (int)RmsSubmissionState.InFlight || state == (int)RmsSubmissionState.AwaitingDestination; diff --git a/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs b/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs index 2be3b0ff..ef195c3f 100644 --- a/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs +++ b/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -29,6 +29,11 @@ public sealed class FakeRmsStore public List Outbox { get; } = new List(); public List Cutovers { get; } = new List(); public List CutoverEvents { get; } = new List(); + public List DueStates { get; } = new List(); + public List LegalHolds { get; } = new List(); + public List EvidenceArtifacts { get; } = new List(); + public List DisclosureRequests { get; } = new List(); + public List DisclosureProductions { get; } = new List(); public Mock RecordsRepo { get; } = new Mock(); public Mock DetailsRepo { get; } = new Mock(); @@ -43,6 +48,11 @@ public sealed class FakeRmsStore public Mock OutboxRepo { get; } = new Mock(); public Mock CutoversRepo { get; } = new Mock(); public Mock CutoverEventsRepo { get; } = new Mock(); + public Mock DueStatesRepo { get; } = new Mock(); + public Mock LegalHoldsRepo { get; } = new Mock(); + public Mock EvidenceRepo { get; } = new Mock(); + public Mock DisclosureRequestsRepo { get; } = new Mock(); + public Mock DisclosureProductionsRepo { get; } = new Mock(); public Mock UnitOfWork { get; } = new Mock(); public int Commits { get; private set; } @@ -85,6 +95,22 @@ public FakeRmsStore() RecordsRepo.Setup(r => r.GetByOwnerAndStatesAsync(It.IsAny(), It.IsAny(), It.IsAny>())) .ReturnsAsync((int d, string owner, IEnumerable states) => Records.Where(x => x.DepartmentId == d && x.OwnerUserId == owner && states.Contains(x.State)).ToList()); + RecordsRepo.Setup(r => r.GetByDepartmentAndStatesAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, IEnumerable states, int? year, int skip, int take) => + { + var list = states?.ToList(); + return (IEnumerable)Records + .Where(x => x.DepartmentId == d && x.DeletedOn == null + && (list == null || list.Count == 0 || list.Contains(x.State)) + && (!year.HasValue || (x.StartedOn ?? x.CreatedOn).Year == year.Value)) + .OrderByDescending(x => x.CreatedOn).Skip(skip).Take(take).ToList(); + }); + RecordsRepo.Setup(r => r.CountByDepartmentAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((int d, IEnumerable states) => + { + var list = states?.ToList(); + return Records.Count(x => x.DepartmentId == d && x.DeletedOn == null && (list == null || list.Count == 0 || list.Contains(x.State))); + }); RecordsRepo.Setup(r => r.GetOpenAsync(It.IsAny())) .ReturnsAsync((int d) => Records.Where(x => x.DepartmentId == d && (x.State == 1 || x.State == 2 || x.State == 3 || x.State == 4)).ToList()); RecordsRepo.Setup(r => r.GetFinalizedSinceAsync(It.IsAny(), It.IsAny())) @@ -204,6 +230,116 @@ public FakeRmsStore() .ReturnsAsync((int d) => Cutovers.FirstOrDefault(x => x.DepartmentId == d)); CutoverEventsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RmsDepartmentCutoverEvent e, CancellationToken c, bool f) => { CutoverEvents.Add(e); return e; }); + CutoversRepo.Setup(r => r.GetActiveAsync()) + .ReturnsAsync(() => Cutovers.Where(x => x.State == (int)RmsDepartmentCutoverState.Active).ToList()); + + // RMS-3 retention candidates and the Pending-attachment rescan queue + RecordsRepo.Setup(r => r.GetRetentionCandidatesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, DateTime cutoff, int take) => Records + .Where(x => x.DepartmentId == d && x.DeletedOn == null && x.FinalizedOn.HasValue && x.FinalizedOn < cutoff) + .OrderBy(x => x.FinalizedOn).Take(take).ToList()); + AttachmentsRepo.Setup(r => r.GetPendingScanAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, int take) => Attachments + .Where(x => x.DepartmentId == d && x.DeletedOn == null && x.ScanState == (int)RmsAttachmentScanState.Pending) + .OrderBy(x => x.UploadedOn).Take(take).ToList()); + + // RMS-3 due states (M0170): one row per (record, obligation) is what the emit-once guarantee rests on. + DueStatesRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordDueState e, CancellationToken c, bool f) => { DueStates.Add(e); return e; }); + DueStatesRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordDueState e, CancellationToken c, bool f) => { DueStates.RemoveAll(x => x.RmsRecordDueStateId == e.RmsRecordDueStateId); DueStates.Add(e); return e; }); + DueStatesRepo.Setup(r => r.GetAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, RmsRecordObligation o) => DueStates.FirstOrDefault(x => x.DepartmentId == d && x.RecordId == id && x.Obligation == (int)o)); + DueStatesRepo.Setup(r => r.GetForRecordAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => DueStates.Where(x => x.DepartmentId == d && x.RecordId == id).ToList()); + DueStatesRepo.Setup(r => r.GetOpenForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, int take) => DueStates.Where(x => x.DepartmentId == d && x.LastEmittedState != (int)RmsDueState.Cleared).Take(take).ToList()); + DueStatesRepo.Setup(r => r.CountOverdueAsync(It.IsAny())) + .ReturnsAsync((int d) => DueStates.Count(x => x.DepartmentId == d && x.LastEmittedState == (int)RmsDueState.Overdue)); + DueStatesRepo.Setup(r => r.ClearForRecordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, DateTime now, CancellationToken c) => + { + var rows = DueStates.Where(x => x.DepartmentId == d && x.RecordId == id && x.LastEmittedState != (int)RmsDueState.Cleared).ToList(); + foreach (var row in rows) { row.LastEmittedState = (int)RmsDueState.Cleared; row.ModifiedOn = now; } + return rows.Count; + }); + + // RMS-3 legal holds (M0170) + LegalHoldsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordLegalHold e, CancellationToken c, bool f) => { LegalHolds.Add(e); return e; }); + LegalHoldsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsRecordLegalHold e, CancellationToken c, bool f) => { LegalHolds.RemoveAll(x => x.RmsRecordLegalHoldId == e.RmsRecordLegalHoldId); LegalHolds.Add(e); return e; }); + LegalHoldsRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => LegalHolds.FirstOrDefault(x => x.DepartmentId == d && x.RmsRecordLegalHoldId == id)); + LegalHoldsRepo.Setup(r => r.GetActiveForDepartmentAsync(It.IsAny())) + .ReturnsAsync((int d) => LegalHolds.Where(x => x.DepartmentId == d && x.ReleasedOn == null).ToList()); + LegalHoldsRepo.Setup(r => r.GetForRecordAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => LegalHolds.Where(x => x.DepartmentId == d && x.RecordId == id).ToList()); + LegalHoldsRepo.Setup(r => r.GetAllForDepartmentAsync(It.IsAny())) + .ReturnsAsync((int d) => LegalHolds.Where(x => x.DepartmentId == d).ToList()); + + // RMS-3c evidence artifacts (M0169): insert and supersede only; there is no update-in-place path. + EvidenceRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsEvidenceArtifact e, CancellationToken c, bool f) => { EvidenceArtifacts.Add(e); return e; }); + EvidenceRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsEvidenceArtifact e, CancellationToken c, bool f) => { EvidenceArtifacts.RemoveAll(x => x.RmsEvidenceArtifactId == e.RmsEvidenceArtifactId); EvidenceArtifacts.Add(e); return e; }); + EvidenceRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => EvidenceArtifacts.FirstOrDefault(x => x.DepartmentId == d && x.RmsEvidenceArtifactId == id)); + EvidenceRepo.Setup(r => r.GetForRecordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string rec, string rev, bool includeSuperseded) => (IEnumerable)EvidenceArtifacts + .Where(x => x.DepartmentId == d && x.RecordId == rec && x.RevisionId == rev && x.DeletedOn == null && (includeSuperseded || x.SupersededOn == null)) + .OrderBy(x => x.Kind).ThenBy(x => x.CapturedOn).ToList()); + EvidenceRepo.Setup(r => r.GetCurrentDraftOfKindAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string rec, RmsEvidenceKind kind, string sourceId) => EvidenceArtifacts.FirstOrDefault(x => + x.DepartmentId == d && x.RecordId == rec && x.RevisionId == null && x.Kind == (int)kind + && x.SupersededOn == null && x.DeletedOn == null && (sourceId == null || x.SourceEntityId == sourceId))); + EvidenceRepo.Setup(r => r.BindDraftToRevisionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string rec, string rev, DateTime now, CancellationToken c) => + { + var rows = EvidenceArtifacts.Where(x => x.DepartmentId == d && x.RecordId == rec && x.RevisionId == null && x.DeletedOn == null).ToList(); + foreach (var row in rows) { row.RevisionId = rev; row.ModifiedOn = now; } + return rows.Count; + }); + EvidenceRepo.Setup(r => r.CountForRecordAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string rec) => EvidenceArtifacts.Count(x => x.DepartmentId == d && x.RecordId == rec && x.DeletedOn == null)); + + // RMS-3d disclosures (M0171) + DisclosureRequestsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsDisclosureRequest e, CancellationToken c, bool f) => { DisclosureRequests.Add(e); return e; }); + DisclosureRequestsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsDisclosureRequest e, CancellationToken c, bool f) => { DisclosureRequests.RemoveAll(x => x.RmsDisclosureRequestId == e.RmsDisclosureRequestId); DisclosureRequests.Add(e); return e; }); + DisclosureRequestsRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => DisclosureRequests.FirstOrDefault(x => x.DepartmentId == d && x.RmsDisclosureRequestId == id)); + DisclosureRequestsRepo.Setup(r => r.GetForDepartmentAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, IEnumerable states, int skip, int take) => + { + var list = states?.ToList(); + return (IEnumerable)DisclosureRequests + .Where(x => x.DepartmentId == d && x.DeletedOn == null && (list == null || list.Count == 0 || list.Contains(x.State))) + .OrderBy(x => x.StatutoryDueOn).Skip(skip).Take(take).ToList(); + }); + DisclosureRequestsRepo.Setup(r => r.CountByStateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, RmsDisclosureState state) => DisclosureRequests.Count(x => x.DepartmentId == d && x.State == (int)state && x.DeletedOn == null)); + DisclosureRequestsRepo.Setup(r => r.CountOverdueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, DateTime now) => DisclosureRequests.Count(x => x.DepartmentId == d && x.DeletedOn == null && x.ClosedOn == null && x.StatutoryDueOn.HasValue && x.StatutoryDueOn < now)); + DisclosureRequestsRepo.Setup(r => r.GetMaxRequestNumberSequenceAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string prefix) => DisclosureRequests + .Where(x => x.DepartmentId == d && x.RequestNumber != null && x.RequestNumber.StartsWith(prefix, StringComparison.Ordinal)) + .Select(x => int.TryParse(x.RequestNumber.Substring(prefix.Length), out var n) ? n : 0) + .DefaultIfEmpty(0).Max()); + + DisclosureProductionsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsDisclosureProduction e, CancellationToken c, bool f) => { DisclosureProductions.Add(e); return e; }); + DisclosureProductionsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsDisclosureProduction e, CancellationToken c, bool f) => { DisclosureProductions.RemoveAll(x => x.RmsDisclosureProductionId == e.RmsDisclosureProductionId); DisclosureProductions.Add(e); return e; }); + DisclosureProductionsRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => DisclosureProductions.FirstOrDefault(x => x.DepartmentId == d && x.RmsDisclosureProductionId == id)); + DisclosureProductionsRepo.Setup(r => r.GetForRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string requestId) => (IEnumerable)DisclosureProductions + .Where(x => x.DepartmentId == d && x.DisclosureRequestId == requestId).OrderBy(x => x.ProductionNumber).ToList()); + DisclosureProductionsRepo.Setup(r => r.GetMaxProductionNumberAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string requestId) => DisclosureProductions + .Where(x => x.DepartmentId == d && x.DisclosureRequestId == requestId).Select(x => x.ProductionNumber).DefaultIfEmpty(0).Max()); } public void SeedActiveCutover(int departmentId, DateTime? activatedOn = null) diff --git a/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs b/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs new file mode 100644 index 00000000..ce16b9ad --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Providers.Neris; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// The NERIS incident-analysis filing (RMS-3). The properties under test are the ones that make it a separate + /// artifact rather than a section of the incident: it never blocks the incident report, it waits for the + /// incident's destination id instead of failing, and it carries its own idempotency key. + /// + [TestFixture] + public class IncidentAnalysisServiceTests + { + private const int Dept = 4; + + private FakeIncidentStore _store; + private Mock _neris; + private RmsNerisProfile _profile; + private bool _submissionEnabled; + private IncidentAnalysisService _service; + private RmsIncidentReport _report; + + [SetUp] + public void SetUp() + { + _store = new FakeIncidentStore(); + + _profile = new RmsNerisProfile { DepartmentId = Dept, NerisEntityId = "FD24027000", ContractVersion = "1.4.78", IsEnabled = true }; + _submissionEnabled = true; + _neris = new Mock(); + _neris.SetupGet(n => n.ContractVersion).Returns("1.4.78"); + _neris.Setup(n => n.GetProfileAsync(Dept)).ReturnsAsync(() => _profile); + _neris.Setup(n => n.IsSubmissionEnabledAsync(Dept)).ReturnsAsync(() => _submissionEnabled); + + _report = new RmsIncidentReport + { + RmsIncidentReportId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + CallId = 88, + ReportingEntityId = "FD24027000", + DefinitionKey = RmsDefinitionKeys.NerisIncidentReport, + DefinitionVersion = 1, + ProfileVersion = "1.4.78", + State = (int)RmsRecordState.Finalized, + IncidentNumber = "2026-000200", + AuthorUserId = "author", + CallCreatedOn = DateTime.UtcNow.AddDays(-2), + CreatedOn = DateTime.UtcNow.AddDays(-2), + ModifiedOn = DateTime.UtcNow.AddDays(-2), + RowVersion = 1 + }; + _store.Reports.Add(_report); + + _service = new IncidentAnalysisService(_store.AnalysesRepo.Object, _store.ReportsRepo.Object, + _store.ModulesRepo.Object, _store.PropertiesRepo.Object, _store.VehiclesRepo.Object, + _store.IssuesRepo.Object, _store.SubmissionsRepo.Object, _store.Shared.RevisionsRepo.Object, + _store.Shared.AuditsRepo.Object, _store.UnitOfWork.Object, _neris.Object, + new NerisMappingService(), new NerisValidationService(Mock.Of(), _neris.Object)); + } + + private static IncidentAnalysisDraftInput CompleteDraft() + { + return new IncidentAnalysisDraftInput + { + GeneralCause = "ACCIDENTAL", + InvestigationTypes = new List { "INVESTIGATED_BY_ARSON_FIRE_INVESTIGATOR" }, + CurrencyCode = "USD", + Properties = new List + { + new IncidentPropertyInput + { + LocationUse = "RESIDENTIAL||DETATCHED_SINGLE_FAMILY_DWELLING", ConstructionType = "TYPE_VB", DamageType = "MAJOR_DAMAGE", + FireSpread = "BUILDING", EstimatedValue = 300000m, EstimatedLoss = 120000m, ContentsValue = 50000m, ContentsLoss = 20000m + } + }, + Vehicles = new List + { + new IncidentVehicleInput { VehicleKind = "AUTOMOBILE", Make = "FORD", Model = "F-150", ModelYear = 2019, DamageType = "DAMAGED_NOT_DRIVABLE", Vin = "1FTFW1E85KFA00000", LicensePlate = "ABC1234", LicenseState = "IL", EstimatedValue = 20000m, EstimatedLoss = 20000m } + }, + Modules = new List + { + new IncidentModuleInput { Kind = RmsIncidentModuleKind.StructureFireOrigin, PrimaryCode = "KITCHEN", DetailJson = "{\"room_of_origin\":\"KITCHEN\"}" } + } + }; + } + + private async Task StartAndFillAsync(bool canWriteRestricted = true) + { + var aggregate = await _service.StartForReportAsync(Dept, "investigator", _report.RmsIncidentReportId); + return await _service.SaveDraftAsync(Dept, "investigator", aggregate.Analysis.RmsIncidentAnalysisId, + aggregate.Analysis.RowVersion, CompleteDraft(), canWriteRestricted); + } + + [Test] + public async Task Starting_twice_returns_the_same_analysis() + { + var first = await _service.StartForReportAsync(Dept, "investigator", _report.RmsIncidentReportId); + var second = await _service.StartForReportAsync(Dept, "someone_else", _report.RmsIncidentReportId); + + second.Analysis.RmsIncidentAnalysisId.Should().Be(first.Analysis.RmsIncidentAnalysisId); + _store.Analyses.Should().ContainSingle("one analysis per incident report"); + } + + [Test] + public async Task Totals_are_summed_from_what_the_analysis_enumerates() + { + var saved = await StartAndFillAsync(); + + saved.Analysis.EstimatedValueTotal.Should().Be(370000m, "property value plus contents value plus vehicle value"); + saved.Analysis.EstimatedLossTotal.Should().Be(160000m); + } + + [Test] + public async Task A_caller_without_the_restricted_grant_neither_writes_nor_erases_vin_and_plate() + { + var saved = await StartAndFillAsync(); + saved.Vehicles.Single().Vin.Should().Be("1FTFW1E85KFA00000"); + + var input = CompleteDraft(); + input.Vehicles[0].Vin = "SOMETHINGELSE"; + input.Vehicles[0].LicensePlate = "ZZZ9999"; + input.Vehicles[0].Model = "F-250"; + + var again = await _service.SaveDraftAsync(Dept, "reviewer", saved.Analysis.RmsIncidentAnalysisId, + saved.Analysis.RowVersion, input, canWriteRestricted: false); + + again.Vehicles.Single().Model.Should().Be("F-250", "the unrestricted half is still editable"); + again.Vehicles.Single().Vin.Should().Be("1FTFW1E85KFA00000", "the stored restricted value is carried forward, not overwritten"); + again.Vehicles.Single().LicensePlate.Should().Be("ABC1234"); + } + + [Test] + public async Task Finalizing_before_the_incident_is_filed_succeeds_and_waits() + { + var saved = await StartAndFillAsync(); + + var finalized = await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); + + finalized.State.Should().Be(RmsIncidentAnalysisState.Finalized); + finalized.Analysis.CurrentRevisionId.Should().NotBeNullOrWhiteSpace(); + _store.Submissions.Should().BeEmpty("the incident has no NERIS id yet, so there is nothing to file against"); + } + + [Test] + public async Task Finalizing_after_the_incident_is_filed_queues_the_analysis_with_its_own_key() + { + _report.NerisIncidentId = "FD24027000I2026000200"; + var saved = await StartAndFillAsync(); + + var finalized = await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); + + finalized.State.Should().Be(RmsIncidentAnalysisState.Submitted); + var submission = _store.Submissions.Should().ContainSingle().Subject; + submission.Destination.Should().Be(RmsSubmissionDestinations.NerisIncidentAnalysis); + submission.RecordKind.Should().Be((int)RmsRecordKind.IncidentAnalysis); + submission.IdempotencyKey.Should().StartWith("neris-analysis:", "an incident and its analysis must never collide on a key"); + submission.PayloadChecksum.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public async Task The_queued_payload_carries_the_incident_id_and_the_analysis_sections() + { + _report.NerisIncidentId = "FD24027000I2026000200"; + var saved = await StartAndFillAsync(); + await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); + + var payload = JObject.Parse(_store.Submissions.Single().PayloadJson); + payload["base"].Value("incident_neris_id").Should().Be("FD24027000I2026000200"); + payload["base"].Value("general_cause").Should().Be("ACCIDENTAL"); + payload["properties"].Should().HaveCount(1); + payload["vehicles"].Should().HaveCount(1); + payload["structure_fire_origin"].Should().NotBeNull("the module lands at the payload path its catalog descriptor names"); + } + + [Test] + public async Task An_analysis_that_will_not_validate_never_touches_the_incident_report() + { + var saved = await StartAndFillAsync(); + var input = CompleteDraft(); + input.GeneralCause = "NOT_A_NERIS_CAUSE"; + var bad = await _service.SaveDraftAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion, input); + + Func act = () => _service.FinalizeAsync(Dept, "investigator", bad.Analysis.RmsIncidentAnalysisId, bad.Analysis.RowVersion); + + await act.Should().ThrowAsync(); + _report.State.Should().Be((int)RmsRecordState.Finalized, "the incident report is untouched by its analysis failing"); + _store.Issues.Should().OnlyContain(i => i.RecordId == bad.Analysis.RmsIncidentAnalysisId); + } + + [Test] + public async Task Analyses_waiting_on_their_incident_are_queued_once_it_is_filed() + { + var saved = await StartAndFillAsync(); + await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); + _store.Submissions.Should().BeEmpty(); + + // The incident's own submission landed; the analysis can now be filed against it. + _report.NerisIncidentId = "FD24027000I2026000200"; + var queued = await _service.QueueAwaitingIncidentAsync(Dept); + + queued.Should().Be(1); + _store.Submissions.Should().ContainSingle().Which.Destination.Should().Be(RmsSubmissionDestinations.NerisIncidentAnalysis); + + // A second pass must not re-queue what is already in flight. + (await _service.QueueAwaitingIncidentAsync(Dept)).Should().Be(0); + _store.Submissions.Should().ContainSingle(); + } + + [Test] + public async Task An_analysis_in_flight_cannot_be_voided_until_it_settles() + { + _report.NerisIncidentId = "FD24027000I2026000200"; + var saved = await StartAndFillAsync(); + var finalized = await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); + finalized.State.Should().Be(RmsIncidentAnalysisState.Submitted); + + Func act = () => _service.VoidAsync(Dept, "chief", finalized.Analysis.RmsIncidentAnalysisId, "Superseded", "Reopened the investigation."); + + await act.Should().ThrowAsync("voiding what the destination may already hold would leave the two out of step"); + } + + [Test] + public async Task Voiding_a_rejected_analysis_supersedes_its_open_submission() + { + _report.NerisIncidentId = "FD24027000I2026000200"; + var saved = await StartAndFillAsync(); + var finalized = await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); + + // The destination rejected it; the department decides not to correct and resubmit. + var analysis = _store.Analyses.Single(); + analysis.State = (int)RmsIncidentAnalysisState.Rejected; + + var voided = await _service.VoidAsync(Dept, "chief", finalized.Analysis.RmsIncidentAnalysisId, "Superseded", "Reopened the investigation."); + + voided.State.Should().Be(RmsIncidentAnalysisState.Voided); + _store.Submissions.Single().State.Should().Be((int)RmsSubmissionState.Superseded); + } + + [Test] + public async Task A_stale_row_version_is_refused() + { + var saved = await StartAndFillAsync(); + + Func act = () => _service.SaveDraftAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion - 1, CompleteDraft()); + + await act.Should().ThrowAsync(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs index d7bacee5..c1c71a6c 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -104,7 +104,9 @@ public void SetUp() _service = new IncidentReportsService(_store.ReportsRepo.Object, _store.FactsRepo.Object, _store.UnitsRepo.Object, _store.TypesRepo.Object, _store.TacticsRepo.Object, _store.AidsRepo.Object, _store.LocationsRepo.Object, _store.NarrativesRepo.Object, _store.IssuesRepo.Object, - _store.SubmissionsRepo.Object, _store.SignaturesRepo.Object, _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, + _store.SubmissionsRepo.Object, _store.SignaturesRepo.Object, + _store.ModulesRepo.Object, _store.ResourcesRepo.Object, _store.CasualtiesRepo.Object, _store.ExposuresRepo.Object, + _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _store.Shared.ScopesRepo.Object, _store.Shared.SharesRepo.Object, _store.Shared.ProjectionsRepo.Object, outbox, _settings.Object, _groups.Object, _profiles.Object, _roles.Object, _units.Object, _calls.Object, _adp.Object, _store.UnitOfWork.Object, _neris.Object, new NerisMappingService(), _validation.Object); @@ -166,6 +168,42 @@ public async Task Start_from_call_twice_returns_the_existing_report() _store.Outbox.Should().ContainSingle(); } + [Test] + public async Task Start_after_the_reporting_entity_is_configured_still_returns_the_original_report() + { + // A report started before the NERIS profile existed carries the placeholder entity. Matching only the + // current entity would start a second authoritative report for the same call. + _profile = null; + var first = await _service.StartFromCallAsync(Dept, "author", CallId); + + _profile = new RmsNerisProfile { DepartmentId = Dept, NerisEntityId = "FD24027000", ContractVersion = "1.4.78", IsEnabled = true }; + var second = await _service.StartFromCallAsync(Dept, "author", CallId); + + second.Report.RmsIncidentReportId.Should().Be(first.Report.RmsIncidentReportId); + _store.Reports.Should().ContainSingle("SingleAuthoritative survives the entity id being configured later"); + } + + [Test] + public async Task The_promoted_primary_incident_type_is_persisted_not_only_returned() + { + var started = await _service.StartFromCallAsync(Dept, "author", CallId); + var input = DraftFrom(started); + input.Types = new List + { + new IncidentTypeInput { TypeCode = "FIRE||STRUCTURE_FIRE||RESIDENTIAL", IsPrimary = false }, + new IncidentTypeInput { TypeCode = "RESCUE||SEARCH", IsPrimary = false } + }; + + var saved = await _service.SaveDraftAsync(Dept, "author", started.Report.RmsIncidentReportId, started.Report.RowVersion, input); + + saved.Types.Count(t => t.IsPrimary).Should().Be(1); + _store.Types.Where(t => t.RecordId == started.Report.RmsIncidentReportId && t.RevisionId == null) + .Count(t => t.IsPrimary).Should().Be(1, "the stored rows must agree with what the save returned, or the next hydrate fails validation"); + + var hydrated = await _service.GetAsync(Dept, started.Report.RmsIncidentReportId); + hydrated.Types.Count(t => t.IsPrimary).Should().Be(1); + } + [Test] public async Task Save_draft_records_corrections_on_the_provenance_row() { diff --git a/Tests/Resgrid.Tests/Rms/RecordAttachmentUploadServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordAttachmentUploadServiceTests.cs index 20542bdb..bf29b25a 100644 --- a/Tests/Resgrid.Tests/Rms/RecordAttachmentUploadServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordAttachmentUploadServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -184,17 +184,20 @@ public async Task Hygiene_rejection_surfaces_as_rejected_and_the_session_stays_o } [Test] - public async Task Idempotency_service_replays_by_department_user_and_key() + public async Task Idempotency_service_replays_by_department_user_command_and_key() { var idempotency = new RecordsApiIdempotencyService(_store); - (await idempotency.TryGetRecordIdAsync(Dept, "u1", "k1")).Should().BeNull(); - await idempotency.RememberAsync(Dept, "u1", "k1", "rec-9"); + (await idempotency.TryGetRecordIdAsync(Dept, "u1", "k1", "Finalize")).Should().BeNull(); + await idempotency.RememberAsync(Dept, "u1", "k1", "Finalize", "rec-9"); - (await idempotency.TryGetRecordIdAsync(Dept, "u1", "k1")).Should().Be("rec-9"); - (await idempotency.TryGetRecordIdAsync(Dept, "u2", "k1")).Should().BeNull("keys are scoped to the user"); - (await idempotency.TryGetRecordIdAsync(43, "u1", "k1")).Should().BeNull("keys are scoped to the department"); - (await idempotency.TryGetRecordIdAsync(Dept, "u1", null)).Should().BeNull(); + (await idempotency.TryGetRecordIdAsync(Dept, "u1", "k1", "Finalize")).Should().Be("rec-9"); + (await idempotency.TryGetRecordIdAsync(Dept, "u2", "k1", "Finalize")).Should().BeNull("keys are scoped to the user"); + (await idempotency.TryGetRecordIdAsync(43, "u1", "k1", "Finalize")).Should().BeNull("keys are scoped to the department"); + (await idempotency.TryGetRecordIdAsync(Dept, "u1", null, "Finalize")).Should().BeNull(); + + // A client that reuses one key for a second command must not be told that command already succeeded. + (await idempotency.TryGetRecordIdAsync(Dept, "u1", "k1", "Void")).Should().BeNull("keys are scoped to the command"); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RecordsDashboardServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDashboardServiceTests.cs new file mode 100644 index 00000000..bda57214 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsDashboardServiceTests.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// The Records work queues and the NERIS crosswalk gap report (RMS-3d). Two behaviours matter beyond the + /// arithmetic: one broken count degrades to a warning instead of taking the dashboard down, and a crosswalk + /// that points at a code the pinned contract no longer carries is reported as stale rather than as mapped. + /// + [TestFixture] + public class RecordsDashboardServiceTests + { + private const int Dept = 31; + + private FakeRmsStore _store; + private FakeIncidentStore _incidents; + private Mock _neris; + private Mock _calls; + private List _crosswalks; + private List _callTypes; + private RecordsDashboardService _service; + + [SetUp] + public void SetUp() + { + _store = new FakeRmsStore(); + _incidents = new FakeIncidentStore(); + + _crosswalks = new List(); + _callTypes = new List(); + + _neris = new Mock(); + _neris.SetupGet(n => n.ContractVersion).Returns("1.4.78"); + _neris.Setup(n => n.GetCrosswalksAsync(Dept)).ReturnsAsync(() => _crosswalks); + _neris.Setup(n => n.GetValueSet("incident_type")).Returns(new NerisValueSet + { + SetKey = "incident_type", + Codes = new List { "FIRE||STRUCTURE_FIRE||RESIDENTIAL", "MEDICAL||ILLNESS||OTHER_ILLNESS" } + }); + + _calls = new Mock(); + _calls.Setup(c => c.GetCallTypesForDepartmentAsync(Dept)).ReturnsAsync(() => _callTypes); + + _service = new RecordsDashboardService(_store.RecordsRepo.Object, _incidents.ReportsRepo.Object, + _incidents.AnalysesRepo.Object, _store.DueStatesRepo.Object, _store.DisclosureRequestsRepo.Object, + _neris.Object, _calls.Object); + } + + private void SeedRecord(RmsRecordState state) + { + _store.Records.Add(new RmsOperationalRecord + { + RmsOperationalRecordId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + DefinitionKey = RmsDefinitionKeys.Run, + State = (int)state, + AuthorUserId = "author", + CreatedOn = DateTime.UtcNow, + ModifiedOn = DateTime.UtcNow, + RowVersion = 1 + }); + } + + private void SeedReport(RmsRecordState state) + { + _incidents.Reports.Add(new RmsIncidentReport + { + RmsIncidentReportId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + CallId = 1, + ReportingEntityId = "FD24027000", + DefinitionKey = RmsDefinitionKeys.NerisIncidentReport, + State = (int)state, + AuthorUserId = "author", + CreatedOn = DateTime.UtcNow, + ModifiedOn = DateTime.UtcNow, + RowVersion = 1 + }); + } + + private void SeedDisclosure(RmsDisclosureState state, DateTime? dueOn = null, bool closed = false) + { + _store.DisclosureRequests.Add(new RmsDisclosureRequest + { + RmsDisclosureRequestId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + RequesterName = "A. Reporter", + ReceivedOn = DateTime.UtcNow.AddDays(-10), + StatutoryDueOn = dueOn, + State = (int)state, + ClosedOn = closed ? DateTime.UtcNow : (DateTime?)null, + CreatedOn = DateTime.UtcNow.AddDays(-10), + ModifiedOn = DateTime.UtcNow.AddDays(-10), + RowVersion = 1 + }); + } + + [Test] + public async Task The_queues_count_both_aggregates() + { + SeedRecord(RmsRecordState.Draft); + SeedRecord(RmsRecordState.Draft); + SeedRecord(RmsRecordState.ReadyForReview); + SeedRecord(RmsRecordState.Returned); + + SeedReport(RmsRecordState.Draft); + SeedReport(RmsRecordState.Returned); + SeedReport(RmsRecordState.ReadyForReview); + SeedReport(RmsRecordState.Rejected); + SeedReport(RmsRecordState.Accepted); + + var dashboard = await _service.GetAsync(Dept, "chief"); + + dashboard.OperationalDrafts.Should().Be(2); + dashboard.OperationalAwaitingReview.Should().Be(1); + dashboard.OperationalReturned.Should().Be(1); + dashboard.IncidentIncomplete.Should().Be(2, "draft and returned are both still incomplete"); + dashboard.IncidentAwaitingReview.Should().Be(1); + dashboard.IncidentRejected.Should().Be(1); + dashboard.IncidentAccepted.Should().Be(1); + dashboard.Warnings.Should().BeEmpty(); + } + + [Test] + public async Task Overdue_comes_from_the_persisted_due_states() + { + _store.DueStates.Add(new RmsRecordDueState { RmsRecordDueStateId = Guid.NewGuid().ToString(), DepartmentId = Dept, RecordId = "r1", Obligation = (int)RmsRecordObligation.Review, LastEmittedState = (int)RmsDueState.Overdue }); + _store.DueStates.Add(new RmsRecordDueState { RmsRecordDueStateId = Guid.NewGuid().ToString(), DepartmentId = Dept, RecordId = "r2", Obligation = (int)RmsRecordObligation.Review, LastEmittedState = (int)RmsDueState.NotDue }); + + var dashboard = await _service.GetAsync(Dept, "chief"); + + dashboard.Overdue.Should().Be(1); + } + + [Test] + public async Task The_statutory_clock_counts_only_open_requests() + { + SeedDisclosure(RmsDisclosureState.Received, DateTime.UtcNow.AddDays(-2)); + SeedDisclosure(RmsDisclosureState.InReview, DateTime.UtcNow.AddDays(3)); + // Already released, and it went out late; a closed request is not still ticking. + SeedDisclosure(RmsDisclosureState.Released, DateTime.UtcNow.AddDays(-5), closed: true); + + var dashboard = await _service.GetAsync(Dept, "chief"); + + dashboard.DisclosuresOpen.Should().Be(2); + dashboard.DisclosuresOverdue.Should().Be(1); + } + + [Test] + public async Task A_broken_count_degrades_to_a_warning() + { + _store.DueStatesRepo.Setup(r => r.CountOverdueAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("table missing")); + + SeedRecord(RmsRecordState.Draft); + var dashboard = await _service.GetAsync(Dept, "chief"); + + dashboard.OperationalDrafts.Should().Be(1, "the rest of the dashboard still renders"); + dashboard.Overdue.Should().Be(0); + dashboard.Warnings.Should().ContainSingle().Which.Should().Contain("overdue obligations"); + } + + [Test] + public async Task Crosswalk_coverage_separates_mapped_unmapped_and_stale() + { + _callTypes.Add(new CallType { CallTypeId = 1, DepartmentId = Dept, Type = "Structure Fire" }); + _callTypes.Add(new CallType { CallTypeId = 2, DepartmentId = Dept, Type = "Medical" }); + _callTypes.Add(new CallType { CallTypeId = 3, DepartmentId = Dept, Type = "Service Call" }); + + _crosswalks.Add(new RmsNerisCrosswalk { DepartmentId = Dept, SetKey = "incident_type", LocalSource = NerisCrosswalkSources.CallType, LocalCode = "Structure Fire", NerisCode = "FIRE||STRUCTURE_FIRE||RESIDENTIAL" }); + // Points at a code the pinned contract no longer carries: looks configured, fails at submission. + _crosswalks.Add(new RmsNerisCrosswalk { DepartmentId = Dept, SetKey = "incident_type", LocalSource = NerisCrosswalkSources.CallType, LocalCode = "Medical", NerisCode = "MEDICAL||RETIRED_CODE" }); + + var coverage = await _service.GetCrosswalkCoverageAsync(Dept); + + coverage.ContractVersion.Should().Be("1.4.78"); + coverage.TotalLocalCodes.Should().Be(3); + coverage.MappedCount.Should().Be(2); + coverage.UnmappedCount.Should().Be(1, "Service Call has no mapping and will need classifying by hand"); + coverage.StaleMappingCount.Should().Be(1, "a mapping to a retired code is not an all-clear"); + coverage.Items.Single(i => i.LocalCode == "Service Call").Mapped.Should().BeFalse(); + } + + [Test] + public async Task Crosswalk_coverage_degrades_when_the_sources_cannot_be_read() + { + _neris.Setup(n => n.GetCrosswalksAsync(Dept)).ThrowsAsync(new InvalidOperationException("unreachable")); + + var coverage = await _service.GetCrosswalkCoverageAsync(Dept); + + coverage.Items.Should().BeEmpty(); + coverage.Warnings.Should().ContainSingle(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs new file mode 100644 index 00000000..dc33ce20 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs @@ -0,0 +1,369 @@ +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.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// Public-records workflow (RMS plan section 4.7, RMS-3d). The three properties the plan actually cares about + /// are the ones under test: a production never mutates a source revision, the produced set is frozen so a + /// later amendment cannot change what was released, and redaction is logged rather than silent. + /// + [TestFixture] + public class RecordsDisclosureServiceTests + { + private const int Dept = 21; + + private FakeRmsStore _store; + private Mock _authorization; + private Mock _settings; + private RecordsDisclosureService _service; + private RmsOperationalRecord _finalized; + private RmsRevision _revision; + + [SetUp] + public void SetUp() + { + _store = new FakeRmsStore(); + + _authorization = new Mock(); + _authorization.Setup(a => a.GetVisibleGroupIdsAsync(It.IsAny(), Dept)).ReturnsAsync((List)null); + _authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); + + _settings = new Mock(); + _settings.Setup(s => s.GetRecordsDisclosureConfigAsync(Dept, It.IsAny())) + .ReturnsAsync(new RecordsDisclosureConfig { StatutoryClockDays = 5, DefaultRedactionProfile = RmsRedactionProfiles.Standard }); + + _finalized = SeedRecord(RmsRecordState.Finalized); + _revision = SeedRevision(_finalized); + + _service = new RecordsDisclosureService(_store.DisclosureRequestsRepo.Object, _store.DisclosureProductionsRepo.Object, + _store.RecordsRepo.Object, _store.RevisionsRepo.Object, _store.AuditsRepo.Object, + _authorization.Object, _settings.Object, _store.UnitOfWork.Object); + } + + private RmsOperationalRecord SeedRecord(RmsRecordState state, string summary = "Structure fire response") + { + var record = new RmsOperationalRecord + { + RmsOperationalRecordId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + DefinitionKey = RmsDefinitionKeys.Run, + DefinitionVersion = 1, + RecordType = (int)RmsOperationalRecordType.Run, + State = (int)state, + RecordNumber = "RUN-2026-0007", + DisplaySummary = summary, + AuthorUserId = "author", + StartedOn = DateTime.UtcNow.AddDays(-30), + FinalizedOn = DateTime.UtcNow.AddDays(-29), + CreatedOn = DateTime.UtcNow.AddDays(-30), + ModifiedOn = DateTime.UtcNow.AddDays(-29), + RowVersion = 2 + }; + _store.Records.Add(record); + return record; + } + + private RmsRevision SeedRevision(RmsOperationalRecord record) + { + var snapshot = new RecordSnapshot + { + RecordId = record.RmsOperationalRecordId, + DepartmentId = Dept, + DefinitionKey = record.DefinitionKey, + RecordNumber = record.RecordNumber, + Details = new RmsOperationalRecordDetail + { + RecordId = record.RmsOperationalRecordId, + Narrative = "Crew made entry and knocked the fire down.", + ContactName = "Jane Public", + // A restricted-class field: the standard profile must withhold it and say so. + CaseNumber = "CASE-2026-014", + Location = "100 Main St" + }, + Participants = new List + { + new RmsRecordParticipant { UserId = "member-1", DisplayNameSnapshot = "A. Firefighter", Role = "Attended", GroupNameSnapshot = "Station 1" } + } + }; + + var json = RecordSnapshotSerializer.Serialize(snapshot); + var revision = new RmsRevision + { + RmsRevisionId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = record.RmsOperationalRecordId, + RecordKind = (int)RmsRecordKind.Operational, + RevisionNumber = 1, + Transition = (int)RmsRevisionTransition.Finalized, + DefinitionKey = record.DefinitionKey, + DefinitionVersion = 1, + SnapshotJson = json, + Checksum = RecordSnapshotSerializer.Checksum(json), + ActorUserId = "author", + CreatedOn = DateTime.UtcNow.AddDays(-29) + }; + _store.Revisions.Add(revision); + record.CurrentRevisionId = revision.RmsRevisionId; + return revision; + } + + private async Task OpenRequestAsync(string profile = RmsRedactionProfiles.Standard) + { + var request = await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest + { + RequesterName = "A. Reporter", + RequesterOrganization = "Local Paper", + JurisdictionProfile = "US-IL", + ReceivedOn = DateTime.UtcNow.AddDays(-1) + }); + + return await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "All run reports from last month", + new RmsRecordQuery { States = new List { (int)RmsRecordState.Finalized }, DefinitionKey = RmsDefinitionKeys.Run }, profile); + } + + [Test] + public async Task A_new_request_gets_a_number_and_a_statutory_clock() + { + var request = await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest + { + RequesterName = "A. Reporter", + ReceivedOn = new DateTime(2026, 9, 1, 12, 0, 0, DateTimeKind.Utc) + }); + + request.RequestNumber.Should().Be("PRR-2026-0001"); + request.State.Should().Be((int)RmsDisclosureState.Received); + request.StatutoryDueOn.Should().Be(new DateTime(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc), + "the clock runs from when the department received it, not from when it was logged"); + request.RedactionProfile.Should().Be(RmsRedactionProfiles.Standard); + } + + [Test] + public async Task A_request_without_a_requester_is_refused() + { + Func act = () => _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest()); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task The_scope_preview_runs_through_the_same_authorization_path_as_the_queue() + { + var request = await OpenRequestAsync(); + _authorization.Setup(a => a.GetVisibleGroupIdsAsync("clerk", Dept)).ReturnsAsync(new List { 5 }); + _authorization.Setup(a => a.CanUserViewRecordAsync("clerk", It.IsAny(), Dept)).ReturnsAsync(false); + + var preview = await _service.PreviewScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + preview.MatchedCount.Should().Be(1); + preview.WithheldWholeRecordCount.Should().Be(1, "a disclosure officer sees no more of the department than their queue shows them"); + preview.Items.Should().BeEmpty(); + } + + [Test] + public async Task A_draft_is_listed_but_never_producible() + { + SeedRecord(RmsRecordState.Draft, "Half-written report"); + var request = await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest { RequesterName = "A. Reporter" }); + await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Everything", + new RmsRecordQuery { DefinitionKey = RmsDefinitionKeys.Run }, RmsRedactionProfiles.Standard); + + var preview = await _service.PreviewScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + preview.Items.Should().HaveCount(2); + var draft = preview.Items.Single(i => i.Summary == "Half-written report"); + draft.Producible.Should().BeFalse(); + draft.NotProducibleReason.Should().Contain("not finalized"); + } + + [Test] + public async Task A_production_redacts_restricted_fields_and_logs_what_it_withheld() + { + var request = await OpenRequestAsync(); + + var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + production.RecordCount.Should().Be(1); + production.WithheldFieldCount.Should().BeGreaterThan(0); + + var artifact = JObject.Parse(production.ArtifactJson); + var details = artifact["documents"][0]["details"]; + details["Narrative"].Value().Should().Contain("knocked the fire down"); + details["ContactName"].Value().Should().Be("Jane Public", "an unrestricted field is released"); + details["CaseNumber"].Should().BeNull("a restricted-class field is not released under the standard profile"); + + var withheld = JArray.Parse(production.WithheldFieldsJson); + withheld.Should().Contain(w => w["Field"].Value() == "CaseNumber", + "a requester is entitled to know something was withheld even when they cannot have it"); + } + + [Test] + public async Task The_no_identifiers_profile_withholds_participant_identity() + { + var request = await OpenRequestAsync(RmsRedactionProfiles.NoPersonalIdentifiers); + + var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + var artifact = JObject.Parse(production.ArtifactJson); + ((JArray)artifact["documents"][0]["participants"]).Should().BeEmpty(); + JArray.Parse(production.WithheldFieldsJson).Should().Contain(w => w["Section"].Value() == "Participants"); + } + + [Test] + public async Task A_production_never_mutates_the_source_revision() + { + var request = await OpenRequestAsync(); + var before = _revision.SnapshotJson; + var beforeChecksum = _revision.Checksum; + var beforeRowVersion = _finalized.RowVersion; + + await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + _revision.SnapshotJson.Should().Be(before); + _revision.Checksum.Should().Be(beforeChecksum); + _finalized.RowVersion.Should().Be(beforeRowVersion, "answering a request must never damage the record it answers from"); + } + + [Test] + public async Task The_produced_set_freezes_the_revision_and_its_checksum() + { + var request = await OpenRequestAsync(); + var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + var produced = JArray.Parse(production.ProducedSetJson); + produced.Should().ContainSingle(); + produced[0]["revision_id"].Value().Should().Be(_revision.RmsRevisionId); + produced[0]["revision_checksum"].Value().Should().Be(_revision.Checksum); + + // The record is amended after release. What was produced must still describe revision 1. + var amended = SeedRevision(_finalized); + amended.RevisionNumber = 2; + + var reread = _store.DisclosureProductions.Single(); + JArray.Parse(reread.ProducedSetJson)[0]["revision_id"].Value().Should().Be(_revision.RmsRevisionId, + "a later amendment cannot silently change what the department released"); + } + + [Test] + public async Task A_production_is_checksummed_and_verifiable() + { + var request = await OpenRequestAsync(); + var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + (await _service.VerifyProductionAsync(Dept, production.RmsDisclosureProductionId)).Should().BeTrue(); + + _store.DisclosureProductions.Single().ArtifactJson = "{\"documents\":[]}"; + (await _service.VerifyProductionAsync(Dept, production.RmsDisclosureProductionId)).Should().BeFalse(); + } + + [Test] + public async Task Releasing_closes_the_statutory_clock_and_audits_the_handover() + { + var request = await OpenRequestAsync(); + var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + var released = await _service.ReleaseAsync(Dept, "chief", production.RmsDisclosureProductionId); + + released.ReleasedOn.Should().NotBeNull(); + released.ReleasedByUserId.Should().Be("chief"); + _store.DisclosureRequests.Single().State.Should().Be((int)RmsDisclosureState.Released); + _store.DisclosureRequests.Single().ClosedOn.Should().NotBeNull(); + _store.Audits.Should().Contain(a => a.Purpose == "Disclosure released"); + } + + [Test] + public async Task Releasing_twice_is_refused() + { + var request = await OpenRequestAsync(); + var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + await _service.ReleaseAsync(Dept, "chief", production.RmsDisclosureProductionId); + + Func act = () => _service.ReleaseAsync(Dept, "chief", production.RmsDisclosureProductionId); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task The_scope_cannot_change_once_something_has_been_produced() + { + var request = await OpenRequestAsync(); + await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + Func act = () => _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Actually, everything", + new RmsRecordQuery(), RmsRedactionProfiles.FullDisclosure); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task Closing_without_a_reason_is_refused() + { + var request = await OpenRequestAsync(); + + Func act = () => _service.CloseAsync(Dept, "chief", request.RmsDisclosureRequestId, RmsDisclosureState.Denied, " "); + + await act.Should().ThrowAsync("a refusal without a recorded basis is not defensible"); + } + + [Test] + public async Task Denying_records_the_exemption_relied_on() + { + var request = await OpenRequestAsync(); + + var denied = await _service.CloseAsync(Dept, "chief", request.RmsDisclosureRequestId, RmsDisclosureState.Denied, "Active investigation exemption"); + + denied.State.Should().Be((int)RmsDisclosureState.Denied); + denied.DispositionReason.Should().Be("Active investigation exemption"); + denied.ClosedOn.Should().NotBeNull(); + } + + [Test] + public async Task Every_produced_record_is_audited_against_the_record_itself() + { + var request = await OpenRequestAsync(); + await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + // "What did we hand out about this record" has to be answerable from the record, not only the request. + _store.Audits.Should().Contain(a => a.RecordId == _finalized.RmsOperationalRecordId && a.Purpose.StartsWith("Disclosure production")); + } + + [Test] + public async Task A_scope_that_resolves_to_nothing_producible_is_refused() + { + _finalized.State = (int)RmsRecordState.Draft; + var request = await OpenRequestAsync(); + + Func act = () => _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task A_client_supplied_scope_cannot_widen_the_viewer() + { + var request = await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest { RequesterName = "A. Reporter" }); + + // A caller tries to scope the request to somebody else's groups. + await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Everything", + new RmsRecordQuery { VisibleGroupIds = new List { 99 }, ViewerUserId = "someone-else" }, RmsRedactionProfiles.Standard); + + var stored = JsonConvert.DeserializeObject(_store.DisclosureRequests.Single().ScopeQueryJson); + stored.VisibleGroupIds.Should().BeNull("the viewer fields come from the caller's own authorization, never the request body"); + stored.ViewerUserId.Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsDueStateServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDueStateServiceTests.cs new file mode 100644 index 00000000..ceb81570 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsDueStateServiceTests.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Queue; +using Resgrid.Model.Repositories; +using Resgrid.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// Worker 42, the bounded RecordOverdue evaluation (RMS plan RMS-3). The behaviour the plan names explicitly is + /// the one under test: the "at most once per record and due-state transition" guarantee is carried by the + /// persisted RmsRecordDueState row, so a repeated run emits nothing new and a missed run still emits. + /// + [TestFixture] + public class RecordsDueStateServiceTests + { + private const int Dept = 7; + + private FakeRmsStore _store; + private FakeIncidentStore _incidents; + private Mock _queue; + private List _notifications; + private RecordsDueStateService _service; + + [SetUp] + public void SetUp() + { + _store = new FakeRmsStore(); + _incidents = new FakeIncidentStore(); + _store.SeedActiveCutover(Dept); + + _notifications = new List(); + _queue = new Mock(); + _queue.Setup(q => q.EnqueueNotification(It.IsAny())) + .Returns((NotificationItem n) => { _notifications.Add(n); return Task.FromResult(true); }); + + var outbox = new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of()); + + _service = new RecordsDueStateService(_store.CutoversRepo.Object, _store.RecordsRepo.Object, + _incidents.ReportsRepo.Object, _store.DueStatesRepo.Object, outbox, _queue.Object); + } + + private RmsOperationalRecord SeedRecordAwaitingReview(DateTime? reviewDueOn, RmsRecordState state = RmsRecordState.ReadyForReview) + { + var record = new RmsOperationalRecord + { + RmsOperationalRecordId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + DefinitionKey = RmsDefinitionKeys.Training, + DefinitionVersion = 1, + RecordType = (int)RmsOperationalRecordType.Training, + State = (int)state, + LifecyclePreset = (int)RmsLifecyclePreset.ReviewRequired, + DraftReference = "D-1", + AuthorUserId = "author", + OwnerUserId = "author", + ReviewerUserId = "reviewer", + ReviewDueOn = reviewDueOn, + CreatedOn = DateTime.UtcNow.AddDays(-5), + ModifiedOn = DateTime.UtcNow.AddDays(-5), + RowVersion = 1 + }; + _store.Records.Add(record); + return record; + } + + [Test] + public async Task An_obligation_that_is_not_yet_due_emits_nothing() + { + SeedRecordAwaitingReview(DateTime.UtcNow.AddDays(5)); + + var result = await _service.SweepAsync(); + + result.BecameOverdue.Should().Be(0); + _store.Outbox.Should().BeEmpty(); + _notifications.Should().BeEmpty(); + _store.DueStates.Should().ContainSingle().Which.LastEmittedState.Should().Be((int)RmsDueState.NotDue); + } + + [Test] + public async Task An_overdue_review_emits_trigger_112_and_notification_32_once() + { + var record = SeedRecordAwaitingReview(DateTime.UtcNow.AddHours(-30)); + + var first = await _service.SweepAsync(); + + first.BecameOverdue.Should().Be(1); + _store.Outbox.Should().ContainSingle(); + _store.Outbox[0].TriggerEventType.Should().Be((int)WorkflowTriggerEventType.RecordOverdue); + _notifications.Should().ContainSingle(); + _notifications[0].Type.Should().Be((int)EventTypes.RecordReviewOverdue); + _notifications[0].Value.Should().Be(record.RmsOperationalRecordId + "|" + (int)RmsRecordObligation.Review); + _notifications[0].UserId.Should().Be("reviewer", "a review obligation rests with the reviewer"); + + var second = await _service.SweepAsync(); + + second.BecameOverdue.Should().Be(0, "the persisted due state already says overdue"); + _store.Outbox.Should().ContainSingle("a repeated run must not double-emit"); + _notifications.Should().ContainSingle(); + } + + [Test] + public async Task A_run_that_was_missed_still_emits_when_it_finally_happens() + { + // The record went overdue days ago and no sweep ran. Nothing here consults a last-run time, so the + // emission is late rather than lost. + SeedRecordAwaitingReview(DateTime.UtcNow.AddDays(-9)); + + var result = await _service.SweepAsync(); + + result.BecameOverdue.Should().Be(1); + _store.Outbox.Should().ContainSingle(); + } + + [Test] + public async Task A_moved_deadline_re_arms_the_emission() + { + var record = SeedRecordAwaitingReview(DateTime.UtcNow.AddHours(-2)); + await _service.SweepAsync(); + _store.Outbox.Should().ContainSingle(); + + // The reviewer pushed the deadline out and it lapsed again: that is a different obligation, so it is + // chased again rather than silently swallowed by the first emission. + record.ReviewDueOn = DateTime.UtcNow.AddHours(-1); + var second = await _service.SweepAsync(); + + second.BecameOverdue.Should().Be(1); + _store.Outbox.Should().HaveCount(2); + _store.DueStates.Single().OverdueCount.Should().Be(2); + } + + [Test] + public async Task Meeting_the_obligation_clears_the_row_and_a_later_lapse_emits_again() + { + var record = SeedRecordAwaitingReview(DateTime.UtcNow.AddHours(-4)); + await _service.SweepAsync(); + _store.Outbox.Should().ContainSingle(); + + // Reviewed: the record leaves ReadyForReview, so the obligation no longer applies. + record.State = (int)RmsRecordState.Finalized; + await _service.ClearForRecordAsync(Dept, record.RmsOperationalRecordId); + _store.DueStates.Single().LastEmittedState.Should().Be((int)RmsDueState.Cleared); + + // Amended and sent back for review, and late again. + record.State = (int)RmsRecordState.ReadyForReview; + record.ReviewDueOn = DateTime.UtcNow.AddHours(-1); + var third = await _service.SweepAsync(); + + third.BecameOverdue.Should().Be(1); + _store.Outbox.Should().HaveCount(2); + } + + [Test] + public async Task A_returned_record_becomes_overdue_only_after_the_correction_grace_period() + { + var record = SeedRecordAwaitingReview(null, RmsRecordState.Returned); + record.ReturnedOn = DateTime.UtcNow.AddHours(-(RecordsDueStateService.CorrectionGraceHours - 1)); + + (await _service.SweepAsync()).BecameOverdue.Should().Be(0); + + record.ReturnedOn = DateTime.UtcNow.AddHours(-(RecordsDueStateService.CorrectionGraceHours + 1)); + var result = await _service.SweepAsync(); + + result.BecameOverdue.Should().Be(1); + _notifications.Should().ContainSingle().Which.UserId.Should().Be("author", "a correction rests with the owner, not the reviewer"); + _store.DueStates.Single().Obligation.Should().Be((int)RmsRecordObligation.Correction); + } + + [Test] + public async Task An_obligation_with_no_deadline_is_never_overdue() + { + // A department that never set a review-due time has not made a promise the record can break. + SeedRecordAwaitingReview(null); + + var result = await _service.SweepAsync(); + + result.BecameOverdue.Should().Be(0); + _store.Outbox.Should().BeEmpty(); + } + + [Test] + public async Task The_event_payload_carries_the_obligation_and_no_record_content() + { + SeedRecordAwaitingReview(DateTime.UtcNow.AddHours(-25)); + + await _service.SweepAsync(); + + var payload = Newtonsoft.Json.Linq.JObject.Parse(_store.Outbox.Single().PayloadJson); + payload["obligation"].Value("type").Should().Be("Review"); + payload["obligation"].Value("overdue_hours").Should().BeGreaterThanOrEqualTo(25); + payload["obligation"].Value("responsible_user_id").Should().Be("reviewer"); + payload["record"].Should().NotBeNull(); + payload.Should().NotContainKey("record_change", "an overdue event is not a lifecycle transition"); + } + + [Test] + public async Task An_obligation_that_no_longer_applies_is_cleared_by_the_next_sweep_without_being_told() + { + var record = SeedRecordAwaitingReview(DateTime.UtcNow.AddHours(-4)); + await _service.SweepAsync(); + _store.DueStates.Single().LastEmittedState.Should().Be((int)RmsDueState.Overdue); + + // The reviewer finalized it and nothing called ClearForRecordAsync. The sweep must still reconcile, + // otherwise a stale Overdue row would silence a later, genuine lapse. + record.State = (int)RmsRecordState.Finalized; + var second = await _service.SweepAsync(); + + second.Cleared.Should().Be(1); + _store.DueStates.Single().LastEmittedState.Should().Be((int)RmsDueState.Cleared); + + record.State = (int)RmsRecordState.ReadyForReview; + record.ReviewDueOn = DateTime.UtcNow.AddHours(-1); + (await _service.SweepAsync()).BecameOverdue.Should().Be(1); + _store.Outbox.Should().HaveCount(2); + } + + [Test] + public async Task Emissions_per_department_are_bounded() + { + for (var i = 0; i < RecordsDueStateService.MaxEmissionsPerDepartment + 10; i++) + SeedRecordAwaitingReview(DateTime.UtcNow.AddHours(-5)); + + var result = await _service.SweepAsync(); + + result.BecameOverdue.Should().Be(RecordsDueStateService.MaxEmissionsPerDepartment + 10, "every row's state is still updated"); + _store.Outbox.Count.Should().Be(RecordsDueStateService.MaxEmissionsPerDepartment, "but a department cannot flood the queue in one sweep"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs new file mode 100644 index 00000000..dc1b7c0e --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using Resgrid.Services.Records.Evidence; + +namespace Resgrid.Tests.Rms +{ + /// + /// Evidence artifacts (RMS plan sections 4.5, 5.2; RMS-3c). The plan requires every one of the six sources to + /// prove authorization, provenance, classification, checksum and retention, and requires that none of them + /// hydrates a live source. Those are the properties under test — plus immutability, which is what makes an + /// artifact worth storing instead of a link. + /// + [TestFixture] + public class RecordsEvidenceServiceTests + { + private const int Dept = 9; + + private FakeRmsStore _store; + private FakeIncidentStore _incidents; + private FakeAdapter _adapter; + private RecordsEvidenceService _service; + private RmsOperationalRecord _record; + + /// A stand-in source so the service's own rules can be tested without six real subsystems. + private sealed class FakeAdapter : IRecordEvidenceAdapter + { + public RmsEvidenceKind Kind { get; set; } = RmsEvidenceKind.RunCardActivation; + public bool Available { get; set; } = true; + public RecordEvidenceCapture Result { get; set; } + public int Calls { get; private set; } + + public Task IsAvailableAsync(int departmentId) => Task.FromResult(Available); + + public Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) + { + Calls++; + return Task.FromResult(Result); + } + } + + [SetUp] + public void SetUp() + { + _store = new FakeRmsStore(); + _incidents = new FakeIncidentStore(); + + _record = new RmsOperationalRecord + { + RmsOperationalRecordId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + DefinitionKey = RmsDefinitionKeys.Run, + DefinitionVersion = 1, + RecordType = (int)RmsOperationalRecordType.Run, + State = (int)RmsRecordState.Draft, + AuthorUserId = "author", + CallId = 501, + CreatedOn = DateTime.UtcNow.AddHours(-3), + ModifiedOn = DateTime.UtcNow.AddHours(-3), + RowVersion = 1 + }; + _store.Records.Add(_record); + + _adapter = new FakeAdapter + { + Result = new RecordEvidenceCapture + { + Title = "Run card activation for call 501", + SourceSubsystem = "RunCards", + SourceEntityType = "RunCardActivation", + SourceEntityId = "7", + IdentifierScheme = "resgrid:runcardactivation", + SourceItemCount = 1, + Manifest = new { call_id = 501, activations = new[] { new { activation_id = 7 } } } + } + }; + + _service = new RecordsEvidenceService(_store.EvidenceRepo.Object, _store.RecordsRepo.Object, + _incidents.ReportsRepo.Object, _store.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { (IRecordEvidenceAdapter)_adapter }); + } + + private RecordEvidenceCaptureRequest Request(RmsEvidenceKind kind = RmsEvidenceKind.RunCardActivation) + { + return new RecordEvidenceCaptureRequest + { + DepartmentId = Dept, + RecordId = _record.RmsOperationalRecordId, + Kind = kind, + CallId = 501, + CaptureReason = "Attached to the run report", + CapturedByUserId = "author" + }; + } + + [Test] + public async Task A_capture_records_provenance_and_a_checksum_over_its_manifest() + { + var artifact = await _service.CaptureAsync(Request()); + + artifact.SourceSubsystem.Should().Be("RunCards"); + artifact.SourceEntityId.Should().Be("7"); + artifact.IdentifierScheme.Should().Be("resgrid:runcardactivation"); + artifact.CaptureReason.Should().Be("Attached to the run report"); + artifact.CapturedByUserId.Should().Be("author"); + artifact.Checksum.Should().NotBeNullOrWhiteSpace(); + artifact.ByteSize.Should().BeGreaterThan(0); + + (await _service.VerifyAsync(Dept, artifact.RmsEvidenceArtifactId)).Should().BeTrue(); + } + + [Test] + public async Task Serialization_is_deterministic_so_a_checksum_still_verifies_later() + { + var first = RecordsEvidenceService.Serialize(new { b = 2, a = 1, when = new DateTime(2026, 9, 4, 12, 0, 0, DateTimeKind.Utc) }); + var second = RecordsEvidenceService.Serialize(new { b = 2, a = 1, when = new DateTime(2026, 9, 4, 12, 0, 0, DateTimeKind.Utc) }); + + first.Should().Be(second, "an auditor re-computing the checksum years later must get the same bytes"); + } + + [Test] + public async Task A_tampered_manifest_fails_verification() + { + var artifact = await _service.CaptureAsync(Request()); + + // Somebody edited the stored manifest directly. + _store.EvidenceArtifacts.Single().ManifestJson = "{\"call_id\":999}"; + + (await _service.VerifyAsync(Dept, artifact.RmsEvidenceArtifactId)).Should().BeFalse(); + } + + [Test] + public async Task A_capture_without_a_reason_is_refused() + { + var request = Request(); + request.CaptureReason = " "; + + Func act = () => _service.CaptureAsync(request); + + await act.Should().ThrowAsync("evidence never enters an official record anonymously"); + } + + [Test] + public async Task Restricted_evidence_needs_the_restricted_grant() + { + _adapter.Result.Classification = RmsEvidenceClassification.Restricted; + + Func act = () => _service.CaptureAsync(Request(), canCaptureRestricted: false); + + await act.Should().ThrowAsync(); + _store.EvidenceArtifacts.Should().BeEmpty(); + + var artifact = await _service.CaptureAsync(Request(), canCaptureRestricted: true); + artifact.Classification.Should().Be((int)RmsEvidenceClassification.Restricted); + } + + [Test] + public async Task A_recapture_supersedes_the_earlier_artifact_and_leaves_it_readable() + { + var first = await _service.CaptureAsync(Request()); + var second = await _service.CaptureAsync(Request()); + + _store.EvidenceArtifacts.Should().HaveCount(2, "a correction is a new artifact, never an edit"); + + var superseded = _store.EvidenceArtifacts.Single(a => a.RmsEvidenceArtifactId == first.RmsEvidenceArtifactId); + superseded.SupersededByArtifactId.Should().Be(second.RmsEvidenceArtifactId); + superseded.SupersededOn.Should().NotBeNull(); + superseded.IsCurrent.Should().BeFalse(); + superseded.ManifestJson.Should().NotBeNull("what an earlier revision attested to stays readable"); + + (await _service.GetForRecordAsync(Dept, _record.RmsOperationalRecordId)).Should().ContainSingle() + .Which.RmsEvidenceArtifactId.Should().Be(second.RmsEvidenceArtifactId); + (await _service.GetForRecordAsync(Dept, _record.RmsOperationalRecordId, includeSuperseded: true)).Should().HaveCount(2); + } + + [Test] + public async Task Evidence_cannot_be_captured_against_a_voided_record() + { + _record.State = (int)RmsRecordState.Voided; + + Func act = () => _service.CaptureAsync(Request()); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task An_unavailable_source_says_so_rather_than_capturing_nothing_silently() + { + _adapter.Available = false; + + Func act = () => _service.CaptureAsync(Request()); + + await act.Should().ThrowAsync(); + _adapter.Calls.Should().Be(0, "an unavailable source is not asked"); + } + + [Test] + public async Task Binding_stamps_draft_artifacts_with_the_revision_and_leaves_bound_ones_alone() + { + var draft = await _service.CaptureAsync(Request()); + draft.RevisionId.Should().BeNull(); + + await _service.BindToRevisionAsync(Dept, _record.RmsOperationalRecordId, "rev-1"); + _store.EvidenceArtifacts.Single().RevisionId.Should().Be("rev-1"); + + // A later capture belongs to the draft again, and binding to revision 2 must not move revision 1's. + _adapter.Result.SourceEntityId = "8"; + await _service.CaptureAsync(Request()); + await _service.BindToRevisionAsync(Dept, _record.RmsOperationalRecordId, "rev-2"); + + _store.EvidenceArtifacts.Should().Contain(a => a.RevisionId == "rev-1"); + _store.EvidenceArtifacts.Should().Contain(a => a.RevisionId == "rev-2"); + } + + [Test] + public async Task Every_one_of_the_six_sources_reports_its_state() + { + var states = await _service.GetSourceStatesAsync(Dept); + + states.Select(s => s.Kind).Should().BeEquivalentTo(Enum.GetValues(typeof(RmsEvidenceKind)).Cast(), + "the plan ships all six; a source with no adapter still reports, so an empty list is never mistaken for no evidence"); + states.Single(s => s.Kind == RmsEvidenceKind.RunCardActivation).Available.Should().BeTrue(); + states.Single(s => s.Kind == RmsEvidenceKind.ReadinessPacket).Reason.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public async Task The_capture_is_audited_with_its_checksum_and_reason() + { + await _service.CaptureAsync(Request()); + + var audit = _store.Audits.Should().ContainSingle().Subject; + audit.RecordId.Should().Be(_record.RmsOperationalRecordId); + audit.Purpose.Should().Contain("Evidence captured"); + JObject.Parse(audit.DetailJson).Value("Checksum").Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public async Task The_readiness_adapter_ships_and_explains_why_it_has_nothing() + { + // The checklists module is planned, not built. "Unavailable" and "there was no readiness evidence" are + // different answers, and the adapter has to give the first one rather than an empty second. + var adapter = new ReadinessPacketEvidenceAdapter(); + + (await adapter.IsAvailableAsync(Dept)).Should().BeFalse(); + var capture = await adapter.CaptureAsync(Request(RmsEvidenceKind.ReadinessPacket)); + capture.Available.Should().BeFalse(); + capture.UnavailableReason.Should().Be(ReadinessPacketEvidenceAdapter.UnavailableReason); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsRetentionServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsRetentionServiceTests.cs new file mode 100644 index 00000000..d4cb57b5 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsRetentionServiceTests.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// Worker 43 (RMS plan RMS-3): retention, legal hold, attachment purge, and the rescan pass for attachments the + /// scanner could not reach at upload time — the RMS-1 gap this package closes. A purge leaves a content-free + /// tombstone (plan 4.9) and a legal hold refuses one loudly rather than skipping it in silence. + /// + [TestFixture] + public class RecordsRetentionServiceTests + { + private const int Dept = 11; + + private FakeRmsStore _store; + private FakeIncidentStore _incidents; + private Mock _settings; + private Mock _scanner; + private RecordsRetentionPolicy _policy; + private RecordsRetentionService _service; + + [SetUp] + public void SetUp() + { + _store = new FakeRmsStore(); + _incidents = new FakeIncidentStore(); + _store.SeedActiveCutover(Dept); + + _policy = new RecordsRetentionPolicy { DepartmentDefaultYears = 7 }; + _settings = new Mock(); + _settings.Setup(s => s.GetRecordsRetentionPolicyAsync(Dept, It.IsAny())).ReturnsAsync(() => _policy); + + _scanner = new Mock(); + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Clean }); + + _service = new RecordsRetentionService(_store.CutoversRepo.Object, _store.RecordsRepo.Object, + _incidents.ReportsRepo.Object, _store.DetailsRepo.Object, _store.AttachmentsRepo.Object, + _store.LegalHoldsRepo.Object, _store.AuditsRepo.Object, _store.ProjectionsRepo.Object, + _scanner.Object, _settings.Object); + } + + private RmsOperationalRecord SeedFinalized(DateTime finalizedOn, string definitionKey = null) + { + var id = Guid.NewGuid().ToString(); + var record = new RmsOperationalRecord + { + RmsOperationalRecordId = id, + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + DefinitionKey = definitionKey ?? RmsDefinitionKeys.Training, + DefinitionVersion = 1, + RecordType = (int)RmsOperationalRecordType.Training, + State = (int)RmsRecordState.Finalized, + RecordNumber = "TRN-2016-0001", + DisplaySummary = "Ladder drill", + AuthorUserId = "author", + StartedOn = finalizedOn, + FinalizedOn = finalizedOn, + CreatedOn = finalizedOn, + ModifiedOn = finalizedOn, + RowVersion = 1 + }; + _store.Records.Add(record); + _store.Details.Add(new RmsOperationalRecordDetail + { + RmsOperationalRecordDetailId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = id, + Narrative = "Crew drilled ladder throws for two hours.", + Location = "Station 1", + CreatedOn = finalizedOn, + ModifiedOn = finalizedOn, + RowVersion = 1 + }); + _store.Projections.Add(new RmsRecordSearchProjection + { + RmsRecordSearchProjectionId = id, + DepartmentId = Dept, + SourceId = id, + RecordKind = (int)RmsRecordKind.Operational, + DefinitionKey = record.DefinitionKey, + State = record.State, + DisplaySummary = "Ladder drill", + SearchText = "Crew drilled ladder throws for two hours.", + RecordCreatedOn = finalizedOn, + ModifiedOn = finalizedOn + }); + return record; + } + + private RmsRecordAttachment SeedAttachment(string recordId, RmsAttachmentScanState scanState) + { + var attachment = new RmsRecordAttachment + { + RmsRecordAttachmentId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + ProtectionId = Guid.NewGuid().ToString(), + RecordId = recordId, + FileName = "photo.jpg", + ContentType = "image/jpeg", + Data = Encoding.UTF8.GetBytes("bytes"), + ByteSize = 5, + ScanState = (int)scanState, + UploadedOn = DateTime.UtcNow.AddDays(-1), + CreatedOn = DateTime.UtcNow.AddDays(-1), + ModifiedOn = DateTime.UtcNow.AddDays(-1), + RowVersion = 1 + }; + _store.Attachments.Add(attachment); + return attachment; + } + + [Test] + public async Task A_record_inside_its_retention_period_is_untouched() + { + var record = SeedFinalized(DateTime.UtcNow.AddYears(-3)); + + var result = await _service.SweepAsync(); + + result.RecordsPurged.Should().Be(0); + record.PurgedOn.Should().BeNull(); + _store.Details.Single().Narrative.Should().NotBeNull(); + } + + [Test] + public async Task A_record_past_retention_becomes_a_content_free_tombstone() + { + var record = SeedFinalized(DateTime.UtcNow.AddYears(-9)); + var attachment = SeedAttachment(record.RmsOperationalRecordId, RmsAttachmentScanState.Clean); + + var result = await _service.SweepAsync(); + + result.RecordsPurged.Should().Be(1); + result.AttachmentsPurged.Should().Be(1); + + // Identity survives so a reference still resolves; content does not. + record.PurgedOn.Should().NotBeNull(); + record.RecordNumber.Should().Be("TRN-2016-0001"); + record.DisplaySummary.Should().Be(RecordsRetentionService.PurgedPlaceholder); + _store.Details.Single().Narrative.Should().BeNull(); + _store.Details.Single().Location.Should().BeNull(); + _store.Details.Single().RecordId.Should().Be(record.RmsOperationalRecordId, "keys are not content"); + attachment.Data.Should().BeNull(); + attachment.DeletedOn.Should().NotBeNull(); + + // The queue and the index stop showing what the department no longer holds. + _store.Projections.Single().DisplaySummary.Should().Be(RecordsRetentionService.PurgedPlaceholder); + _store.Projections.Single().SearchText.Should().BeNull(); + + _store.Audits.Should().Contain(a => a.Purpose == RecordsRetentionService.PurgeAuditPurpose); + } + + [Test] + public async Task A_legal_hold_refuses_the_purge_and_says_so_in_the_audit() + { + var record = SeedFinalized(DateTime.UtcNow.AddYears(-9)); + _store.LegalHolds.Add(new RmsRecordLegalHold + { + RmsRecordLegalHoldId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + RecordId = record.RmsOperationalRecordId, + Reason = RmsLegalHoldReasons.Litigation, + ReferenceNumber = "CV-2026-14", + PlacedByUserId = "admin", + PlacedOn = DateTime.UtcNow.AddMonths(-2), + CreatedOn = DateTime.UtcNow.AddMonths(-2), + ModifiedOn = DateTime.UtcNow.AddMonths(-2), + RowVersion = 1 + }); + + var result = await _service.SweepAsync(); + + result.RecordsPurged.Should().Be(0); + result.HeldByLegalHold.Should().Be(1); + record.PurgedOn.Should().BeNull(); + _store.Details.Single().Narrative.Should().NotBeNull(); + _store.Audits.Should().Contain(a => a.Purpose == RecordsRetentionService.HeldAuditPurpose, + "a refused purge must be visible; a silent skip looks like a sweep that never ran"); + } + + [Test] + public async Task A_released_hold_no_longer_protects_the_record() + { + var record = SeedFinalized(DateTime.UtcNow.AddYears(-9)); + _store.LegalHolds.Add(new RmsRecordLegalHold + { + RmsRecordLegalHoldId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + RecordId = record.RmsOperationalRecordId, + Reason = RmsLegalHoldReasons.Investigation, + PlacedOn = DateTime.UtcNow.AddMonths(-6), + ReleasedOn = DateTime.UtcNow.AddDays(-1), + ReleasedByUserId = "admin", + CreatedOn = DateTime.UtcNow.AddMonths(-6), + ModifiedOn = DateTime.UtcNow.AddDays(-1), + RowVersion = 1 + }); + + (await _service.SweepAsync()).RecordsPurged.Should().Be(1); + } + + [Test] + public async Task A_restricted_class_is_never_purged_because_it_retains_permanently() + { + // Coroner is a restricted class; plan 4.9 gives it permanent retention with no department default. + var record = SeedFinalized(DateTime.UtcNow.AddYears(-20), RmsDefinitionKeys.Coroner); + + var result = await _service.SweepAsync(); + + result.RecordsPurged.Should().Be(0); + record.PurgedOn.Should().BeNull(); + } + + [Test] + public async Task A_definition_override_of_permanent_beats_the_department_default() + { + _policy.Overrides.Add(new RecordsRetentionOverride { DefinitionKey = RmsDefinitionKeys.Training, RetentionYears = RecordsRetentionPolicy.Permanent }); + SeedFinalized(DateTime.UtcNow.AddYears(-9)); + + (await _service.SweepAsync()).RecordsPurged.Should().Be(0); + } + + [Test] + public async Task A_pending_attachment_is_rescanned_and_promoted_when_it_comes_back_clean() + { + var record = SeedFinalized(DateTime.UtcNow.AddYears(-1)); + var attachment = SeedAttachment(record.RmsOperationalRecordId, RmsAttachmentScanState.Pending); + + var result = await _service.SweepAsync(); + + result.AttachmentsRescanned.Should().Be(1); + result.AttachmentsRejectedOnRescan.Should().Be(0); + attachment.ScanState.Should().Be((int)RmsAttachmentScanState.Clean); + attachment.Data.Should().NotBeNull(); + } + + [Test] + public async Task A_pending_attachment_that_rescans_dirty_loses_its_bytes() + { + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Rejected, Detail = "Eicar-Test-Signature" }); + + var record = SeedFinalized(DateTime.UtcNow.AddYears(-1)); + var attachment = SeedAttachment(record.RmsOperationalRecordId, RmsAttachmentScanState.Pending); + + var result = await _service.SweepAsync(); + + result.AttachmentsRejectedOnRescan.Should().Be(1); + attachment.ScanState.Should().Be((int)RmsAttachmentScanState.Rejected); + attachment.Data.Should().BeNull("a Pending attachment that turns out to be malware was downloadable the whole time it sat unscanned"); + attachment.DeletedOn.Should().NotBeNull(); + _store.Audits.Should().Contain(a => a.Purpose == "Attachment rejected on rescan"); + } + + [Test] + public async Task A_scanner_that_is_still_unreachable_leaves_the_attachment_pending() + { + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Pending, Detail = "clamd unreachable" }); + + var record = SeedFinalized(DateTime.UtcNow.AddYears(-1)); + var attachment = SeedAttachment(record.RmsOperationalRecordId, RmsAttachmentScanState.Pending); + + var result = await _service.SweepAsync(); + + result.AttachmentsRescanned.Should().Be(0); + attachment.ScanState.Should().Be((int)RmsAttachmentScanState.Pending, "the next sweep tries again"); + attachment.Data.Should().NotBeNull(); + } + + [Test] + public async Task A_scanner_that_throws_does_not_stop_the_sweep() + { + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("socket closed")); + + // The Pending attachment hangs off a record still inside retention, so it survives to be rescanned; + // the expired record is a separate one, which is what proves the two passes are independent. + var current = SeedFinalized(DateTime.UtcNow.AddYears(-1)); + SeedAttachment(current.RmsOperationalRecordId, RmsAttachmentScanState.Pending); + SeedFinalized(DateTime.UtcNow.AddYears(-9)); + + var result = await _service.SweepAsync(); + + result.Errors.Should().Be(1); + result.RecordsPurged.Should().Be(1, "the retention pass runs regardless of the rescan pass"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsSearchIndexMaintenanceServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsSearchIndexMaintenanceServiceTests.cs index 6a7901dd..c54e9f85 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsSearchIndexMaintenanceServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsSearchIndexMaintenanceServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -101,8 +101,8 @@ public async Task Matching_generation_catches_up_modified_rows_and_removes_soft_ _states.Setup(s => s.GetAsync(Dept, RmsSearchIndexState.RecordsIndexName)).ReturnsAsync(new RmsSearchIndexState { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = "1.3.7", State = (int)RmsSearchIndexBuildState.Ready, LastIndexedModifiedOn = since }); var deleted = Projection("rec-9", new DateTime(2026, 9, 3)); deleted.DeletedOn = DateTime.UtcNow; - _projections.SetupSequence(p => p.GetModifiedSinceAsync(Dept, since, It.IsAny())).ReturnsAsync(new[] { Projection("rec-5", new DateTime(2026, 9, 2)), deleted }); - _projections.Setup(p => p.GetModifiedSinceAsync(Dept, new DateTime(2026, 9, 3), It.IsAny())).ReturnsAsync(new RmsRecordSearchProjection[0]); + _projections.SetupSequence(p => p.GetModifiedSinceAsync(Dept, since, It.IsAny(), It.IsAny())).ReturnsAsync(new[] { Projection("rec-5", new DateTime(2026, 9, 2)), deleted }); + _projections.Setup(p => p.GetModifiedSinceAsync(Dept, new DateTime(2026, 9, 3), It.IsAny(), It.IsAny())).ReturnsAsync(new RmsRecordSearchProjection[0]); var result = await _service.SweepAsync(); diff --git a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs index 257fc3f0..167faf5d 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -36,6 +36,9 @@ public class RecordsServiceTests private List _notifications; private Mock _outboundQueue; private RecordsService _service; + private Mock _evidence; + /// Revisions the finalize path bound draft evidence to (RMS-3c). + private readonly List _boundRevisions = new List(); [SetUp] public void SetUp() @@ -76,8 +79,12 @@ public void SetUp() _outboundQueue.Setup(q => q.EnqueueNotification(It.IsAny())) .Callback(n => _notifications.Add(n)).ReturnsAsync(true); + _evidence = new Mock(); + _evidence.Setup(e => e.BindToRevisionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string r, string rev, CancellationToken c) => { _boundRevisions.Add(rev); return 0; }); + _service = new RecordsService(_store.RecordsRepo.Object, new Resgrid.Services.Records.RmsRecordValueService(_store.DetailsRepo.Object), _store.ParticipantsRepo.Object, _store.UnitsRepo.Object, - _store.AttachmentsRepo.Object, _store.RevisionsRepo.Object, _store.ScopesRepo.Object, _store.SharesRepo.Object, _store.ProjectionsRepo.Object, + _store.AttachmentsRepo.Object, _store.RevisionsRepo.Object, _evidence.Object, _store.ScopesRepo.Object, _store.SharesRepo.Object, _store.ProjectionsRepo.Object, _store.AuditsRepo.Object, outbox, _cutover.Object, _settings.Object, _groups.Object, _profiles.Object, _units.Object, _calls.Object, _adp.Object, _store.UnitOfWork.Object, _outboundQueue.Object, new Resgrid.Services.Records.NullRecordAttachmentScanner()); } diff --git a/Tests/Resgrid.Tests/Rms/RecordsSubmissionServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsSubmissionServiceTests.cs index 6fea5795..5707bbc3 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsSubmissionServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsSubmissionServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -12,6 +12,7 @@ using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Queue; +using Resgrid.Model.Services; using Resgrid.Services; using Resgrid.Services.Records; @@ -58,8 +59,8 @@ public void SetUp() _outboundQueue.Setup(q => q.EnqueueNotification(It.IsAny())).Callback(n => _notifications.Add(n)).ReturnsAsync(true); var outbox = new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, new Mock().Object); - _service = new RecordsSubmissionService(_store.SubmissionsRepo.Object, _store.ReportsRepo.Object, _store.Shared.ProjectionsRepo.Object, - _store.Shared.AuditsRepo.Object, _profiles.Object, _delivery.Object, outbox, _outboundQueue.Object, _store.UnitOfWork.Object); + _service = new RecordsSubmissionService(_store.SubmissionsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, _store.Shared.ProjectionsRepo.Object, + _store.Shared.AuditsRepo.Object, _profiles.Object, _delivery.Object, outbox, _outboundQueue.Object, _store.UnitOfWork.Object, _store.Shared.CutoversRepo.Object, Mock.Of()); } [TearDown] @@ -189,6 +190,27 @@ public async Task Transient_outcome_backs_off_then_fails_after_MaxAttempts_with_ _notifications.Should().BeEmpty("a failed delivery is an operator concern, not the author's"); } + [Test] + public async Task A_transient_status_poll_never_spends_the_delivery_retry_budget() + { + // The row was delivered on its last allowed attempt and the destination now holds the revision. Status + // polls leave Attempts alone, so treating a poll error as an exhausted retry would fail a submission the + // destination may still accept. + var submission = Seed(RmsRecordState.Submitted, RmsSubmissionState.AwaitingDestination, maxAttempts: 2, externalId: "FD24027000I2026000123"); + submission.Attempts = 2; + _delivery.Setup(d => d.CheckStatusAsync(_profile, "FD24027000I2026000123", It.IsAny())) + .ReturnsAsync(new NerisSubmissionOutcome { Kind = NerisOutcomeKind.Transient, StatusCode = 503, Message = "NERIS returned 503." }); + + var result = await _service.ProcessAsync(submission); + + result.State.Should().Be((int)RmsSubmissionState.AwaitingDestination); + result.Attempts.Should().Be(2, "a status poll is not a delivery attempt"); + result.CompletedOn.Should().BeNull(); + result.NextAttemptOn.Should().NotBeNull(); + result.ErrorSummary.Should().Be("NERIS returned 503."); + _store.Outbox.Should().NotContain(o => o.TriggerEventType == (int)WorkflowTriggerEventType.RecordSubmissionFailed); + } + [Test] public async Task Fatal_outcome_fails_immediately() { diff --git a/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs b/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs index 4f90acb6..f09ddf5e 100644 --- a/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs @@ -1,5 +1,7 @@ +using System.Linq; using FluentAssertions; using NUnit.Framework; +using Resgrid.Model; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -41,6 +43,14 @@ public void Records_services_resolve_from_the_container() Resolve().Should().BeOfType("the scanning module replaces the null scanner"); Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); + // RMS-3: worker 42 and worker 43 + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + // RMS-3c/3d + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); } [Test] @@ -80,6 +90,31 @@ public void Records_repositories_resolve_from_the_container() Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); + // RMS-3 (M0167-M0168, M0170) + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + // RMS-3c/3d (M0169, M0171) + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + } + + [Test] + public void All_six_evidence_adapters_are_registered() + { + // The plan ships all six, none optional. A source that is not present in this build still registers an + // adapter, so the dashboard can say "unavailable" rather than leaving an author to infer "none". + var adapters = ResolveAll().ToList(); + + adapters.Select(a => a.Kind).Should().BeEquivalentTo( + System.Enum.GetValues(typeof(RmsEvidenceKind)).Cast()); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index e5b56e88..ba2f0c44 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -81,11 +81,36 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() ((int)WorkflowTriggerEventType.RecordAmended).Should().Be(105); ((int)WorkflowTriggerEventType.RecordVoided).Should().Be(106); ((int)WorkflowTriggerEventType.RecordCancelled).Should().Be(107); + ((int)WorkflowTriggerEventType.RecordOverdue).Should().Be(112); foreach (var value in Enumerable.Range(52, 48)) Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for another plan"); } + [Test] + public void Rms_worker_command_ids_are_the_registry_values() + { + // The RMS worker block is 40-43 plus the Unified Search allocation (44) that RMS-1 absorbed. These are + // scheduled by integer in Workers/Resgrid.Workers.Console/Program.cs; the pin keeps that file honest. + var program = ProgramSource(); + program.Should().Contain("new Commands.DomainEventOutboxDispatchCommand(40)"); + program.Should().Contain("new Commands.RmsSubmissionCommand(41)"); + program.Should().Contain("new Commands.RmsDueStateEvaluationCommand(42)"); + program.Should().Contain("new Commands.RmsRetentionAndPurgeCommand(43)"); + program.Should().Contain("new Commands.RecordsSearchIndexCommand(44)"); + } + + private static string ProgramSource() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !System.IO.File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + directory = directory.Parent; + + var path = Path.Combine(directory!.FullName, "Workers", "Resgrid.Workers.Console", "Program.cs"); + System.IO.File.Exists(path).Should().BeTrue("the worker host must exist at its documented path"); + return System.IO.File.ReadAllText(path); + } + [Test] public void Feature_flag_keys_match_the_registry() { diff --git a/Tests/Resgrid.Tests/Rms/SystemApiKeyRecordPolicyTests.cs b/Tests/Resgrid.Tests/Rms/SystemApiKeyRecordPolicyTests.cs index 1597d9de..36d20d21 100644 --- a/Tests/Resgrid.Tests/Rms/SystemApiKeyRecordPolicyTests.cs +++ b/Tests/Resgrid.Tests/Rms/SystemApiKeyRecordPolicyTests.cs @@ -1,66 +1,247 @@ +using System.Collections.Generic; using System.IO; +using IoFile = System.IO.File; using System.Linq; using System.Reflection; +using System.Security.Claims; using System.Text.RegularExpressions; using FluentAssertions; using NUnit.Framework; +using Resgrid.Model; using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; namespace Resgrid.Tests.Rms { /// - /// The system API key principal (SMTP relay) is built with a fixed resource list and full actions on each. - /// RMS-1 requires that it grants no Record policy at all — mutating or otherwise — so a relay credential can - /// never author, finalize, void, export or even read Records (plan RMS-1 package, registry section 4.4). - /// The handler is source-inspected the same way the helper-parity test is, because it is an - /// AuthenticationHandler that only yields its claims inside a request pipeline. + /// System principals — the SMTP relay key and the client_credentials service accounts — are built with a + /// fixed resource list and full actions on each. Records is the one exception (Identifier Allocation + /// Registry section 4.4): a system principal is restricted to Record_View under an explicitly + /// configured department+purpose grant, every mutating and restricted Record policy stays denied, and a + /// non-department-wide grant sees only the groups it names. + /// The two issuance sites are source-inspected the same way the helper-parity test is, because both build + /// their claims inside a request/token pipeline that a unit test cannot enter. /// [TestFixture] public class SystemApiKeyRecordPolicyTests { - private static string HandlerSource() + private static string _originalGrants; + + [SetUp] + public void SetUp() + { + _originalGrants = Resgrid.Config.RecordsSystemAccessConfig.Grants; + } + + [TearDown] + public void TearDown() + { + Resgrid.Config.RecordsSystemAccessConfig.Grants = _originalGrants; + } + + private static DirectoryInfo RepositoryRoot() { var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); - while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + while (directory != null && !IoFile.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) directory = directory.Parent; directory.Should().NotBeNull("the repository root should be locatable from the test directory"); - var path = Path.Combine(directory!.FullName, "Web", "Resgrid.Web.Services", "Middleware", "SystemApiKeyAuthHandler.cs"); - File.Exists(path).Should().BeTrue("the system API key handler should exist at its documented path"); - return File.ReadAllText(path); + return directory; } - [Test] - public void The_system_api_key_principal_receives_no_record_resource() + private static string SourceOf(params string[] segments) { - var source = HandlerSource(); - source.Should().Contain("ResgridClaimTypes.Resources.Log", "the file read should be the handler that enumerates resources"); + var path = Path.Combine(new[] { RepositoryRoot().FullName }.Concat(segments).ToArray()); + IoFile.Exists(path).Should().BeTrue($"{path} should exist at its documented path"); + return IoFile.ReadAllText(path); + } + + private static string HandlerSource() => + SourceOf("Web", "Resgrid.Web.Services", "Middleware", "SystemApiKeyAuthHandler.cs"); + + private static string ConnectSource() => + SourceOf("Web", "Resgrid.Web.Services", "Controllers", "v4", "ConnectController.cs"); - var recordResources = typeof(ResgridClaimTypes.Resources) + private static List RecordResourceNames() => + typeof(ResgridClaimTypes.Resources) .GetFields(BindingFlags.Public | BindingFlags.Static) .Where(f => f.Name.StartsWith("Record")) .Select(f => f.Name) .ToList(); - recordResources.Should().NotBeEmpty(); - foreach (var resource in recordResources) - Regex.IsMatch(source, $@"Resources\.{resource}\b").Should().BeFalse($"the relay principal must not carry the {resource} resource"); + /// The blanket resource array must stay free of every Record resource, including plain Record. + private static void AssertNoRecordResourceInBlanketList(string source, string arrayAnchor) + { + var start = source.IndexOf(arrayAnchor, System.StringComparison.Ordinal); + start.Should().BeGreaterThan(-1, $"the source should contain the blanket resource list anchored at {arrayAnchor}"); + var end = source.IndexOf("};", start, System.StringComparison.Ordinal); + end.Should().BeGreaterThan(start); + var block = source.Substring(start, end - start); + + foreach (var resource in RecordResourceNames()) + Regex.IsMatch(block, $@"Resources\.{resource}\b").Should().BeFalse($"the blanket resource list must not carry the {resource} resource"); } [Test] - public void The_system_api_key_handler_names_no_record_policy() + public void The_relay_blanket_resource_list_carries_no_record_resource() { var source = HandlerSource(); + source.Should().Contain("ResgridClaimTypes.Resources.Log", "the file read should be the handler that enumerates resources"); + AssertNoRecordResourceInBlanketList(source, "var resources = new[]"); + } + + [Test] + public void The_connect_blanket_resource_list_carries_no_record_resource() + { + var source = ConnectSource(); + source.Should().Contain("AddAllResourceClaims", "the file read should be the controller that issues service-account claims"); + AssertNoRecordResourceInBlanketList(source, "var resources = new[]"); + } + + [Test] + public void The_only_record_resource_either_site_names_is_record_with_the_view_action() + { + foreach (var source in new[] { HandlerSource(), ConnectSource() }) + { + // Exactly one shape is permitted: Resources.Record paired with Actions.View. + var restricted = RecordResourceNames().Where(n => n != "Record").ToList(); + restricted.Should().NotBeEmpty(); + foreach (var resource in restricted) + Regex.IsMatch(source, $@"Resources\.{resource}\b").Should().BeFalse($"a system principal must never receive the {resource} resource"); - var recordPolicies = typeof(ResgridResources) + foreach (Match match in Regex.Matches(source, @"Resources\.Record\s*,\s*ResgridClaimTypes\.Actions\.(\w+)")) + match.Groups[1].Value.Should().Be("View", "a system principal receives Record_View and nothing else"); + } + } + + [Test] + public void Neither_site_names_a_mutating_or_restricted_record_policy() + { + // Record_View is the one Record policy a system principal may ever reach, so it is the one name + // allowed to appear here (in prose or in code). Every other Record policy must be absent. + var policies = typeof(ResgridResources) .GetFields(BindingFlags.Public | BindingFlags.Static) - .Where(f => f.Name.StartsWith("Record")) + .Where(f => f.Name.StartsWith("Record") && f.Name != nameof(ResgridResources.Record_View)) .Select(f => f.Name) .ToList(); - recordPolicies.Should().NotBeEmpty(); - foreach (var policy in recordPolicies) - Regex.IsMatch(source, $@"\b{policy}\b").Should().BeFalse($"the relay handler must not reference the {policy} policy"); + policies.Should().NotBeEmpty(); + foreach (var source in new[] { HandlerSource(), ConnectSource() }) + { + foreach (var policy in policies) + Regex.IsMatch(source, $@"\b{policy}\b").Should().BeFalse($"the issuance site must not reference the {policy} policy"); + } + } + + [Test] + public void The_relay_only_adds_the_record_claim_when_a_grant_is_configured() + { + HandlerSource().Should().Contain("SystemPrincipalRecordGrant.AnyConfigured()", + "the relay must not carry Record_View when no grant exists at all"); + } + + [Test] + public void No_grant_is_configured_by_default() + { + Resgrid.Config.RecordsSystemAccessConfig.Grants = ""; + SystemPrincipalRecordGrant.AnyConfigured().Should().BeFalse(); + SystemPrincipalRecordGrant.For(12).Should().BeNull(); + } + + [Test] + public void A_department_wide_grant_parses_and_sees_the_whole_department() + { + Resgrid.Config.RecordsSystemAccessConfig.Grants = "12|NerisAudit|DepartmentWide"; + + var grant = SystemPrincipalRecordGrant.For(12); + grant.Should().NotBeNull(); + grant.Purpose.Should().Be("NerisAudit"); + grant.DepartmentWide.Should().BeTrue(); + grant.VisibleGroupIds().Should().BeNull("null is the 'whole department' filter every read path already understands"); + SystemPrincipalRecordGrant.For(13).Should().BeNull("a grant covers exactly the department it names"); + } + + [Test] + public void A_group_scoped_grant_sees_only_the_groups_it_names() + { + Resgrid.Config.RecordsSystemAccessConfig.Grants = "45|StationReporting|Groups:102,101,102"; + + var grant = SystemPrincipalRecordGrant.For(45); + grant.Should().NotBeNull(); + grant.DepartmentWide.Should().BeFalse(); + grant.GroupIds.Should().Equal(101, 102); + grant.VisibleGroupIds().Should().Equal(101, 102); + } + + [Test] + public void A_malformed_grant_is_dropped_rather_than_widened() + { + Resgrid.Config.RecordsSystemAccessConfig.Grants = "12;abc|X|DepartmentWide;13||DepartmentWide;14|Reporting|Everything;15|Reporting|Groups:"; + + var grants = SystemPrincipalRecordGrant.All(); + grants.Select(g => g.DepartmentId).Should().NotContain(new[] { 12, 13, 14 }); + + // "Groups:" with no ids is well-formed but empty: the principal sees nothing, which is the safe reading. + var empty = SystemPrincipalRecordGrant.For(15); + empty.Should().NotBeNull(); + empty.DepartmentWide.Should().BeFalse(); + empty.GroupIds.Should().BeEmpty(); + } + + [Test] + public void A_department_named_twice_resolves_deterministically_to_the_first_entry() + { + Resgrid.Config.RecordsSystemAccessConfig.Grants = "12|First|Groups:1;12|Second|DepartmentWide"; + + SystemPrincipalRecordGrant.For(12).Purpose.Should().Be("First"); + } + + [Test] + public void A_user_principal_never_resolves_a_grant() + { + Resgrid.Config.RecordsSystemAccessConfig.Grants = "12|NerisAudit|DepartmentWide"; + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ResgridClaimTypes.Data.UserId, "a-real-member"), + new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View) + }, "Cookies")); + + RecordsSystemPrincipal.IsSystemPrincipal(user).Should().BeFalse(); + RecordsSystemPrincipal.ResolveGrant(user, 12).Should().BeNull("a member's visibility comes from their own groups, never from a grant"); + } + + [Test] + public void A_system_principal_resolves_only_its_own_departments_grant() + { + Resgrid.Config.RecordsSystemAccessConfig.Grants = "12|NerisAudit|DepartmentWide"; + + var system = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ResgridClaimTypes.Data.ServiceAccount, "true"), + new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View) + }, "SystemApiKey")); + + RecordsSystemPrincipal.IsSystemPrincipal(system).Should().BeTrue(); + RecordsSystemPrincipal.ResolveGrant(system, 12).Should().NotBeNull(); + RecordsSystemPrincipal.ResolveGrant(system, 99).Should().BeNull("an ungranted department is a denial, not unrestricted access"); + } + + [Test] + public void A_revoked_grant_stops_working_for_an_already_minted_token() + { + Resgrid.Config.RecordsSystemAccessConfig.Grants = "12|NerisAudit|DepartmentWide"; + var system = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ResgridClaimTypes.Data.ServiceAccount, "true"), + new Claim(ResgridClaimTypes.Data.RecordGrantPurpose, "NerisAudit"), + new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View) + }, "Bearer")); + + RecordsSystemPrincipal.ResolveGrant(system, 12).Should().NotBeNull(); + + Resgrid.Config.RecordsSystemAccessConfig.Grants = ""; + RecordsSystemPrincipal.ResolveGrant(system, 12).Should().BeNull("the grant is re-read per request, not trusted from the token"); } } } diff --git a/Tests/Resgrid.Tests/Services/WorkflowSampleDataGeneratorTests.cs b/Tests/Resgrid.Tests/Services/WorkflowSampleDataGeneratorTests.cs index c827f342..dec8181c 100644 --- a/Tests/Resgrid.Tests/Services/WorkflowSampleDataGeneratorTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkflowSampleDataGeneratorTests.cs @@ -21,6 +21,32 @@ public void GenerateSampleData_ReturnsNonNullForAllEventTypes() } } + [Test] + public void GenerateSampleData_IncludesTheSubmissionBlockForTheSubmissionTriggers() + { + // The dispatch switch previously stopped at RecordCancelled (107), so the submission triggers advertised + // submission.* in the variable catalog and rendered nothing in the preview. + foreach (var eventType in new[] + { + WorkflowTriggerEventType.RecordSubmissionQueued, WorkflowTriggerEventType.RecordSubmissionAccepted, + WorkflowTriggerEventType.RecordSubmissionRejected, WorkflowTriggerEventType.RecordSubmissionFailed + }) + { + var data = (ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData(eventType); + data["record"].Should().NotBeNull($"{eventType} advertises record.*"); + data["submission"].Should().NotBeNull($"{eventType} advertises submission.*"); + } + } + + [Test] + public void GenerateSampleData_IncludesTheObligationBlockForRecordOverdue() + { + var data = (ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData(WorkflowTriggerEventType.RecordOverdue); + + data["record"].Should().NotBeNull(); + data["obligation"].Should().NotBeNull(); + } + [Test] public void GenerateSampleData_IncludesDepartmentVariables() { diff --git a/Tests/Resgrid.Tests/TestBase.cs b/Tests/Resgrid.Tests/TestBase.cs index 22371abe..97d9158d 100644 --- a/Tests/Resgrid.Tests/TestBase.cs +++ b/Tests/Resgrid.Tests/TestBase.cs @@ -41,6 +41,12 @@ protected T Resolve() return Bootstrapper.GetKernel().Resolve(); } + /// Every registration for a service registered more than once (the evidence adapters, for example). + protected System.Collections.Generic.IEnumerable ResolveAll() + { + return Bootstrapper.GetKernel().Resolve>(); + } + [SetUp] public void SetupContext_ALL() { diff --git a/Tests/Resgrid.Tests/Web/Services/RecordsApiControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/RecordsApiControllerTests.cs index 37945a0c..25da72db 100644 --- a/Tests/Resgrid.Tests/Web/Services/RecordsApiControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/RecordsApiControllerTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -43,6 +43,7 @@ public class RecordsApiControllerTests private Mock _search; private Mock _uploads; private Mock _idempotency; + private Mock _dashboard; private RecordsModuleState _moduleState; private RecordsController _controller; private DefaultHttpContext _http; @@ -71,6 +72,7 @@ public void SetUp() _uploads = new Mock(); _uploads.SetupGet(u => u.ChunkSize).Returns(512 * 1024); _idempotency = new Mock(); + _dashboard = new Mock(); _http = new DefaultHttpContext { @@ -87,7 +89,7 @@ public void SetUp() ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = _http }; _activity = new Activity("RecordsApiControllerTests").Start(); - _controller = new RecordsController(_records.Object, _cutover.Object, _authorization.Object, _flags.Object, _settings.Object, _adp.Object, _search.Object, _uploads.Object, _idempotency.Object) + _controller = new RecordsController(_records.Object, _cutover.Object, _authorization.Object, _flags.Object, _settings.Object, _adp.Object, _search.Object, _uploads.Object, _idempotency.Object, _dashboard.Object) { ControllerContext = new ControllerContext { HttpContext = _http } }; @@ -280,10 +282,10 @@ public async Task Finalize_requires_attestation_checks_the_row_version_and_remem var finalized = await _controller.Finalize(new RecordCommandInput { RecordId = RecordId, RowVersion = 5, Attested = true, IdempotencyKey = "fin-1" }, CancellationToken.None); ((RecordResult)((OkObjectResult)finalized.Result).Value).Data.StateName.Should().Be("Finalized"); - _idempotency.Verify(i => i.RememberAsync(Dept, Me, "fin-1", RecordId), Times.Once); + _idempotency.Verify(i => i.RememberAsync(Dept, Me, "fin-1", "Finalize", RecordId), Times.Once); // Replay: the remembered key short-circuits the transition. - _idempotency.Setup(i => i.TryGetRecordIdAsync(Dept, Me, "fin-1")).ReturnsAsync(RecordId); + _idempotency.Setup(i => i.TryGetRecordIdAsync(Dept, Me, "fin-1", "Finalize")).ReturnsAsync(RecordId); var replayed = await _controller.Finalize(new RecordCommandInput { RecordId = RecordId, RowVersion = 99, Attested = true, IdempotencyKey = "fin-1" }, CancellationToken.None); replayed.Result.Should().BeOfType(); _records.Verify(r => r.FinalizeAsync(Dept, Me, RecordId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); @@ -308,7 +310,7 @@ public async Task Changes_returns_a_cursor_tombstones_and_drops_rows_outside_the var t0 = new DateTime(2026, 9, 3, 10, 0, 0, DateTimeKind.Utc); _authorization.Setup(a => a.GetVisibleGroupIdsAsync(Me, Dept)).ReturnsAsync(new List { 1 }); _authorization.Setup(a => a.CanUserViewRecordAsync(Me, "hidden", Dept)).ReturnsAsync(false); - _records.Setup(r => r.GetChangesSinceAsync(Dept, It.IsAny(), 3)).ReturnsAsync(new List + _records.Setup(r => r.GetChangesSinceAsync(Dept, It.IsAny(), 3, It.IsAny())).ReturnsAsync(new List { new RmsRecordSearchProjection { RmsRecordSearchProjectionId = "live", State = (int)RmsRecordState.Draft, ModifiedOn = t0, RecordCreatedOn = t0 }, new RmsRecordSearchProjection { RmsRecordSearchProjectionId = "hidden", State = (int)RmsRecordState.Draft, ModifiedOn = t0.AddMinutes(1), RecordCreatedOn = t0 }, @@ -322,7 +324,7 @@ public async Task Changes_returns_a_cursor_tombstones_and_drops_rows_outside_the data.Records.Select(r => r.RecordId).Should().BeEquivalentTo(new[] { "live" }, "the hidden row is outside the caller's group scope"); data.ServerTimestampMs.Should().Be(new DateTimeOffset(t0.AddMinutes(1)).ToUnixTimeMilliseconds(), "the cursor stops at the last row of the page so nothing is skipped"); - _records.Setup(r => r.GetChangesSinceAsync(Dept, It.IsAny(), 3)).ReturnsAsync(new List + _records.Setup(r => r.GetChangesSinceAsync(Dept, It.IsAny(), 3, It.IsAny())).ReturnsAsync(new List { new RmsRecordSearchProjection { RmsRecordSearchProjectionId = "gone", State = (int)RmsRecordState.Voided, ModifiedOn = t0.AddMinutes(2), RecordCreatedOn = t0 } }); diff --git a/Tests/Resgrid.Tests/Web/Services/Rms3ApiMapperTests.cs b/Tests/Resgrid.Tests/Web/Services/Rms3ApiMapperTests.cs new file mode 100644 index 00000000..4f738ecc --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/Rms3ApiMapperTests.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; + +namespace Resgrid.Tests.Web.Services +{ + /// + /// The RMS-3 v4 mapping rules that a client depends on and that a restricted grant decides: conditional + /// sections carry the rule and its value sets, restricted content is withheld and named rather than + /// silently dropped, and the bodies that would be expensive or sensitive to ship are only carried on the + /// single-item reads. + /// + [TestFixture] + public class Rms3ApiMapperTests + { + private const int Dept = 42; + + #region Incident report sections + + [Test] + public void Sections_carry_the_rule_its_value_sets_and_whether_the_report_already_has_one() + { + var aggregate = Report(); + aggregate.Modules.Add(new RmsIncidentModule + { + RmsIncidentModuleId = "m1", ModuleKind = (int)RmsIncidentModuleKind.Fire, PrimaryCode = "HYDRANT", SecondaryCode = "NO", Ordinal = 0 + }); + + var sections = new List + { + new NerisSectionRequirement { Kind = RmsIncidentModuleKind.Fire, Required = true, Reason = "A fire incident carries fire detail.", PrimaryCodeSet = "water_supply", SecondaryCodeSet = "fire_invest_need" }, + new NerisSectionRequirement { Kind = RmsIncidentModuleKind.SmokeAlarm, Required = false, Reason = "Alarm performance is worth recording.", PrimaryCodeSet = "alarm_smoke", SecondaryCodeSet = "alarm_operation" } + }; + + var data = IncidentReportsApiMapper.ToReport(aggregate, false, true, sections); + + data.Sections.Should().HaveCount(2); + + var fire = data.Sections.Single(s => s.Kind == (int)RmsIncidentModuleKind.Fire); + fire.Required.Should().BeTrue(); + fire.Present.Should().BeTrue("the report already carries a fire section"); + fire.PayloadPath.Should().Be("fire_detail"); + fire.SchemaName.Should().Be("FirePayload"); + fire.PrimaryCodeSet.Should().Be("water_supply"); + fire.SecondaryCodeSet.Should().Be("fire_invest_need"); + + var alarm = data.Sections.Single(s => s.Kind == (int)RmsIncidentModuleKind.SmokeAlarm); + alarm.Required.Should().BeFalse(); + alarm.Present.Should().BeFalse(); + alarm.Reason.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public void A_report_with_no_section_rules_yet_still_maps() + { + var data = IncidentReportsApiMapper.ToReport(Report(), false); + + data.Sections.Should().BeEmpty(); + data.Modules.Should().BeEmpty(); + data.WithheldFields.Should().BeEmpty(); + } + + [Test] + public void A_rejected_report_can_be_re_queued_and_the_flag_says_so() + { + var aggregate = Report(); + aggregate.Report.State = (int)RmsRecordState.Rejected; + aggregate.Report.CurrentRevisionId = "rev-1"; + aggregate.Submissions.Add(new RmsSubmission + { + RmsSubmissionId = "s1", DepartmentId = Dept, RecordId = "r1", RevisionId = "rev-1", + State = (int)RmsSubmissionState.Rejected, QueuedOn = DateTime.UtcNow + }); + + // The destination stores a rejection as RmsSubmissionState.Rejected and QueueSubmissionCoreAsync re-queues + // it; reporting only Failed hid the retry from every client that reads the flag. + IncidentReportsApiMapper.ToReport(aggregate, true).CanQueueSubmission.Should().BeTrue(); + } + + #endregion + + #region Restricted withholding + + [Test] + public void A_casualty_stays_visible_without_the_restricted_grant_but_its_identifying_half_is_withheld() + { + var aggregate = Report(); + aggregate.Casualties.Add(Casualty()); + + var data = IncidentReportsApiMapper.ToReport(aggregate, false, false); + + var casualty = data.Casualties.Should().ContainSingle().Subject; + casualty.CasualtyCause.Should().Be("FALL", "that somebody was hurt is part of the incident"); + casualty.WasFatal.Should().BeFalse(); + + casualty.PersonnelUserId.Should().BeNull(); + casualty.Rank.Should().BeNull(); + casualty.BirthMonthYear.Should().BeNull(); + casualty.Gender.Should().BeNull(); + casualty.Race.Should().BeNull(); + casualty.InjuryDetailJson.Should().BeNull(); + + data.WithheldFields.Should().Contain(new[] + { + "Casualties.PersonnelUserId", "Casualties.Rank", "Casualties.BirthMonthYear", + "Casualties.Gender", "Casualties.Race", "Casualties.InjuryDetailJson" + }); + } + + [Test] + public void The_restricted_grant_carries_the_whole_casualty() + { + var aggregate = Report(); + aggregate.Casualties.Add(Casualty()); + + var data = IncidentReportsApiMapper.ToReport(aggregate, false, true); + + var casualty = data.Casualties.Should().ContainSingle().Subject; + casualty.PersonnelUserId.Should().Be("member-1"); + casualty.Rank.Should().Be("LIEUTENANT"); + casualty.BirthMonthYear.Should().Be("1979-04"); + data.WithheldFields.Should().BeEmpty(); + } + + [Test] + public void A_vehicle_keeps_its_make_and_damage_but_loses_vin_and_plate_without_the_grant() + { + var aggregate = Analysis(); + aggregate.Vehicles.Add(Vehicle()); + + var data = IncidentAnalysisApiMapper.ToAnalysis(aggregate, false); + + var vehicle = data.Vehicles.Should().ContainSingle().Subject; + vehicle.Make.Should().Be("FORD"); + vehicle.DamageType.Should().Be("DAMAGED_NOT_DRIVABLE"); + vehicle.Vin.Should().BeNull(); + vehicle.LicensePlate.Should().BeNull(); + vehicle.LicenseState.Should().BeNull(); + data.WithheldFields.Should().Contain(new[] { "Vehicles.Vin", "Vehicles.LicensePlate", "Vehicles.LicenseState" }); + } + + [Test] + public void A_disclosure_request_withholds_who_asked_without_the_restricted_grant() + { + var request = new RmsDisclosureRequest + { + RmsDisclosureRequestId = "d1", DepartmentId = Dept, RequestNumber = "PRR-2026-0001", State = (int)RmsDisclosureState.Received, + RequesterName = "A. Reporter", RequesterOrganization = "The Gazette", RequesterContact = "a@example.test", + ReceivedOn = DateTime.UtcNow, RowVersion = 3 + }; + + var withheld = DisclosuresApiMapper.ToRequest(request, false); + withheld.RequestNumber.Should().Be("PRR-2026-0001", "the reference is not itself sensitive"); + withheld.RequesterName.Should().BeNull(); + withheld.RequesterOrganization.Should().BeNull(); + withheld.RequesterContact.Should().BeNull(); + withheld.WithheldFields.Should().BeEquivalentTo(new[] { "RequesterName", "RequesterOrganization", "RequesterContact" }); + + var granted = DisclosuresApiMapper.ToRequest(request, true); + granted.RequesterName.Should().Be("A. Reporter"); + granted.WithheldFields.Should().BeEmpty(); + } + + #endregion + + #region Bodies are only carried where they belong + + [Test] + public void An_evidence_list_never_ships_manifests_and_a_restricted_one_says_it_was_withheld() + { + var artifact = new RmsEvidenceArtifact + { + RmsEvidenceArtifactId = "e1", DepartmentId = Dept, RecordId = "r1", Kind = (int)RmsEvidenceKind.TrackingFix, + Title = "Unit tracking", ManifestJson = "{\"fixes\":[]}", Checksum = "abc", SourceItemCount = 4, + Classification = (int)RmsEvidenceClassification.Restricted, CapturedOn = DateTime.UtcNow + }; + + RecordEvidenceApiMapper.ToArtifact(artifact, true).ManifestJson + .Should().BeNull("a list response carries the header only, however many artifacts it holds"); + + var withheld = RecordEvidenceApiMapper.ToArtifact(artifact, false, true); + withheld.Title.Should().Be("Unit tracking"); + withheld.ManifestJson.Should().BeNull(); + withheld.ManifestWithheld.Should().BeTrue("the caller should know there is content they are not seeing"); + + var granted = RecordEvidenceApiMapper.ToArtifact(artifact, true, true); + granted.ManifestJson.Should().Be("{\"fixes\":[]}"); + granted.ManifestWithheld.Should().BeFalse(); + } + + [Test] + public void An_unrestricted_manifest_is_readable_without_the_restricted_grant() + { + var artifact = new RmsEvidenceArtifact + { + RmsEvidenceArtifactId = "e2", DepartmentId = Dept, RecordId = "r1", Kind = (int)RmsEvidenceKind.RunCardActivation, + ManifestJson = "{\"card\":\"A\"}", Classification = (int)RmsEvidenceClassification.Unrestricted, CapturedOn = DateTime.UtcNow + }; + + RecordEvidenceApiMapper.ToArtifact(artifact, false, true).ManifestJson.Should().Be("{\"card\":\"A\"}"); + } + + [Test] + public void A_production_listing_omits_the_released_content_but_keeps_the_produced_set() + { + var production = new RmsDisclosureProduction + { + RmsDisclosureProductionId = "p1", DisclosureRequestId = "d1", ProductionNumber = 1, + RedactionProfile = RmsRedactionProfiles.Standard, ProducedSetJson = "[{\"recordId\":\"r1\"}]", + WithheldFieldsJson = "[]", ArtifactJson = "{\"records\":[]}", Checksum = "def", RecordCount = 1, PreparedOn = DateTime.UtcNow + }; + + var listed = DisclosuresApiMapper.ToProduction(production); + listed.ArtifactJson.Should().BeNull(); + listed.ProducedSetJson.Should().NotBeNull("the produced set is what proves a later amendment changed nothing"); + + DisclosuresApiMapper.ToProduction(production, true).ArtifactJson.Should().Be("{\"records\":[]}"); + } + + #endregion + + #region Scope input + + [Test] + public void A_scope_query_never_takes_the_viewer_fields_from_the_client() + { + var scope = DisclosuresApiMapper.ToScopeQuery(new DisclosureScopeQueryInput + { + States = new List { (int)RmsRecordState.Finalized }, + DefinitionKey = " ", + Year = 2026, + Take = 5000 + }); + + scope.States.Should().BeEquivalentTo(new[] { (int)RmsRecordState.Finalized }); + scope.DefinitionKey.Should().BeNull("a blank filter is no filter"); + scope.Year.Should().Be(2026); + scope.Take.Should().Be(500, "the page size is clamped so a scope cannot ask for the whole department at once"); + scope.ViewerUserId.Should().BeNull(); + scope.VisibleGroupIds.Should().BeNull(); + } + + [Test] + public void A_missing_scope_is_null_rather_than_an_empty_query_that_would_match_everything() + { + DisclosuresApiMapper.ToScopeQuery(null).Should().BeNull(); + } + + #endregion + + #region Fixtures + + private static IncidentReportAggregate Report() + { + return new IncidentReportAggregate + { + Report = new RmsIncidentReport + { + RmsIncidentReportId = "r1", DepartmentId = Dept, CallId = 7, DefinitionKey = "neris.incident", DefinitionVersion = 1, + LifecyclePreset = (int)RmsLifecyclePreset.QuickEntry, State = (int)RmsRecordState.Draft, DraftReference = "D-ABCDE", + RowVersion = 3, CreatedOn = DateTime.UtcNow, ModifiedOn = DateTime.UtcNow + } + }; + } + + private static IncidentAnalysisAggregate Analysis() + { + return new IncidentAnalysisAggregate + { + Analysis = new RmsIncidentAnalysis + { + RmsIncidentAnalysisId = "a1", DepartmentId = Dept, IncidentReportId = "r1", State = (int)RmsIncidentAnalysisState.Draft, + RowVersion = 2, CreatedOn = DateTime.UtcNow, ModifiedOn = DateTime.UtcNow + }, + Report = new RmsIncidentReport { RmsIncidentReportId = "r1", DepartmentId = Dept } + }; + } + + private static RmsCasualtyRescue Casualty() + { + return new RmsCasualtyRescue + { + RmsCasualtyRescueId = "c1", DepartmentId = Dept, RecordId = "r1", Kind = (int)RmsCasualtyRescueKind.Casualty, + PersonType = RmsCasualtyPersonTypes.Firefighter, PersonnelUserId = "member-1", Rank = "LIEUTENANT", BirthMonthYear = "1979-04", + Gender = "MALE", Race = "WHITE", WasInjured = true, WasFatal = false, CasualtyCause = "FALL", + InjuryDetailJson = "{\"body_part\":\"ANKLE\"}", Ordinal = 0 + }; + } + + private static RmsIncidentVehicle Vehicle() + { + return new RmsIncidentVehicle + { + RmsIncidentVehicleId = "v1", DepartmentId = Dept, RecordId = "a1", VehicleKind = "AUTOMOBILE", Make = "FORD", + DamageType = "DAMAGED_NOT_DRIVABLE", Vin = "1FTFW1ET5DFC12345", LicensePlate = "ABC 123", LicenseState = "IL", Ordinal = 0 + }; + } + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs index 25ea6651..44e41559 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs @@ -518,6 +518,7 @@ public async Task Token() // Add all resource claims for full access AddAllResourceClaims(identity); + AddSystemRecordViewClaim(identity, 0); var principal = new ClaimsPrincipal(identity); @@ -588,6 +589,7 @@ public async Task Token() // Add all resource claims for full department access AddAllResourceClaims(deptIdentity); + AddSystemRecordViewClaim(deptIdentity, department.DepartmentId); var deptPrincipal = new ClaimsPrincipal(deptIdentity); @@ -1336,6 +1338,30 @@ private static void AddAllResourceClaims(ClaimsIdentity identity) } } } + /// + /// Records (RMS) is deliberately absent from . A system principal — + /// the cross-department system account or a department service account — reads Records only under an + /// explicitly configured department+purpose grant, and then only with Record_View + /// (Identifier Allocation Registry section 4.4). No configuration produces a mutating or restricted + /// Record claim here. The purpose rides along on its own claim so the per-request guard can identify + /// the principal as non-user and write the purpose to the Record access audit. + /// + /// The principal's department, or 0 for the cross-department system account. + private static void AddSystemRecordViewClaim(ClaimsIdentity identity, int departmentId) + { + var grant = departmentId > 0 + ? SystemPrincipalRecordGrant.For(departmentId) + : SystemPrincipalRecordGrant.All().FirstOrDefault(); + + if (grant == null) + return; + + identity.AddClaim(new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View) + .SetDestinations(Destinations.AccessToken)); + identity.AddClaim(new Claim(ResgridClaimTypes.Data.RecordGrantPurpose, grant.Purpose) + .SetDestinations(Destinations.AccessToken)); + } + /// /// Performs a timing-safe comparison of two secret strings to prevent timing attacks. /// diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DisclosuresController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DisclosuresController.cs new file mode 100644 index 00000000..8e0fab5f --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/DisclosuresController.cs @@ -0,0 +1,390 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Public-records and access-to-information requests over the v4 Records contract (RMS plan section 4.7, RMS-3). + /// + /// Every endpoint requires RecordDisclosure_Update, including the reads: a public-records queue names who + /// asked for what, which is itself restricted in most jurisdictions, so this is not a Record_View surface. A + /// system principal is refused outright — a statutory release is a human act, and no service account is ever + /// granted a disclosure policy. + /// + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Authorize(Policy = ResgridResources.RecordDisclosure_Update)] + public class DisclosuresController : V4AuthenticatedApiControllerbase + { + private readonly IRecordsDisclosureService _disclosures; + private readonly IRecordsCutoverService _cutoverService; + private readonly IRecordsApiIdempotencyService _idempotency; + + public DisclosuresController(IRecordsDisclosureService disclosures, IRecordsCutoverService cutoverService, IRecordsApiIdempotencyService idempotency) + { + _disclosures = disclosures; + _cutoverService = cutoverService; + _idempotency = idempotency; + } + + #region Reads + + /// The disclosure queue, newest first; filters to one state. + [HttpGet("GetRequests")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetRequests(int? state, int skip = 0, int take = 50) + { + if (!await FlagOnAsync()) + return NotFound(); + + var states = state.HasValue ? new List { (RmsDisclosureState)state.Value } : null; + var requests = await _disclosures.QueryAsync(DepartmentId, states, Math.Max(0, skip), Math.Max(1, Math.Min(RecordsController.MaxPageSize, take))); + var canViewRestricted = ClaimsAuthorizationHelper.CanViewRestrictedRecords(); + + var result = new DisclosureRequestsResult + { + Data = requests.Select(r => DisclosuresApiMapper.ToRequest(r, canViewRestricted)).ToList(), + Status = ResponseHelper.Success + }; + result.Total = skip + result.Data.Count; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + [HttpGet("GetRequest")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetRequest(string id) + { + if (!await FlagOnAsync()) + return NotFound(); + + var request = await _disclosures.GetAsync(DepartmentId, id); + if (request == null) + return NotFound(); + + return Ok(Wrap(request)); + } + + /// + /// What the scope resolves to right now, through the same authorization path as the Records queue. Drafts + /// are listed but never producible: an unfinished report is not a public record. + /// + [HttpGet("PreviewScope")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PreviewScope(string id, int take = 200) + { + if (!await FlagOnAsync()) + return NotFound(); + if (await _disclosures.GetAsync(DepartmentId, id) == null) + return NotFound(); + + try + { + var preview = await _disclosures.PreviewScopeAsync(DepartmentId, UserId, id, Math.Max(1, Math.Min(RecordsController.MaxPageSize, take))); + var result = new DisclosureScopePreviewResult { Data = DisclosuresApiMapper.ToPreview(preview), PageSize = preview.Items.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + /// The request's productions; the released content itself is only carried by GetProduction. + [HttpGet("GetProductions")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProductions(string requestId) + { + if (!await FlagOnAsync()) + return NotFound(); + if (await _disclosures.GetAsync(DepartmentId, requestId) == null) + return NotFound(); + + var productions = await _disclosures.GetProductionsAsync(DepartmentId, requestId); + var result = new DisclosureProductionsResult { Data = productions.Select(p => DisclosuresApiMapper.ToProduction(p)).ToList(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// One production with its redacted artifact. The request id is required rather than inferred: a production + /// is only meaningful as part of its request, and reading one is the act of re-opening a release. + /// + [HttpGet("GetProduction")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProduction(string requestId, string id) + { + var production = await LoadProductionAsync(requestId, id); + if (production == null) + return NotFound(); + + var result = new DisclosureProductionResult { Data = DisclosuresApiMapper.ToProduction(production, true), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// Re-computes a production's checksum from its stored artifact; false means it was tampered with. + [HttpGet("VerifyProduction")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> VerifyProduction(string requestId, string id) + { + if (await LoadProductionAsync(requestId, id) == null) + return NotFound(); + + var intact = await _disclosures.VerifyProductionAsync(DepartmentId, id); + var result = new DisclosureVerifyResult + { + Data = new DisclosureVerifyData { ProductionId = id, Intact = intact }, + PageSize = 1, + Status = intact ? ResponseHelper.Success : ResponseHelper.Failure + }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + #endregion + + #region Workflow + + /// Logs a request and starts the statutory clock. + [HttpPost("Create")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task> Create(CreateDisclosureRequestInput input, CancellationToken cancellationToken) + { + if (input == null) + return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) + return usable; + + var key = RecordsApiHelper.ResolveIdempotencyKey(input.IdempotencyKey, Request); + if (key != null) + { + var replayed = await _idempotency.TryGetRecordIdAsync(DepartmentId, UserId, key, nameof(Create)); + if (!string.IsNullOrWhiteSpace(replayed)) + { + var existing = await _disclosures.GetAsync(DepartmentId, replayed); + if (existing != null) + return Ok(Wrap(existing)); + } + } + + try + { + var created = await _disclosures.CreateRequestAsync(DepartmentId, UserId, new RmsDisclosureRequest + { + RequesterName = input.RequesterName, + RequesterOrganization = input.RequesterOrganization, + RequesterContact = input.RequesterContact, + ReceivedOn = RecordsApiHelper.Utc(input.ReceivedOn) ?? DateTime.UtcNow, + StatutoryDueOn = RecordsApiHelper.Utc(input.StatutoryDueOn), + JurisdictionProfile = input.JurisdictionProfile, + ScopeNarrative = input.ScopeNarrative, + AssignedToUserId = input.AssignedToUserId, + RedactionProfile = input.RedactionProfile + }, cancellationToken); + + if (key != null) + await _idempotency.RememberAsync(DepartmentId, UserId, key, nameof(Create), created.RmsDisclosureRequestId); + + return StatusCode(StatusCodes.Status201Created, Wrap(created, ResponseHelper.Created)); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + /// Saves the scope query and narrative; refused once a production exists, because the scope is what was produced against. + [HttpPost("SaveScope")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> SaveScope(SaveDisclosureScopeInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.RequestId)) + return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) + return usable; + if (await _disclosures.GetAsync(DepartmentId, input.RequestId) == null) + return NotFound(); + + try + { + var saved = await _disclosures.SaveScopeAsync(DepartmentId, UserId, input.RequestId, input.ScopeNarrative, + DisclosuresApiMapper.ToScopeQuery(input.Scope), input.RedactionProfile, cancellationToken); + return Ok(Wrap(saved, ResponseHelper.Updated)); + } + catch (RecordTransitionException ex) + { + return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message, type: "record_transition"); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + /// Builds a new immutable production: redacted content, produced-set snapshot, redaction log and checksum. + [HttpPost("Produce")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task> Produce(DisclosureCommandInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.RequestId)) + return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) + return usable; + if (await _disclosures.GetAsync(DepartmentId, input.RequestId) == null) + return NotFound(); + + try + { + var production = await _disclosures.ProduceAsync(DepartmentId, UserId, input.RequestId, input.RedactionProfile, cancellationToken); + var result = new DisclosureProductionResult { Data = DisclosuresApiMapper.ToProduction(production), PageSize = 1, Status = ResponseHelper.Created }; + ResponseHelper.PopulateV4ResponseData(result); + return StatusCode(StatusCodes.Status201Created, result); + } + catch (RecordTransitionException ex) + { + return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message, type: "record_transition"); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + /// Releases a prepared production to the requester and closes the statutory clock. + [HttpPost("Release")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Release(DisclosureCommandInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.RequestId) || string.IsNullOrWhiteSpace(input.ProductionId)) + return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) + return usable; + if (await LoadProductionAsync(input.RequestId, input.ProductionId) == null) + return NotFound(); + + try + { + var released = await _disclosures.ReleaseAsync(DepartmentId, UserId, input.ProductionId, cancellationToken); + var result = new DisclosureProductionResult { Data = DisclosuresApiMapper.ToProduction(released), PageSize = 1, Status = ResponseHelper.Updated }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (RecordTransitionException ex) + { + return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message, type: "record_transition"); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + /// Closes a request without release — denied under an exemption, or withdrawn. A reason is required. + [HttpPost("Close")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Close(DisclosureCommandInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.RequestId) || !input.Disposition.HasValue || string.IsNullOrWhiteSpace(input.Reason)) + return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) + return usable; + if (await _disclosures.GetAsync(DepartmentId, input.RequestId) == null) + return NotFound(); + + try + { + var closed = await _disclosures.CloseAsync(DepartmentId, UserId, input.RequestId, (RmsDisclosureState)input.Disposition.Value, input.Reason, cancellationToken); + return Ok(Wrap(closed, ResponseHelper.Updated)); + } + catch (RecordTransitionException ex) + { + return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message, type: "record_transition"); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + #endregion + + #region Helpers + + private async Task FlagOnAsync() + { + return (await _cutoverService.GetModuleStateAsync(DepartmentId)).FlagEnabled; + } + + private async Task UsableAsync() + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return NotFound(); + if (!moduleState.RecordsUsable) + return Problem(statusCode: StatusCodes.Status409Conflict, title: "Records is not activated for this department.", type: "records_not_activated"); + return null; + } + + /// A production is only reachable through its own request, so a stray id cannot walk another department's release. + private async Task LoadProductionAsync(string requestId, string productionId) + { + if (string.IsNullOrWhiteSpace(requestId) || string.IsNullOrWhiteSpace(productionId) || !await FlagOnAsync()) + return null; + if (await _disclosures.GetAsync(DepartmentId, requestId) == null) + return null; + + var productions = await _disclosures.GetProductionsAsync(DepartmentId, requestId); + return productions.FirstOrDefault(p => string.Equals(p.RmsDisclosureProductionId, productionId, StringComparison.Ordinal)); + } + + private DisclosureRequestResult Wrap(RmsDisclosureRequest request, string status = ResponseHelper.Success) + { + var result = new DisclosureRequestResult + { + Data = DisclosuresApiMapper.ToRequest(request, ClaimsAuthorizationHelper.CanViewRestrictedRecords()), + PageSize = 1, + Status = status + }; + ResponseHelper.PopulateV4ResponseData(result); + RecordsApiHelper.SetETag(Response, request.RowVersion); + return result; + } + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentAnalysisController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentAnalysisController.cs new file mode 100644 index 00000000..25a89904 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentAnalysisController.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Mime; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// The NERIS incident-analysis filing (RMS-3): the fire/hazmat investigation the contract posts separately from + /// the incident, once the destination already holds it. + /// + /// It is a second submittable artifact for the same incident rather than a section of it, so it has its own + /// endpoints, its own ETag and its own lifecycle — an analysis that will not validate must never block the + /// incident report. Visibility is inherited from the incident: an analysis is never visible to somebody who + /// cannot see the report it belongs to. + /// + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class IncidentAnalysisController : V4AuthenticatedApiControllerbase, IActionFilter + { + private readonly IIncidentAnalysisService _analysis; + private readonly IIncidentReportsService _incidentReports; + private readonly IRecordsCutoverService _cutoverService; + private readonly IRecordsAuthorizationService _recordsAuthorizationService; + private readonly IFeatureToggleService _featureToggleService; + private readonly IRecordsApiIdempotencyService _idempotency; + + private SystemPrincipalRecordGrant _systemGrant; + private bool _systemGrantResolved; + + public IncidentAnalysisController(IIncidentAnalysisService analysis, IIncidentReportsService incidentReports, IRecordsCutoverService cutoverService, + IRecordsAuthorizationService recordsAuthorizationService, IFeatureToggleService featureToggleService, IRecordsApiIdempotencyService idempotency) + { + _analysis = analysis; + _incidentReports = incidentReports; + _cutoverService = cutoverService; + _recordsAuthorizationService = recordsAuthorizationService; + _featureToggleService = featureToggleService; + _idempotency = idempotency; + } + + /// Same system-principal gate as the other Records controllers (registry section 4.4). + public void OnActionExecuting(ActionExecutingContext context) + { + if (!RecordsSystemPrincipal.IsSystemPrincipal(User)) + return; + + if (SystemGrant == null) + context.Result = Problem(statusCode: StatusCodes.Status403Forbidden, + title: "This system principal has no configured Record grant for this department.", type: "record_grant_missing"); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + + private SystemPrincipalRecordGrant SystemGrant + { + get + { + if (!_systemGrantResolved) + { + _systemGrant = RecordsSystemPrincipal.ResolveGrant(User, DepartmentId); + _systemGrantResolved = true; + } + + return _systemGrant; + } + } + + #region Reads + + /// The analysis by its own id. + [HttpGet("GetIncidentAnalysis")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> GetIncidentAnalysis(string id) + { + if (!await FlagOnAsync()) + return NotFound(); + + var aggregate = await _analysis.GetAsync(DepartmentId, id, true); + if (aggregate?.Analysis == null || !await CanViewReportAsync(aggregate.Analysis.IncidentReportId)) + return NotFound(); + + await RecordReadAsync(aggregate.Analysis.IncidentReportId); + return Ok(Wrap(aggregate)); + } + + /// The analysis for an incident report, if one has been started. + [HttpGet("GetForReport")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> GetForReport(string reportId) + { + if (!await FlagOnAsync()) + return NotFound(); + if (string.IsNullOrWhiteSpace(reportId) || !await CanViewReportAsync(reportId)) + return NotFound(); + + var aggregate = await _analysis.GetForReportAsync(DepartmentId, reportId, true); + if (aggregate?.Analysis == null) + return NotFound(); + + return Ok(Wrap(aggregate)); + } + + #endregion + + #region Authoring + + /// Starts (or returns) the analysis for a report; one per report, so a second start is a 200. + [HttpPost("Start")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Start(StartIncidentAnalysisInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentReportId)) + return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) + return usable; + + var origin = RecordsApiHelper.ResolveOrigin(input.OriginClient); + var gate = await FieldClientGateAsync(origin); + if (gate != null) + return gate; + + if (!await CanViewReportAsync(input.IncidentReportId)) + return NotFound(); + + try + { + var existing = await _analysis.GetForReportAsync(DepartmentId, input.IncidentReportId); + var aggregate = await _analysis.StartForReportAsync(DepartmentId, UserId, input.IncidentReportId, origin, cancellationToken); + var result = Wrap(aggregate, existing?.Analysis != null ? ResponseHelper.Success : ResponseHelper.Created); + return existing?.Analysis != null ? Ok(result) : StatusCode(StatusCodes.Status201Created, result); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + /// ETag-guarded draft save; 409 with the current analysis on a stale row version. + [HttpPost("SaveDraft")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> SaveDraft(SaveIncidentAnalysisDraftInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.AnalysisId)) + return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) + return usable; + + var rowVersion = RecordsApiHelper.ResolveRowVersion(input.RowVersion, Request); + if (!rowVersion.HasValue) + return Problem(statusCode: StatusCodes.Status428PreconditionRequired, title: "RowVersion or If-Match is required for a draft save.", type: "precondition_required"); + + var origin = RecordsApiHelper.ResolveOrigin(input.OriginClient); + var gate = await FieldClientGateAsync(origin); + if (gate != null) + return gate; + + var current = await LoadAuthorizedAsync(input.AnalysisId); + if (current == null) + return NotFound(); + if (!CanEdit(current)) + return Forbid(); + + try + { + var saved = await _analysis.SaveDraftAsync(DepartmentId, UserId, input.AnalysisId, rowVersion.Value, + IncidentAnalysisApiMapper.ToDraftInput(input, origin), ClaimsAuthorizationHelper.CanViewRestrictedRecords(), cancellationToken); + return Ok(Wrap(saved, ResponseHelper.Updated)); + } + catch (RecordConcurrencyException ex) + { + return await ConflictAsync(input.AnalysisId, ex.ExpectedRowVersion); + } + catch (RecordTransitionException ex) + { + return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message, type: "record_transition"); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + /// Local validation against the pinned contract; the incident's own issue list is never touched. + [HttpPost("Validate")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Validate(IncidentAnalysisCommandInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.AnalysisId)) + return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) + return usable; + if (await LoadAuthorizedAsync(input.AnalysisId) == null) + return NotFound(); + + try + { + var issues = await _analysis.ValidateAsync(DepartmentId, input.AnalysisId, cancellationToken); + var result = new IncidentValidationResult + { + Data = issues.Select(IncidentReportsApiMapper.ToIssue).ToList(), + HasBlockingIssues = issues.Any(i => i.Severity == (int)RmsValidationSeverity.Error), + Status = ResponseHelper.Success + }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + #endregion + + #region Lifecycle + + /// + /// Writes the immutable revision and queues the filing. Finalizing before the incident is at the + /// destination is allowed: the submission waits for the incident's NERIS id rather than failing. + /// + [HttpPost("Finalize")] + [Consumes(MediaTypeNames.Application.Json)] + [Authorize(Policy = ResgridResources.Record_Finalize)] + public Task> Finalize(IncidentAnalysisCommandInput input, CancellationToken cancellationToken) + { + return CommandAsync(input, true, rowVersion => _analysis.FinalizeAsync(DepartmentId, UserId, input.AnalysisId, rowVersion, cancellationToken)); + } + + /// Queues (or re-queues) the current revision; used when the incident was filed after the analysis was finalized. + [HttpPost("Submit")] + [Consumes(MediaTypeNames.Application.Json)] + [Authorize(Policy = ResgridResources.Record_Submit)] + public Task> Submit(IncidentAnalysisCommandInput input, CancellationToken cancellationToken) + { + return CommandAsync(input, false, _ => _analysis.QueueSubmissionAsync(DepartmentId, UserId, input.AnalysisId, cancellationToken)); + } + + [HttpPost("Void")] + [Consumes(MediaTypeNames.Application.Json)] + [Authorize(Policy = ResgridResources.Record_Void)] + public Task> Void(IncidentAnalysisCommandInput input, CancellationToken cancellationToken) + { + return CommandAsync(input, false, _ => _analysis.VoidAsync(DepartmentId, UserId, input.AnalysisId, input.ReasonCode, input.ReasonText, cancellationToken)); + } + + private async Task> CommandAsync(IncidentAnalysisCommandInput input, bool requiresRowVersion, Func> action, + [CallerMemberName] string command = null) + { + if (input == null || string.IsNullOrWhiteSpace(input.AnalysisId)) + return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) + return usable; + + var origin = RecordsApiHelper.ResolveOrigin(input.OriginClient); + var gate = await FieldClientGateAsync(origin); + if (gate != null) + return gate; + + var current = await LoadAuthorizedAsync(input.AnalysisId); + if (current == null) + return NotFound(); + + var key = RecordsApiHelper.ResolveIdempotencyKey(input.IdempotencyKey, Request); + if (key != null) + { + var replayed = await _idempotency.TryGetRecordIdAsync(DepartmentId, UserId, key, command); + if (string.Equals(replayed, input.AnalysisId, StringComparison.Ordinal)) + return Ok(Wrap(await _analysis.GetAsync(DepartmentId, input.AnalysisId, true))); + } + + long rowVersion = current.Analysis.RowVersion; + if (requiresRowVersion) + { + var supplied = RecordsApiHelper.ResolveRowVersion(input.RowVersion, Request); + if (!supplied.HasValue) + return Problem(statusCode: StatusCodes.Status428PreconditionRequired, title: "RowVersion or If-Match is required for this command.", type: "precondition_required"); + rowVersion = supplied.Value; + } + + try + { + var aggregate = await action(rowVersion); + if (key != null) + await _idempotency.RememberAsync(DepartmentId, UserId, key, command, input.AnalysisId); + return Ok(Wrap(aggregate, ResponseHelper.Updated)); + } + catch (RecordConcurrencyException ex) + { + return await ConflictAsync(input.AnalysisId, ex.ExpectedRowVersion); + } + catch (IncidentReportValidationException ex) + { + var result = new IncidentValidationResult { Data = ex.Issues.Select(IncidentReportsApiMapper.ToIssue).ToList(), HasBlockingIssues = true, Status = ResponseHelper.Failure }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return StatusCode(StatusCodes.Status422UnprocessableEntity, result); + } + catch (RecordTransitionException ex) + { + return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message, type: "record_transition"); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + #endregion + + #region Helpers + + private async Task FlagOnAsync() + { + return (await _cutoverService.GetModuleStateAsync(DepartmentId)).FlagEnabled; + } + + private async Task UsableAsync() + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return NotFound(); + if (!moduleState.RecordsUsable) + return Problem(statusCode: StatusCodes.Status409Conflict, title: "Records is not activated for this department.", type: "records_not_activated"); + return null; + } + + private async Task FieldClientGateAsync(RmsOriginClient origin) + { + var flag = RecordsApiHelper.FieldFlagFor(origin); + if (flag == null || await _featureToggleService.IsEnabledAsync(flag, DepartmentId)) + return null; + return Problem(statusCode: StatusCodes.Status403Forbidden, title: $"Field Records are not enabled for the {origin} app in this department.", type: "field_records_disabled"); + } + + /// An analysis is only as visible as the incident it belongs to. + private async Task CanViewReportAsync(string reportId) + { + if (string.IsNullOrWhiteSpace(reportId)) + return false; + + var grant = SystemGrant; + if (grant != null) + return await _recordsAuthorizationService.CanSystemPrincipalViewRecordAsync(grant, reportId); + + return await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, reportId, DepartmentId); + } + + private async Task LoadAuthorizedAsync(string analysisId, bool includeHistory = false) + { + if (string.IsNullOrWhiteSpace(analysisId)) + return null; + + var aggregate = await _analysis.GetAsync(DepartmentId, analysisId, includeHistory); + if (aggregate?.Analysis == null) + return null; + + if (!await CanViewReportAsync(aggregate.Analysis.IncidentReportId)) + { + await RecordDeniedAsync(aggregate.Analysis.IncidentReportId); + return null; + } + + return aggregate; + } + + private bool CanEdit(IncidentAnalysisAggregate aggregate) + { + if (!ClaimsAuthorizationHelper.CanCreateRecord()) + return false; + + var analysis = aggregate.Analysis; + return ClaimsAuthorizationHelper.IsUserDepartmentAdmin() + || string.Equals(analysis.OwnerUserId, UserId, StringComparison.OrdinalIgnoreCase) + || string.Equals(analysis.AuthorUserId, UserId, StringComparison.OrdinalIgnoreCase); + } + + private Task RecordReadAsync(string reportId) + { + return _incidentReports.RecordAccessAsync(DepartmentId, UserId, reportId, null, RmsAccessAuditAction.Read, AccessPurpose(), IpAddressHelper.GetRequestIP(Request, true)); + } + + private Task RecordDeniedAsync(string reportId) + { + return _incidentReports.RecordAccessAsync(DepartmentId, UserId, reportId, null, RmsAccessAuditAction.Denied, AccessPurpose(), IpAddressHelper.GetRequestIP(Request, true)); + } + + private string AccessPurpose(string purpose = null) + { + var grant = SystemGrant; + if (grant == null) + return purpose; + + return string.IsNullOrWhiteSpace(purpose) ? grant.Purpose : $"{grant.Purpose}: {purpose}"; + } + + private async Task> ConflictAsync(string analysisId, long expectedRowVersion) + { + var current = await _analysis.GetAsync(DepartmentId, analysisId, true); + if (current?.Analysis == null) + return NotFound(); + + var result = Wrap(current, RecordsController.ConflictStatus); + result.Data.ETag = RecordsApiContract.ToETag(current.Analysis.RowVersion); + // The expected row version is what the caller sent; the payload already carries the current one. + Response.Headers[RecordsApiContract.ETagHeader] = RecordsApiContract.ToETag(current.Analysis.RowVersion); + return Conflict(result); + } + + private IncidentAnalysisResult Wrap(IncidentAnalysisAggregate aggregate, string status = ResponseHelper.Success) + { + var result = new IncidentAnalysisResult + { + Data = IncidentAnalysisApiMapper.ToAnalysis(aggregate, ClaimsAuthorizationHelper.CanViewRestrictedRecords()), + PageSize = 1, + Status = status + }; + ResponseHelper.PopulateV4ResponseData(result); + RecordsApiHelper.SetETag(Response, aggregate.Analysis.RowVersion); + return result; + } + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs index 5667b498..dfb799ad 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs @@ -1,12 +1,14 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Mime; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using Resgrid.Model; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; @@ -28,7 +30,7 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] - public class IncidentReportsController : V4AuthenticatedApiControllerbase + public class IncidentReportsController : V4AuthenticatedApiControllerbase, IActionFilter { private readonly IIncidentReportsService _incidentReports; private readonly IRecordsCutoverService _cutoverService; @@ -36,9 +38,13 @@ public class IncidentReportsController : V4AuthenticatedApiControllerbase private readonly INerisProfileService _neris; private readonly IFeatureToggleService _featureToggleService; private readonly IRecordsApiIdempotencyService _idempotency; + private readonly IIncidentAnalysisService _analysis; + + private SystemPrincipalRecordGrant _systemGrant; + private bool _systemGrantResolved; public IncidentReportsController(IIncidentReportsService incidentReports, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, - INerisProfileService neris, IFeatureToggleService featureToggleService, IRecordsApiIdempotencyService idempotency) + INerisProfileService neris, IFeatureToggleService featureToggleService, IRecordsApiIdempotencyService idempotency, IIncidentAnalysisService analysis) { _incidentReports = incidentReports; _cutoverService = cutoverService; @@ -46,6 +52,71 @@ public IncidentReportsController(IIncidentReportsService incidentReports, IRecor _neris = neris; _featureToggleService = featureToggleService; _idempotency = idempotency; + _analysis = analysis; + } + + /// + /// Same system-principal gate as : a service account with no configured + /// Record grant for this department is refused before the action runs, and a granted one is confined to + /// reads because no mutating Record policy is ever issued to it (registry section 4.4). + /// + public void OnActionExecuting(ActionExecutingContext context) + { + if (!RecordsSystemPrincipal.IsSystemPrincipal(User)) + return; + + if (SystemGrant == null) + context.Result = Problem(statusCode: StatusCodes.Status403Forbidden, + title: "This system principal has no configured Record grant for this department.", type: "record_grant_missing"); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + + /// The configured grant this request runs under, or null for a user principal or an ungranted system one. + private SystemPrincipalRecordGrant SystemGrant + { + get + { + if (!_systemGrantResolved) + { + _systemGrant = RecordsSystemPrincipal.ResolveGrant(User, DepartmentId); + _systemGrantResolved = true; + } + + return _systemGrant; + } + } + + /// Group filter for the caller: the grant's groups for a system principal, the member's own otherwise. + private async Task> VisibleGroupIdsAsync() + { + var grant = SystemGrant; + if (grant != null) + return grant.VisibleGroupIds(); + + return await _recordsAuthorizationService.GetVisibleGroupIdsAsync(UserId, DepartmentId); + } + + /// Per-record visibility for the caller, routed to the grant rule for a system principal. + private async Task CanViewRecordAsync(string recordId) + { + var grant = SystemGrant; + if (grant != null) + return await _recordsAuthorizationService.CanSystemPrincipalViewRecordAsync(grant, recordId); + + return await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, recordId, DepartmentId); + } + + /// Audit purpose: the grant's stated purpose for a system principal, so a machine read is never anonymous. + private string AccessPurpose(string purpose = null) + { + var grant = SystemGrant; + if (grant == null) + return purpose; + + return string.IsNullOrWhiteSpace(purpose) ? grant.Purpose : $"{grant.Purpose}: {purpose}"; } #region Reads @@ -62,6 +133,10 @@ public async Task> GetIncidentReports(int? y var result = new IncidentReportsResult(); if (moduleState.RecordsUsable) { + // Visibility belongs in the query, not in a filter over the page. Filtering afterwards made the total + // the size of whatever survived this page, so a member with scoped visibility was told there was one + // page and could never reach older reports. + var visible = await VisibleGroupIdsAsync(); var query = new RmsIncidentReportQuery { Year = year, @@ -69,16 +144,15 @@ public async Task> GetIncidentReports(int? y CallId = callId, OwnerUserId = string.IsNullOrWhiteSpace(owner) ? null : owner, StationGroupId = group, + VisibleGroupIds = visible, + ViewerUserId = SystemGrant == null ? UserId : null, Skip = Math.Max(0, skip), Take = Math.Max(1, Math.Min(RecordsController.MaxPageSize, take)) }; - var visible = await _recordsAuthorizationService.GetVisibleGroupIdsAsync(UserId, DepartmentId); foreach (var report in await _incidentReports.QueryAsync(DepartmentId, query)) - { - if (visible == null || await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, report.RmsIncidentReportId, DepartmentId)) - result.Data.Add(IncidentReportsApiMapper.ToSummary(report)); - } - result.Total = visible == null ? await _incidentReports.CountAsync(DepartmentId, query) : query.Skip + result.Data.Count; + result.Data.Add(IncidentReportsApiMapper.ToSummary(report)); + + result.Total = await _incidentReports.CountAsync(DepartmentId, query); result.Page = query.Skip / query.Take; } @@ -101,7 +175,7 @@ public async Task> GetIncidentReport(string i if (aggregate == null) return NotFound(); - await _incidentReports.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Read, null, IpAddressHelper.GetRequestIP(Request, true)); + await _incidentReports.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Read, AccessPurpose(), IpAddressHelper.GetRequestIP(Request, true)); return Ok(await WrapAsync(aggregate)); } @@ -191,7 +265,7 @@ public async Task> SaveDraft(SaveIncidentRepo try { - var saved = await _incidentReports.SaveDraftAsync(DepartmentId, UserId, input.ReportId, rowVersion.Value, IncidentReportsApiMapper.ToDraftInput(input, origin), cancellationToken); + var saved = await _incidentReports.SaveDraftAsync(DepartmentId, UserId, input.ReportId, rowVersion.Value, IncidentReportsApiMapper.ToDraftInput(input, origin), ClaimsAuthorizationHelper.CanViewRestrictedRecords(), cancellationToken); return Ok(await WrapAsync(saved, ResponseHelper.Updated)); } catch (RecordConcurrencyException ex) @@ -320,7 +394,13 @@ public Task> Cancel(IncidentReportCommandInpu return CommandAsync(input, false, _ => _incidentReports.CancelAsync(DepartmentId, UserId, input.ReportId, cancellationToken)); } - private async Task> CommandAsync(IncidentReportCommandInput input, bool requiresRowVersion, Func> action) + /// + /// scopes the idempotency key to the calling action. Without it a client that + /// reused one key for SubmitForReview and then Finalize matched on the report alone and was told the second + /// command succeeded without it ever running. + /// + private async Task> CommandAsync(IncidentReportCommandInput input, bool requiresRowVersion, Func> action, + [CallerMemberName] string command = null) { if (input == null || string.IsNullOrWhiteSpace(input.ReportId)) return BadRequest(); @@ -340,7 +420,7 @@ private async Task> CommandAsync(IncidentRepo var key = RecordsApiHelper.ResolveIdempotencyKey(input.IdempotencyKey, Request); if (key != null) { - var replayed = await _idempotency.TryGetRecordIdAsync(DepartmentId, UserId, key); + var replayed = await _idempotency.TryGetRecordIdAsync(DepartmentId, UserId, key, command); if (string.Equals(replayed, input.ReportId, StringComparison.Ordinal)) return Ok(await WrapAsync(await _incidentReports.GetAsync(DepartmentId, input.ReportId, true), ResponseHelper.Success)); } @@ -358,7 +438,7 @@ private async Task> CommandAsync(IncidentRepo { var aggregate = await action(rowVersion); if (key != null) - await _idempotency.RememberAsync(DepartmentId, UserId, key, input.ReportId); + await _idempotency.RememberAsync(DepartmentId, UserId, key, command, input.ReportId); return Ok(await WrapAsync(aggregate, ResponseHelper.Updated)); } catch (RecordConcurrencyException ex) @@ -414,9 +494,9 @@ private async Task LoadAuthorizedAsync(string id, bool if (string.IsNullOrWhiteSpace(id)) return null; - if (!await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, id, DepartmentId)) + if (!await CanViewRecordAsync(id)) { - await _incidentReports.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Denied, null, IpAddressHelper.GetRequestIP(Request, true)); + await _incidentReports.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Denied, AccessPurpose(), IpAddressHelper.GetRequestIP(Request, true)); return null; } @@ -461,7 +541,15 @@ private async Task> ConflictAsync(string repo private async Task WrapAsync(IncidentReportAggregate aggregate, string status = ResponseHelper.Success) { - var result = new IncidentReportResult { Data = IncidentReportsApiMapper.ToReport(aggregate, await _neris.IsSubmissionEnabledAsync(DepartmentId)), PageSize = 1, Status = status }; + // The progressive section rules and the analysis link come from the server, so a client renders exactly + // what validation will enforce and never keeps its own copy of either. + var sections = await _incidentReports.GetSectionRequirementsAsync(DepartmentId, aggregate.Report.RmsIncidentReportId); + var analysis = await _analysis.GetForReportAsync(DepartmentId, aggregate.Report.RmsIncidentReportId); + + var data = IncidentReportsApiMapper.ToReport(aggregate, await _neris.IsSubmissionEnabledAsync(DepartmentId), + ClaimsAuthorizationHelper.CanViewRestrictedRecords(), sections, analysis?.Analysis?.RmsIncidentAnalysisId); + + var result = new IncidentReportResult { Data = data, PageSize = 1, Status = status }; ResponseHelper.PopulateV4ResponseData(result); RecordsApiHelper.SetETag(Response, aggregate.Report.RowVersion); return result; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs new file mode 100644 index 00000000..81f0e76e --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Evidence capture over the v4 Records contract (RMS plan section 4.5, RMS-3): readiness, run-card activation, + /// tracking fixes, promoted chat, inventory usage and certification validity. + /// + /// A capture is a write to an official record, so it needs Record_Create and a stated reason; reading one only + /// needs Record_View plus visibility of the Record it supports. Artifacts are immutable: there is no update or + /// delete endpoint, and a correction is a fresh capture that supersedes the old one. + /// + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class RecordEvidenceController : V4AuthenticatedApiControllerbase, IActionFilter + { + private readonly IRecordsEvidenceService _evidence; + private readonly IRecordsCutoverService _cutoverService; + private readonly IRecordsAuthorizationService _recordsAuthorizationService; + private readonly IFeatureToggleService _featureToggleService; + private readonly IRecordsApiIdempotencyService _idempotency; + + private SystemPrincipalRecordGrant _systemGrant; + private bool _systemGrantResolved; + + public RecordEvidenceController(IRecordsEvidenceService evidence, IRecordsCutoverService cutoverService, + IRecordsAuthorizationService recordsAuthorizationService, IFeatureToggleService featureToggleService, IRecordsApiIdempotencyService idempotency) + { + _evidence = evidence; + _cutoverService = cutoverService; + _recordsAuthorizationService = recordsAuthorizationService; + _featureToggleService = featureToggleService; + _idempotency = idempotency; + } + + /// Same system-principal gate as the other Records controllers (registry section 4.4). + public void OnActionExecuting(ActionExecutingContext context) + { + if (!RecordsSystemPrincipal.IsSystemPrincipal(User)) + return; + + if (SystemGrant == null) + context.Result = Problem(statusCode: StatusCodes.Status403Forbidden, + title: "This system principal has no configured Record grant for this department.", type: "record_grant_missing"); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + + private SystemPrincipalRecordGrant SystemGrant + { + get + { + if (!_systemGrantResolved) + { + _systemGrant = RecordsSystemPrincipal.ResolveGrant(User, DepartmentId); + _systemGrantResolved = true; + } + + return _systemGrant; + } + } + + /// + /// Which of the six sources can produce evidence for this department right now. A source that is not built + /// or not configured answers "unavailable, and here is why" — which is a different answer from "there was + /// no evidence" and is why the list always carries all six. + /// + [HttpGet("GetSources")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> GetSources() + { + if (!await FlagOnAsync()) + return NotFound(); + + var states = await _evidence.GetSourceStatesAsync(DepartmentId); + var result = new RecordEvidenceSourcesResult { Data = states.Select(RecordEvidenceApiMapper.ToSource).ToList(), Status = ResponseHelper.Success }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// Artifacts on the working draft, or on a revision when one is named. Manifests are not carried in a list. + [HttpGet("GetEvidence")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> GetEvidence(string recordId, string revisionId, bool includeSuperseded = false) + { + if (!await FlagOnAsync()) + return NotFound(); + if (string.IsNullOrWhiteSpace(recordId) || !await CanViewRecordAsync(recordId)) + return NotFound(); + + var artifacts = await _evidence.GetForRecordAsync(DepartmentId, recordId, revisionId, includeSuperseded); + var canViewRestricted = ClaimsAuthorizationHelper.CanViewRestrictedRecords(); + var result = new RecordEvidenceListResult + { + Data = artifacts.Select(a => RecordEvidenceApiMapper.ToArtifact(a, canViewRestricted)).ToList(), + Status = ResponseHelper.Success + }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// One artifact with its manifest; the manifest is withheld and flagged when it is classified above the caller. + [HttpGet("GetArtifact")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> GetArtifact(string id) + { + var artifact = await LoadAuthorizedAsync(id); + if (artifact == null) + return NotFound(); + + var result = new RecordEvidenceResult + { + Data = RecordEvidenceApiMapper.ToArtifact(artifact, ClaimsAuthorizationHelper.CanViewRestrictedRecords(), true), + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// Re-computes the checksum from the stored manifest; false means the artifact was tampered with. + [HttpGet("Verify")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Verify(string id) + { + var artifact = await LoadAuthorizedAsync(id); + if (artifact == null) + return NotFound(); + + var intact = await _evidence.VerifyAsync(DepartmentId, id); + var result = new RecordEvidenceVerifyResult + { + Data = new RecordEvidenceVerifyData { ArtifactId = id, Intact = intact, Checksum = artifact.Checksum }, + PageSize = 1, + Status = intact ? ResponseHelper.Success : ResponseHelper.Failure + }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Captures one artifact. 409 when the source has nothing to give for this department — that is a real + /// answer about the source, not a client error, and the reason is carried in the problem detail. + /// + [HttpPost("Capture")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Capture(CaptureRecordEvidenceInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.RecordId) || string.IsNullOrWhiteSpace(input.CaptureReason)) + return BadRequest(); + var usable = await UsableAsync(); + if (usable != null) + return usable; + + var origin = RecordsApiHelper.ResolveOrigin(input.OriginClient); + var gate = await FieldClientGateAsync(origin); + if (gate != null) + return gate; + + if (!await CanViewRecordAsync(input.RecordId)) + return NotFound(); + if (!ClaimsAuthorizationHelper.CanCreateRecord()) + return Forbid(); + + var key = RecordsApiHelper.ResolveIdempotencyKey(input.IdempotencyKey, Request); + if (key != null) + { + var replayed = await _idempotency.TryGetRecordIdAsync(DepartmentId, UserId, key, nameof(Capture)); + if (!string.IsNullOrWhiteSpace(replayed)) + { + var existing = await _evidence.GetAsync(DepartmentId, replayed); + if (existing != null) + return Ok(Wrap(existing, ResponseHelper.Success)); + } + } + + try + { + var request = RecordEvidenceApiMapper.ToCaptureRequest(input, DepartmentId, UserId, origin); + var artifact = await _evidence.CaptureAsync(request, ClaimsAuthorizationHelper.CanViewRestrictedRecords(), cancellationToken); + if (artifact == null) + return Problem(statusCode: StatusCodes.Status409Conflict, title: "That evidence source has nothing to capture for this record.", type: "evidence_unavailable"); + + if (key != null) + await _idempotency.RememberAsync(DepartmentId, UserId, key, nameof(Capture), artifact.RmsEvidenceArtifactId); + + return StatusCode(StatusCodes.Status201Created, Wrap(artifact, ResponseHelper.Created)); + } + catch (RecordTransitionException ex) + { + return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message, type: "record_transition"); + } + catch (UnauthorizedAccessException ex) + { + return Problem(statusCode: StatusCodes.Status403Forbidden, title: ex.Message, type: "record_restricted"); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, type: "record_validation"); + } + } + + #region Helpers + + private async Task FlagOnAsync() + { + return (await _cutoverService.GetModuleStateAsync(DepartmentId)).FlagEnabled; + } + + private async Task UsableAsync() + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return NotFound(); + if (!moduleState.RecordsUsable) + return Problem(statusCode: StatusCodes.Status409Conflict, title: "Records is not activated for this department.", type: "records_not_activated"); + return null; + } + + private async Task FieldClientGateAsync(RmsOriginClient origin) + { + var flag = RecordsApiHelper.FieldFlagFor(origin); + if (flag == null || await _featureToggleService.IsEnabledAsync(flag, DepartmentId)) + return null; + return Problem(statusCode: StatusCodes.Status403Forbidden, title: $"Field Records are not enabled for the {origin} app in this department.", type: "field_records_disabled"); + } + + private async Task CanViewRecordAsync(string recordId) + { + var grant = SystemGrant; + if (grant != null) + return await _recordsAuthorizationService.CanSystemPrincipalViewRecordAsync(grant, recordId); + + return await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, recordId, DepartmentId); + } + + /// An artifact is only as visible as the Record it supports. + private async Task LoadAuthorizedAsync(string artifactId) + { + if (string.IsNullOrWhiteSpace(artifactId) || !await FlagOnAsync()) + return null; + + var artifact = await _evidence.GetAsync(DepartmentId, artifactId); + if (artifact == null || !await CanViewRecordAsync(artifact.RecordId)) + return null; + + return artifact; + } + + private RecordEvidenceResult Wrap(RmsEvidenceArtifact artifact, string status) + { + var result = new RecordEvidenceResult + { + Data = RecordEvidenceApiMapper.ToArtifact(artifact, ClaimsAuthorizationHelper.CanViewRestrictedRecords(), true), + PageSize = 1, + Status = status + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs index 9cb06521..5508a07d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs @@ -1,12 +1,14 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Mime; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Repositories; @@ -29,7 +31,7 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] - public class RecordsController : V4AuthenticatedApiControllerbase + public class RecordsController : V4AuthenticatedApiControllerbase, IActionFilter { public const string ConflictStatus = "conflict"; public const int MaxPageSize = 200; @@ -43,10 +45,14 @@ public class RecordsController : V4AuthenticatedApiControllerbase private readonly IRecordsSearchService _recordsSearch; private readonly IRecordAttachmentUploadService _uploads; private readonly IRecordsApiIdempotencyService _idempotency; + private readonly IRecordsDashboardService _dashboard; + + private SystemPrincipalRecordGrant _systemGrant; + private bool _systemGrantResolved; public RecordsController(IRecordsService recordsService, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, IFeatureToggleService featureToggleService, IDepartmentSettingsService departmentSettingsService, IDepartmentDataProtectionService dataProtectionService, - IRecordsSearchService recordsSearch, IRecordAttachmentUploadService uploads, IRecordsApiIdempotencyService idempotency) + IRecordsSearchService recordsSearch, IRecordAttachmentUploadService uploads, IRecordsApiIdempotencyService idempotency, IRecordsDashboardService dashboard) { _recordsService = recordsService; _cutoverService = cutoverService; @@ -57,8 +63,71 @@ public RecordsController(IRecordsService recordsService, IRecordsCutoverService _recordsSearch = recordsSearch; _uploads = uploads; _idempotency = idempotency; + _dashboard = dashboard; + } + + /// + /// Every action on this controller refuses a system principal that has no configured Record grant for + /// the department it resolved to, before the action runs (Identifier Allocation Registry section 4.4). + /// A user principal is untouched. Mutating actions need no further guard: their policies are never + /// issued to a system principal, so the claim check has already refused them. + /// + public void OnActionExecuting(ActionExecutingContext context) + { + var gate = SystemPrincipalGate(); + if (gate != null) + context.Result = gate; + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + + #region Dashboard + + /// + /// The Records work queues an officer opens the module to look at (RMS-3): incomplete, awaiting review, + /// rejected, accepted, overdue, plus the disclosure clock. Group-scope aware, so a member is never told that + /// records exist which their own queue will not show them, and a count that cannot be produced degrades into + /// a warning rather than failing the whole dashboard. + /// + [HttpGet("Dashboard")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Dashboard(CancellationToken cancellationToken) + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return NotFound(); + + var dashboard = await _dashboard.GetAsync(DepartmentId, UserId, cancellationToken); + var result = new RecordsDashboardResult { Data = RecordsDashboardApiMapper.ToDashboard(dashboard), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); } + /// + /// NERIS crosswalk coverage: which of the department's own call types map to a NERIS incident type, which do + /// not, and which map to a code the pinned contract no longer carries. A gap report, not a statistic — every + /// unmapped type is a filing somebody classifies by hand on the night. + /// + [HttpGet("CrosswalkCoverage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> CrosswalkCoverage(CancellationToken cancellationToken) + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return NotFound(); + + var coverage = await _dashboard.GetCrosswalkCoverageAsync(DepartmentId, cancellationToken); + var result = new NerisCrosswalkCoverageResult { Data = RecordsDashboardApiMapper.ToCoverage(coverage), PageSize = coverage.Items.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + #endregion + #region Capabilities /// @@ -195,7 +264,7 @@ public async Task> GetRecords(int? year, string defi CallId = callId, OwnerUserId = string.IsNullOrWhiteSpace(owner) ? null : owner, StationGroupId = group, - VisibleGroupIds = await _recordsAuthorizationService.GetVisibleGroupIdsAsync(UserId, DepartmentId), + VisibleGroupIds = await VisibleGroupIdsAsync(), ViewerUserId = UserId, Skip = Math.Max(0, skip), Take = Math.Max(1, Math.Min(MaxPageSize, take)) @@ -233,7 +302,7 @@ public async Task> Search(string text, int? year, st return Ok(result); } - var visible = await _recordsAuthorizationService.GetVisibleGroupIdsAsync(UserId, DepartmentId); + var visible = await VisibleGroupIdsAsync(); var states = state.HasValue ? new List { state.Value } : null; take = Math.Max(1, Math.Min(MaxPageSize, take)); skip = Math.Max(0, skip); @@ -270,7 +339,7 @@ public async Task> Search(string text, int? year, st var dropped = 0; foreach (var id in ids) { - if (!loaded.TryGetValue(id, out var projection) || !await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, id, DepartmentId)) + if (!loaded.TryGetValue(id, out var projection) || !await CanViewRecordAsync(id)) { dropped++; continue; @@ -297,7 +366,7 @@ public async Task> Search(string text, int? year, st [HttpGet("Changes")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Record_View)] - public async Task> Changes(long since = 0, int take = 200) + public async Task> Changes(long since = 0, int take = 200, string sinceId = null) { var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); if (!moduleState.FlagEnabled) @@ -309,20 +378,32 @@ public async Task> Changes(long since = 0, in if (moduleState.RecordsUsable) { - var visible = await _recordsAuthorizationService.GetVisibleGroupIdsAsync(UserId, DepartmentId); - var rows = await _recordsService.GetChangesSinceAsync(DepartmentId, RecordsApiHelper.FromUnixMs(since), take + 1); + var visible = await VisibleGroupIdsAsync(); + var rows = await _recordsService.GetChangesSinceAsync(DepartmentId, RecordsApiHelper.FromUnixMs(since), take + 1, sinceId); var hasMore = rows.Count > take; var page = rows.Take(take).ToList(); foreach (var projection in page) { // Tombstones ride through regardless of scope (the client may hold the row); live rows pass the visibility rule. var summary = RecordsApiMapper.ToSummary(projection); - if (!summary.IsTombstone && visible != null && !await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, projection.RmsRecordSearchProjectionId, DepartmentId)) + if (!summary.IsTombstone && visible != null && !await CanViewRecordAsync(projection.RmsRecordSearchProjectionId)) continue; result.Data.Records.Add(summary); } result.Data.HasMore = hasMore; - result.Data.ServerTimestampMs = hasMore && page.Count > 0 ? RecordsApiHelper.ToUnixMs(page[page.Count - 1].ModifiedOn) : RecordsApiHelper.ToUnixMs(now); + + // Mid-stream the cursor is the last row delivered, timestamp and id together; at the end of the stream + // it is the server clock with no tie-breaker, which is the full-catch-up case. + if (hasMore && page.Count > 0) + { + var last = page[page.Count - 1]; + result.Data.ServerTimestampMs = RecordsApiHelper.ToUnixMs(last.ModifiedOn); + result.Data.ServerCursorId = last.RmsRecordSearchProjectionId; + } + else + { + result.Data.ServerTimestampMs = RecordsApiHelper.ToUnixMs(now); + } } else { @@ -353,7 +434,7 @@ public async Task> GetRecord(string id) if (aggregate == null) return NotFound(); - await _recordsService.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Read, null, IpAddressHelper.GetRequestIP(Request, true), RmsOriginClient.Api); + await _recordsService.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Read, AccessPurpose(), IpAddressHelper.GetRequestIP(Request, true), RmsOriginClient.Api); return Ok(Wrap(aggregate)); } @@ -394,7 +475,7 @@ public async Task> GetRevision(string if (snapshot == null) return NotFound(); - await _recordsService.RecordAccessAsync(DepartmentId, UserId, id, revisionId, RmsAccessAuditAction.Read, "Revision " + revision.RevisionNumber, IpAddressHelper.GetRequestIP(Request, true), RmsOriginClient.Api); + await _recordsService.RecordAccessAsync(DepartmentId, UserId, id, revisionId, RmsAccessAuditAction.Read, AccessPurpose("Revision " + revision.RevisionNumber), IpAddressHelper.GetRequestIP(Request, true), RmsOriginClient.Api); var result = new RecordRevisionSnapshotResult { Data = new RecordRevisionSnapshotData { Revision = RecordsApiMapper.ToRevision(revision), Snapshot = RecordsApiMapper.ToRecord(snapshot, ClaimsAuthorizationHelper.CanViewRestrictedRecords()) }, @@ -613,7 +694,8 @@ public Task> Reassign(RecordCommandInput input, Cance /// Shared command path: flag/usable gate, field-client gate, per-record visibility, scoped idempotency replay, /// ETag check where the transition takes one, and the 409/400 mapping. Never last-writer-wins. /// - private async Task> CommandAsync(RecordCommandInput input, bool requiresRowVersion, Func> action) + private async Task> CommandAsync(RecordCommandInput input, bool requiresRowVersion, Func> action, + [CallerMemberName] string command = null) { if (input == null || string.IsNullOrWhiteSpace(input.RecordId)) return BadRequest(); @@ -633,7 +715,7 @@ private async Task> CommandAsync(RecordCommandInput i var key = RecordsApiHelper.ResolveIdempotencyKey(input.IdempotencyKey, Request); if (key != null) { - var replayed = await _idempotency.TryGetRecordIdAsync(DepartmentId, UserId, key); + var replayed = await _idempotency.TryGetRecordIdAsync(DepartmentId, UserId, key, command); if (string.Equals(replayed, input.RecordId, StringComparison.Ordinal)) return Ok(Wrap(await _recordsService.GetAsync(DepartmentId, input.RecordId, true), ResponseHelper.Success)); } @@ -651,7 +733,7 @@ private async Task> CommandAsync(RecordCommandInput i { var aggregate = await action(rowVersion); if (key != null) - await _idempotency.RememberAsync(DepartmentId, UserId, key, input.RecordId); + await _idempotency.RememberAsync(DepartmentId, UserId, key, command, input.RecordId); return Ok(Wrap(aggregate, ResponseHelper.Updated)); } catch (RecordConcurrencyException ex) @@ -705,7 +787,7 @@ public async Task> GetAttachment(str if (attachment == null || !string.Equals(attachment.RecordId, id, StringComparison.Ordinal) || attachment.Data == null || attachment.DeletedOn.HasValue) return NotFound(); - await _recordsService.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Read, "Attachment " + attachmentId, IpAddressHelper.GetRequestIP(Request, true), RmsOriginClient.Api); + await _recordsService.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Read, AccessPurpose("Attachment " + attachmentId), IpAddressHelper.GetRequestIP(Request, true), RmsOriginClient.Api); var meta = RecordsApiMapper.ToAttachment(attachment); var result = new RecordAttachmentContentResult { @@ -903,6 +985,71 @@ private async Task FlagOnAsync() return (await _cutoverService.GetModuleStateAsync(DepartmentId)).FlagEnabled; } + #region System principals + + /// True when the caller is the relay key or a client_credentials service account, not a member. + private bool IsSystemPrincipal => RecordsSystemPrincipal.IsSystemPrincipal(User); + + /// The configured grant this request runs under, or null for a user principal or an ungranted system one. + private SystemPrincipalRecordGrant SystemGrant + { + get + { + if (!_systemGrantResolved) + { + _systemGrant = RecordsSystemPrincipal.ResolveGrant(User, DepartmentId); + _systemGrantResolved = true; + } + + return _systemGrant; + } + } + + /// + /// A system principal with no grant for this department is refused before any read runs. Mutating + /// endpoints need no equivalent: their policies are never issued to a system principal at all. + /// + private ActionResult SystemPrincipalGate() + { + if (!IsSystemPrincipal || SystemGrant != null) + return null; + + return Problem(statusCode: StatusCodes.Status403Forbidden, + title: "This system principal has no configured Record grant for this department.", type: "record_grant_missing"); + } + + /// Group filter for the caller: the grant's groups for a system principal, the member's own otherwise. + private async Task> VisibleGroupIdsAsync() + { + var grant = SystemGrant; + if (grant != null) + return grant.VisibleGroupIds(); + + return await _recordsAuthorizationService.GetVisibleGroupIdsAsync(UserId, DepartmentId); + } + + /// Per-record visibility for the caller, routed to the grant rule for a system principal. + private async Task CanViewRecordAsync(string recordId) + { + var grant = SystemGrant; + if (grant != null) + return await _recordsAuthorizationService.CanSystemPrincipalViewRecordAsync(grant, recordId); + + return await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, recordId, DepartmentId); + } + + /// Audit purpose: the grant's stated purpose for a system principal, so a machine read is never anonymous. + private string AccessPurpose(string purpose = null) + { + var grant = SystemGrant; + if (grant == null) + return purpose; + + return string.IsNullOrWhiteSpace(purpose) ? grant.Purpose : $"{grant.Purpose}: {purpose}"; + } + + #endregion + /// Writes need the module usable: flag on and the department activated. private async Task UsableAsync() { @@ -931,9 +1078,9 @@ private async Task LoadAuthorizedAsync(string id, bool includeR if (string.IsNullOrWhiteSpace(id)) return null; - if (!await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, id, DepartmentId)) + if (!await CanViewRecordAsync(id)) { - await _recordsService.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Denied, null, IpAddressHelper.GetRequestIP(Request, true), RmsOriginClient.Api); + await _recordsService.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Denied, AccessPurpose(), IpAddressHelper.GetRequestIP(Request, true), RmsOriginClient.Api); return null; } diff --git a/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs b/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs index c9c2ec03..e0ae5183 100644 --- a/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Http; @@ -330,13 +330,18 @@ public static IncidentReportSummaryData ToSummary(RmsIncidentReport r) }; } - public static IncidentReportData ToReport(IncidentReportAggregate a, bool submissionEnabled) + public static IncidentReportData ToReport(IncidentReportAggregate a, bool submissionEnabled, bool canViewRestricted = true, + IEnumerable sections = null, string incidentAnalysisId = null) { + var withheld = new List(); var r = a.Report; var state = (RmsRecordState)r.State; var preset = (RmsLifecyclePreset)r.LifecyclePreset; var canQueue = submissionEnabled && r.AmendsRevisionId == null && !string.IsNullOrWhiteSpace(r.CurrentRevisionId) - && (state == RmsRecordState.Finalized || state == RmsRecordState.Amended || state == RmsRecordState.Corrected || (state == RmsRecordState.Rejected && a.Submissions.Any(s => s.State == (int)RmsSubmissionState.Failed))); + && (state == RmsRecordState.Finalized || state == RmsRecordState.Amended || state == RmsRecordState.Corrected + // A destination rejection is stored as RmsSubmissionState.Rejected, and QueueSubmissionCoreAsync + // re-queues Failed, Superseded and Rejected alike; reporting only Failed hid the retry action. + || (state == RmsRecordState.Rejected && a.Submissions.Any(s => s.State == (int)RmsSubmissionState.Failed || s.State == (int)RmsSubmissionState.Rejected))); return new IncidentReportData { ReportId = r.RmsIncidentReportId, CallId = r.CallId, ReportingEntityId = r.ReportingEntityId, DefinitionKey = r.DefinitionKey, DefinitionVersion = r.DefinitionVersion, ProfileVersion = r.ProfileVersion, @@ -376,6 +381,16 @@ public static IncidentReportData ToReport(IncidentReportAggregate a, bool submis FactKey = f.FactKey, SourceKind = f.SourceKind, SourceKindName = ((RmsSourceKind)f.SourceKind).ToString(), SourceSystem = f.SourceSystem, SourceEntityType = f.SourceEntityType, SourceEntityId = f.SourceEntityId, SourceValue = f.SourceValue, CurrentValue = f.CurrentValue, SourceTime = f.SourceTime, CorrectedOn = f.CorrectedOn, CorrectedByUserId = f.CorrectedByUserId }).ToList(), + Sections = BuildSections(a, sections), + Modules = a.Modules.OrderBy(m => m.Ordinal).Select(ToModule).ToList(), + Resources = a.Resources.OrderBy(r => r.Ordinal).Select(r => new IncidentResourceData + { + ResourceId = r.RmsIncidentResourceId, ResourceCode = r.ResourceCode, Quantity = r.Quantity, Detail = r.Detail, Ordinal = r.Ordinal + }).ToList(), + Casualties = a.Casualties.OrderBy(c => c.Ordinal).Select(c => ToCasualty(c, canViewRestricted, withheld)).ToList(), + Exposures = a.Exposures.OrderBy(e => e.Ordinal).Select(ToExposure).ToList(), + WithheldFields = withheld, + IncidentAnalysisId = incidentAnalysisId, Issues = a.Issues.Select(ToIssue).ToList(), Submissions = a.Submissions.OrderByDescending(s => s.QueuedOn).Select(s => new IncidentSubmissionData { @@ -393,6 +408,99 @@ public static IncidentReportData ToReport(IncidentReportAggregate a, bool submis }; } + /// + /// The progressive section requirements, each marked with whether the report already carries that section, + /// plus the contract's payload path and schema so a client can render and post the right shape. + /// + private static List BuildSections(IncidentReportAggregate a, IEnumerable sections) + { + var present = new HashSet(a.Modules.Select(m => m.ModuleKind)); + return (sections ?? Enumerable.Empty()).Select(r => + { + var descriptor = RmsIncidentModuleCatalog.Get(r.Kind); + return new IncidentSectionRequirementData + { + Kind = (int)r.Kind, + KindName = r.Kind.ToString(), + PayloadPath = descriptor?.PayloadPath, + SchemaName = descriptor?.SchemaName, + IsCollection = descriptor?.IsCollection ?? false, + Required = r.Required, + Reason = r.Reason, + PrimaryCodeSet = r.PrimaryCodeSet, + SecondaryCodeSet = r.SecondaryCodeSet, + Present = present.Contains((int)r.Kind) + }; + }).ToList(); + } + + public static IncidentModuleData ToModule(RmsIncidentModule m) + { + var kind = (RmsIncidentModuleKind)m.ModuleKind; + var descriptor = RmsIncidentModuleCatalog.Get(kind); + return new IncidentModuleData + { + ModuleId = m.RmsIncidentModuleId, Kind = m.ModuleKind, KindName = kind.ToString(), + PayloadPath = descriptor?.PayloadPath, SchemaName = m.SchemaName ?? descriptor?.SchemaName, + PrimaryCode = m.PrimaryCode, SecondaryCode = m.SecondaryCode, Quantity = m.Quantity, QuantityUnit = m.QuantityUnit, + OccurredOn = m.OccurredOn, DetailJson = m.DetailJson, Ordinal = m.Ordinal + }; + } + + /// + /// A casualty or rescue. The entry itself is not secret — that somebody was hurt is part of the report — + /// but demographics, the personnel link and the injury detail are restricted, so they are dropped and named + /// rather than the whole row disappearing, which would misrepresent the incident. + /// + public static IncidentCasualtyData ToCasualty(RmsCasualtyRescue c, bool canViewRestricted, List withheld) + { + var data = new IncidentCasualtyData + { + CasualtyId = c.RmsCasualtyRescueId, Kind = c.Kind, KindName = ((RmsCasualtyRescueKind)c.Kind).ToString(), PersonType = c.PersonType, + YearsOfService = c.YearsOfService, JobClassification = c.JobClassification, WasInjured = c.WasInjured, WasFatal = c.WasFatal, + CasualtyCause = c.CasualtyCause, CasualtyAction = c.CasualtyAction, CasualtyTimeline = c.CasualtyTimeline, DutyType = c.DutyType, + Ppe = SplitCodes(c.PpeCsv), RescueType = c.RescueType, RescueActions = SplitCodes(c.RescueActionsCsv), + RescueImpediments = SplitCodes(c.RescueImpedimentsCsv), RescueMode = c.RescueMode, RescuePath = c.RescuePath, + RescueElevation = c.RescueElevation, PresenceKnown = c.PresenceKnown, OccurredOn = c.OccurredOn, Ordinal = c.Ordinal + }; + + if (canViewRestricted) + { + data.PersonnelUserId = c.PersonnelUserId; + data.Rank = c.Rank; + data.BirthMonthYear = c.BirthMonthYear; + data.Gender = c.Gender; + data.Race = c.Race; + data.InjuryDetailJson = c.InjuryDetailJson; + } + else + { + foreach (var field in new[] { "PersonnelUserId", "Rank", "BirthMonthYear", "Gender", "Race", "InjuryDetailJson" }) + withheld.Add("Casualties." + field); + } + + return data; + } + + public static IncidentExposureData ToExposure(RmsExposure e) + { + return new IncidentExposureData + { + ExposureId = e.RmsExposureId, LocationKind = e.LocationKind, ItemType = e.ItemType, DamageType = e.DamageType, LocationUse = e.LocationUse, + PeoplePresent = e.PeoplePresent, DisplacementCount = e.DisplacementCount, DisplacementCauses = SplitCodes(e.DisplacementCausesCsv), + AddressText = e.AddressText, Street = e.Street, Municipality = e.Municipality, State = e.State, PostalCode = e.PostalCode, + Latitude = e.Latitude, Longitude = e.Longitude, EstimatedValue = e.EstimatedValue, EstimatedLoss = e.EstimatedLoss, + CurrencyCode = e.CurrencyCode, Ordinal = e.Ordinal + }; + } + + private static List SplitCodes(string csv) + { + return string.IsNullOrWhiteSpace(csv) + ? new List() + : csv.Split(',').Select(c => c.Trim()).Where(c => c.Length > 0).ToList(); + } + public static IncidentIssueData ToIssue(RmsValidationIssue i) { return new IncidentIssueData { RuleKey = i.RuleKey, Severity = i.Severity, SeverityName = ((RmsValidationSeverity)i.Severity).ToString(), FieldPath = i.FieldPath, Message = i.Message, Source = i.Source, SourceName = ((RmsValidationSource)i.Source).ToString() }; @@ -430,6 +538,32 @@ public static IncidentReportDraftInput ToDraftInput(SaveIncidentReportDraftInput ImpedimentNarrative = input.ImpedimentNarrative, OutcomeNarrative = input.OutcomeNarrative, SupplementalJson = input.SupplementalJson, + // Null stays null: the service reads absence as "leave this section alone". + Modules = input.Modules?.Select(m => new IncidentModuleInput + { + Kind = (RmsIncidentModuleKind)m.Kind, PrimaryCode = m.PrimaryCode, SecondaryCode = m.SecondaryCode, + Quantity = m.Quantity, QuantityUnit = m.QuantityUnit, OccurredOn = RecordsApiHelper.Utc(m.OccurredOn), DetailJson = m.DetailJson + }).ToList(), + Resources = input.Resources?.Select(r => new IncidentResourceInput { ResourceCode = r.ResourceCode, Quantity = r.Quantity, Detail = r.Detail }).ToList(), + Casualties = input.Casualties?.Select(c => new IncidentCasualtyRescueInput + { + Kind = (RmsCasualtyRescueKind)c.Kind, PersonType = c.PersonType, PersonnelUserId = c.PersonnelUserId, Rank = c.Rank, + YearsOfService = c.YearsOfService, JobClassification = c.JobClassification, BirthMonthYear = c.BirthMonthYear, + Gender = c.Gender, Race = c.Race, WasInjured = c.WasInjured, WasFatal = c.WasFatal, + CasualtyCause = c.CasualtyCause, CasualtyAction = c.CasualtyAction, CasualtyTimeline = c.CasualtyTimeline, DutyType = c.DutyType, + Ppe = c.Ppe ?? new List(), InjuryDetailJson = c.InjuryDetailJson, RescueType = c.RescueType, + RescueActions = c.RescueActions ?? new List(), RescueImpediments = c.RescueImpediments ?? new List(), + RescueMode = c.RescueMode, RescuePath = c.RescuePath, RescueElevation = c.RescueElevation, PresenceKnown = c.PresenceKnown, + OccurredOn = RecordsApiHelper.Utc(c.OccurredOn), DetailJson = c.DetailJson + }).ToList(), + Exposures = input.Exposures?.Select(e => new IncidentExposureInput + { + LocationKind = e.LocationKind, ItemType = e.ItemType, DamageType = e.DamageType, LocationUse = e.LocationUse, + PeoplePresent = e.PeoplePresent, DisplacementCount = e.DisplacementCount, DisplacementCauses = e.DisplacementCauses ?? new List(), + AddressText = e.AddressText, Street = e.Street, Municipality = e.Municipality, State = e.State, PostalCode = e.PostalCode, + Latitude = e.Latitude, Longitude = e.Longitude, EstimatedValue = e.EstimatedValue, EstimatedLoss = e.EstimatedLoss, + CurrencyCode = e.CurrencyCode, DetailJson = e.DetailJson + }).ToList(), OriginClient = origin }; } diff --git a/Web/Resgrid.Web.Services/Helpers/RecordsRms3ApiHelper.cs b/Web/Resgrid.Web.Services/Helpers/RecordsRms3ApiHelper.cs new file mode 100644 index 00000000..35164d2f --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/RecordsRms3ApiHelper.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Models.v4.Records; + +namespace Resgrid.Web.Services.Helpers +{ + /// + /// v4 mapping for the RMS-3 surfaces: the incident-analysis filing, evidence artifacts, public-records + /// disclosures and the Records dashboard. Kept apart from because these + /// are separate aggregates with their own lifecycles, not sections of the incident report. + /// + public static class IncidentAnalysisApiMapper + { + public static IncidentAnalysisData ToAnalysis(IncidentAnalysisAggregate a, bool canViewRestricted = true) + { + var withheld = new List(); + var analysis = a.Analysis; + var state = (RmsIncidentAnalysisState)analysis.State; + + return new IncidentAnalysisData + { + AnalysisId = analysis.RmsIncidentAnalysisId, IncidentReportId = analysis.IncidentReportId, ReportingEntityId = analysis.ReportingEntityId, + ProfileVersion = analysis.ProfileVersion, State = analysis.State, StateName = state.ToString(), GeneralCause = analysis.GeneralCause, + InvestigationTypes = SplitCodes(analysis.InvestigationTypesCsv), EstimatedValueTotal = analysis.EstimatedValueTotal, + EstimatedLossTotal = analysis.EstimatedLossTotal, CurrencyCode = analysis.CurrencyCode, AuthorUserId = analysis.AuthorUserId, + OwnerUserId = analysis.OwnerUserId, FinalizedOn = analysis.FinalizedOn, CurrentRevisionId = analysis.CurrentRevisionId, + RevisionCount = analysis.RevisionCount, NerisAnalysisId = analysis.NerisAnalysisId, LastSubmissionState = analysis.LastSubmissionState, + LastSubmissionStateName = analysis.LastSubmissionState.HasValue ? ((RmsSubmissionState)analysis.LastSubmissionState.Value).ToString() : null, + LastSubmittedOn = analysis.LastSubmittedOn, AcceptedOn = analysis.AcceptedOn, RejectedOn = analysis.RejectedOn, + RejectionSummary = analysis.RejectionSummary, VoidedOn = analysis.VoidedOn, VoidReasonCode = analysis.VoidReasonCode, + CreatedOn = analysis.CreatedOn, ModifiedOn = analysis.ModifiedOn, RowVersion = analysis.RowVersion, + ETag = RecordsApiContract.ToETag(analysis.RowVersion), + IsEditable = state == RmsIncidentAnalysisState.Draft || state == RmsIncidentAnalysisState.Rejected, + IncidentIsFiled = a.IncidentIsFiled, + Modules = a.Modules.OrderBy(m => m.Ordinal).Select(IncidentReportsApiMapper.ToModule).ToList(), + Properties = a.Properties.OrderBy(p => p.Ordinal).Select(ToProperty).ToList(), + Vehicles = a.Vehicles.OrderBy(v => v.Ordinal).Select(v => ToVehicle(v, canViewRestricted, withheld)).ToList(), + Submissions = a.Submissions.OrderByDescending(s => s.QueuedOn).Select(s => new IncidentSubmissionData + { + SubmissionId = s.RmsSubmissionId, RevisionId = s.RevisionId, Destination = s.Destination, DestinationVersion = s.DestinationVersion, + State = s.State, StateName = ((RmsSubmissionState)s.State).ToString(), ExternalId = s.ExternalId, ExternalStatus = s.ExternalStatus, + Attempts = s.Attempts, MaxAttempts = s.MaxAttempts, ErrorSummary = s.ErrorSummary, PayloadChecksum = s.PayloadChecksum, + QueuedOn = s.QueuedOn, SentOn = s.SentOn, CompletedOn = s.CompletedOn, NextAttemptOn = s.NextAttemptOn + }).ToList(), + Revisions = a.Revisions.OrderByDescending(r => r.RevisionNumber).Select(RecordsApiMapper.ToRevision).ToList(), + WithheldFields = withheld + }; + } + + public static IncidentPropertyData ToProperty(RmsIncidentProperty p) + { + return new IncidentPropertyData + { + PropertyId = p.RmsIncidentPropertyId, LocationUse = p.LocationUse, ConstructionType = p.ConstructionType, Foundation = p.Foundation, + ExteriorFinish = p.ExteriorFinish, RoofMaterial = p.RoofMaterial, StoriesAboveGrade = p.StoriesAboveGrade, StoriesBelowGrade = p.StoriesBelowGrade, + YearBuilt = p.YearBuilt, Vacancy = p.Vacancy, DamageType = p.DamageType, FireSpread = p.FireSpread, EstimatedValue = p.EstimatedValue, + EstimatedLoss = p.EstimatedLoss, ContentsValue = p.ContentsValue, ContentsLoss = p.ContentsLoss, CurrencyCode = p.CurrencyCode, Ordinal = p.Ordinal + }; + } + + /// + /// The vehicle row stays visible without the restricted grant — that a vehicle burned is part of the + /// incident — but VIN, plate and registration state identify a person's property, so those three are + /// dropped and named instead of the row disappearing. + /// + public static IncidentVehicleData ToVehicle(RmsIncidentVehicle v, bool canViewRestricted, List withheld) + { + var data = new IncidentVehicleData + { + VehicleId = v.RmsIncidentVehicleId, VehicleKind = v.VehicleKind, Make = v.Make, Model = v.Model, ModelYear = v.ModelYear, + BodyStyle = v.BodyStyle, Powertrain = v.Powertrain, DamageType = v.DamageType, WasOccupied = v.WasOccupied, + EstimatedValue = v.EstimatedValue, EstimatedLoss = v.EstimatedLoss, CurrencyCode = v.CurrencyCode, Ordinal = v.Ordinal + }; + + if (canViewRestricted) + { + data.Vin = v.Vin; + data.LicensePlate = v.LicensePlate; + data.LicenseState = v.LicenseState; + } + else + { + foreach (var field in new[] { "Vin", "LicensePlate", "LicenseState" }) + withheld.Add("Vehicles." + field); + } + + return data; + } + + public static IncidentAnalysisDraftInput ToDraftInput(SaveIncidentAnalysisDraftInput input, RmsOriginClient origin) + { + return new IncidentAnalysisDraftInput + { + GeneralCause = input.GeneralCause, + InvestigationTypes = input.InvestigationTypes ?? new List(), + CurrencyCode = input.CurrencyCode, + // Null stays null: the service reads absence as "leave this section alone". + Modules = input.Modules?.Select(m => new IncidentModuleInput + { + Kind = (RmsIncidentModuleKind)m.Kind, PrimaryCode = m.PrimaryCode, SecondaryCode = m.SecondaryCode, Quantity = m.Quantity, + QuantityUnit = m.QuantityUnit, OccurredOn = RecordsApiHelper.Utc(m.OccurredOn), DetailJson = m.DetailJson + }).ToList(), + Properties = input.Properties?.Select(p => new IncidentPropertyInput + { + LocationUse = p.LocationUse, ConstructionType = p.ConstructionType, Foundation = p.Foundation, ExteriorFinish = p.ExteriorFinish, + RoofMaterial = p.RoofMaterial, StoriesAboveGrade = p.StoriesAboveGrade, StoriesBelowGrade = p.StoriesBelowGrade, YearBuilt = p.YearBuilt, + Vacancy = p.Vacancy, DamageType = p.DamageType, FireSpread = p.FireSpread, EstimatedValue = p.EstimatedValue, EstimatedLoss = p.EstimatedLoss, + ContentsValue = p.ContentsValue, ContentsLoss = p.ContentsLoss, DetailJson = p.DetailJson + }).ToList(), + Vehicles = input.Vehicles?.Select(v => new IncidentVehicleInput + { + VehicleKind = v.VehicleKind, Make = v.Make, Model = v.Model, ModelYear = v.ModelYear, BodyStyle = v.BodyStyle, Powertrain = v.Powertrain, + DamageType = v.DamageType, Vin = v.Vin, LicensePlate = v.LicensePlate, LicenseState = v.LicenseState, WasOccupied = v.WasOccupied, + EstimatedValue = v.EstimatedValue, EstimatedLoss = v.EstimatedLoss, DetailJson = v.DetailJson + }).ToList(), + OriginClient = origin + }; + } + + private static List SplitCodes(string csv) + { + return string.IsNullOrWhiteSpace(csv) + ? new List() + : csv.Split(',').Select(c => c.Trim()).Where(c => c.Length > 0).ToList(); + } + } + + /// Evidence artifacts (RMS plan section 4.5). + public static class RecordEvidenceApiMapper + { + public static RecordEvidenceSourceData ToSource(RecordEvidenceSourceState state) + { + return new RecordEvidenceSourceData { Kind = (int)state.Kind, KindName = state.Kind.ToString(), Available = state.Available, Reason = state.Reason }; + } + + /// + /// is only ever true for a single-artifact read. A restricted artifact's + /// manifest is withheld and flagged rather than omitted silently, so the caller knows there is content they + /// are not seeing. + /// + public static RecordEvidenceArtifactData ToArtifact(RmsEvidenceArtifact a, bool canViewRestricted, bool includeManifest = false) + { + var classification = (RmsEvidenceClassification)a.Classification; + var mayReadContent = canViewRestricted || classification == RmsEvidenceClassification.Unrestricted; + + return new RecordEvidenceArtifactData + { + ArtifactId = a.RmsEvidenceArtifactId, RecordId = a.RecordId, RecordKind = a.RecordKind, RevisionId = a.RevisionId, Kind = a.Kind, + KindName = ((RmsEvidenceKind)a.Kind).ToString(), Title = a.Title, CaptureReason = a.CaptureReason, SourceSubsystem = a.SourceSubsystem, + SourceEntityType = a.SourceEntityType, SourceEntityId = a.SourceEntityId, IdentifierScheme = a.IdentifierScheme, SourceVersion = a.SourceVersion, + CoverageStart = a.CoverageStart, CoverageEnd = a.CoverageEnd, Checksum = a.Checksum, ByteSize = a.ByteSize, SourceItemCount = a.SourceItemCount, + Classification = a.Classification, ClassificationName = classification.ToString(), RetentionYears = a.RetentionYears, + CapturedByUserId = a.CapturedByUserId, CapturedOn = a.CapturedOn, OriginClient = a.OriginClient, + SupersededByArtifactId = a.SupersededByArtifactId, SupersededOn = a.SupersededOn, IsCurrent = a.IsCurrent, + ManifestJson = includeManifest && mayReadContent ? a.ManifestJson : null, + ManifestWithheld = includeManifest && !mayReadContent + }; + } + + public static RecordEvidenceCaptureRequest ToCaptureRequest(CaptureRecordEvidenceInput input, int departmentId, string userId, RmsOriginClient origin) + { + return new RecordEvidenceCaptureRequest + { + DepartmentId = departmentId, + RecordId = input.RecordId, + RecordKind = input.RecordKind.HasValue ? (RmsRecordKind)input.RecordKind.Value : RmsRecordKind.Operational, + Kind = (RmsEvidenceKind)input.Kind, + CaptureReason = input.CaptureReason, + CallId = input.CallId, + CoverageStart = RecordsApiHelper.Utc(input.CoverageStart), + CoverageEnd = RecordsApiHelper.Utc(input.CoverageEnd), + SourceIds = input.SourceIds ?? new List(), + UnitIds = input.UnitIds ?? new List(), + UserIds = input.UserIds ?? new List(), + CapturedByUserId = userId, + OriginClient = origin + }; + } + } + + /// Public-records requests and productions (RMS plan section 4.7). + public static class DisclosuresApiMapper + { + public static DisclosureRequestData ToRequest(RmsDisclosureRequest r, bool canViewRestricted) + { + var data = new DisclosureRequestData + { + RequestId = r.RmsDisclosureRequestId, RequestNumber = r.RequestNumber, ReceivedOn = r.ReceivedOn, StatutoryDueOn = r.StatutoryDueOn, + IsOverdue = r.IsOverdue, JurisdictionProfile = r.JurisdictionProfile, ScopeNarrative = r.ScopeNarrative, ScopeQueryJson = r.ScopeQueryJson, + State = r.State, StateName = ((RmsDisclosureState)r.State).ToString(), AssignedToUserId = r.AssignedToUserId, RedactionProfile = r.RedactionProfile, + ClosedOn = r.ClosedOn, ClosedByUserId = r.ClosedByUserId, DispositionReason = r.DispositionReason, CreatedOn = r.CreatedOn, + ModifiedOn = r.ModifiedOn, RowVersion = r.RowVersion, ETag = RecordsApiContract.ToETag(r.RowVersion) + }; + + // Who asked is not itself public in most jurisdictions, and it must never ride into a produced packet. + if (canViewRestricted) + { + data.RequesterName = r.RequesterName; + data.RequesterOrganization = r.RequesterOrganization; + data.RequesterContact = r.RequesterContact; + } + else + { + data.WithheldFields.AddRange(new[] { "RequesterName", "RequesterOrganization", "RequesterContact" }); + } + + return data; + } + + public static DisclosureScopePreviewData ToPreview(RmsDisclosureScopePreview preview) + { + return new DisclosureScopePreviewData + { + MatchedCount = preview.MatchedCount, ProducibleCount = preview.ProducibleCount, WithheldWholeRecordCount = preview.WithheldWholeRecordCount, + Truncated = preview.Truncated, + Items = preview.Items.Select(i => new DisclosureScopeItemData + { + RecordId = i.RecordId, RecordNumber = i.RecordNumber, DefinitionKey = i.DefinitionKey, Summary = i.Summary, OccurredOn = i.OccurredOn, + CurrentRevisionId = i.CurrentRevisionId, Producible = i.Producible, NotProducibleReason = i.NotProducibleReason + }).ToList() + }; + } + + /// The redacted artifact is only carried on a single-production read; a listing would ship the whole release. + public static DisclosureProductionData ToProduction(RmsDisclosureProduction p, bool includeArtifact = false) + { + return new DisclosureProductionData + { + ProductionId = p.RmsDisclosureProductionId, RequestId = p.DisclosureRequestId, ProductionNumber = p.ProductionNumber, + RedactionProfile = p.RedactionProfile, Checksum = p.Checksum, ByteSize = p.ByteSize, RecordCount = p.RecordCount, + WithheldFieldCount = p.WithheldFieldCount, PreparedByUserId = p.PreparedByUserId, PreparedOn = p.PreparedOn, + ReleasedByUserId = p.ReleasedByUserId, ReleasedOn = p.ReleasedOn, IsReleased = p.IsReleased, + ProducedSetJson = p.ProducedSetJson, WithheldFieldsJson = p.WithheldFieldsJson, + ArtifactJson = includeArtifact ? p.ArtifactJson : null + }; + } + + /// + /// The scope query as the Records queue runs it. The viewer fields are deliberately not accepted from the + /// client — the service sets them from the caller, so a scope can never be widened past what its author + /// may see. + /// + public static RmsRecordQuery ToScopeQuery(DisclosureScopeQueryInput input) + { + if (input == null) + return null; + + return new RmsRecordQuery + { + States = input.States != null && input.States.Count > 0 ? input.States.ToList() : null, + DefinitionKey = string.IsNullOrWhiteSpace(input.DefinitionKey) ? null : input.DefinitionKey, + Year = input.Year, + CallId = input.CallId, + AuthorUserId = string.IsNullOrWhiteSpace(input.AuthorUserId) ? null : input.AuthorUserId, + OwnerUserId = string.IsNullOrWhiteSpace(input.OwnerUserId) ? null : input.OwnerUserId, + StationGroupId = input.StationGroupId, + IncludeLegacy = input.IncludeLegacy, + Take = Math.Max(1, Math.Min(500, input.Take ?? 200)) + }; + } + } + + /// Dashboard counts and crosswalk coverage. + public static class RecordsDashboardApiMapper + { + public static RecordsDashboardData ToDashboard(RecordsDashboard d) + { + return new RecordsDashboardData + { + GeneratedOn = d.GeneratedOn, OperationalDrafts = d.OperationalDrafts, OperationalAwaitingReview = d.OperationalAwaitingReview, + OperationalReturned = d.OperationalReturned, IncidentIncomplete = d.IncidentIncomplete, IncidentAwaitingReview = d.IncidentAwaitingReview, + IncidentSubmitted = d.IncidentSubmitted, IncidentAccepted = d.IncidentAccepted, IncidentRejected = d.IncidentRejected, Overdue = d.Overdue, + AnalysesAwaitingFiling = d.AnalysesAwaitingFiling, DisclosuresOpen = d.DisclosuresOpen, DisclosuresOverdue = d.DisclosuresOverdue, + Warnings = d.Warnings.ToList() + }; + } + + public static NerisCrosswalkCoverageData ToCoverage(NerisCrosswalkCoverage c) + { + return new NerisCrosswalkCoverageData + { + ContractVersion = c.ContractVersion, TotalLocalCodes = c.TotalLocalCodes, MappedCount = c.MappedCount, UnmappedCount = c.UnmappedCount, + StaleMappingCount = c.StaleMappingCount, + Items = c.Items.Select(i => new NerisCrosswalkCoverageItemData { SetKey = i.SetKey, LocalCode = i.LocalCode, NerisCode = i.NerisCode, Mapped = i.Mapped }).ToList(), + Warnings = c.Warnings.ToList() + }; + } + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/RecordsSystemPrincipal.cs b/Web/Resgrid.Web.Services/Helpers/RecordsSystemPrincipal.cs new file mode 100644 index 00000000..2892a1c8 --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/RecordsSystemPrincipal.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using Resgrid.Model; +using Resgrid.Providers.Claims; + +namespace Resgrid.Web.Services.Helpers +{ + /// + /// Request-side resolution of the Record grant a system principal is acting under (Identifier Allocation + /// Registry section 4.4). The claim issued at sign-in only says "some grant exists"; this re-reads the + /// configuration against the department the request actually resolved to, so a token minted while a grant + /// existed stops working the moment the grant is removed or points at another department. + /// + /// Nothing here can widen access. Mutating and restricted Record policies are unreachable for a system + /// principal because the claim is never issued, not because this class declines to hand one out. + /// + /// + public static class RecordsSystemPrincipal + { + /// + /// Whether the caller is a non-user principal — the SMTP relay key or a client_credentials service + /// account. A real member never carries either marker. + /// + public static bool IsSystemPrincipal(ClaimsPrincipal principal) + { + if (principal == null) + return false; + + return principal.HasClaim(ResgridClaimTypes.Data.ServiceAccount, "true") || + principal.HasClaim(c => c.Type == ResgridClaimTypes.Data.RecordGrantPurpose); + } + + /// + /// The grant covering this request, or null when the caller is a user principal or has no grant for + /// the department. Callers treat null-with-a-system-principal as a denial, not as "unrestricted". + /// + public static SystemPrincipalRecordGrant ResolveGrant(ClaimsPrincipal principal, int departmentId) + { + if (!IsSystemPrincipal(principal)) + return null; + + return SystemPrincipalRecordGrant.For(departmentId); + } + } +} diff --git a/Web/Resgrid.Web.Services/Middleware/SystemApiKeyAuthHandler.cs b/Web/Resgrid.Web.Services/Middleware/SystemApiKeyAuthHandler.cs index 77215c5b..61e18c00 100644 --- a/Web/Resgrid.Web.Services/Middleware/SystemApiKeyAuthHandler.cs +++ b/Web/Resgrid.Web.Services/Middleware/SystemApiKeyAuthHandler.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Resgrid.Model; using Resgrid.Providers.Claims; namespace Resgrid.Web.Services.Middleware @@ -19,6 +20,8 @@ namespace Resgrid.Web.Services.Middleware /// Used by the SMTP Relay in hosted multi-department mode to bypass OAuth 2.0 entirely. /// The handler creates a ClaimsPrincipal with full permissions across all departments. /// Department scoping is achieved via the DepartmentId field on individual API request models. + /// Records (RMS) is the one exception to "full permissions": the principal gets Record_View only when a + /// department+purpose grant is configured, and never a mutating or restricted Record claim. /// public class SystemApiKeyAuthHandler : AuthenticationHandler { @@ -127,6 +130,13 @@ protected override Task HandleAuthenticateAsync() } } + // Records (RMS) is deliberately absent from the blanket list above. A system principal reaches + // Records only through an explicitly configured department+purpose grant, and then only to read + // (Identifier Allocation Registry section 4.4). No configuration produces a mutating or + // restricted Record claim here; RecordsSystemPrincipalGuard re-checks the grant per request. + if (SystemPrincipalRecordGrant.AnyConfigured()) + claims.Add(new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View)); + var identity = new ClaimsIdentity(claims, "SystemApiKey"); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/IncidentAnalysisApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/IncidentAnalysisApiModels.cs new file mode 100644 index 00000000..583bc094 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Records/IncidentAnalysisApiModels.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Records +{ + public class IncidentAnalysisResult : StandardApiResponseV4Base + { + public IncidentAnalysisData Data { get; set; } + } + + /// + /// The NERIS incident-analysis filing (RMS-3): the fire/hazmat investigation posted separately from the + /// incident, once the destination already holds it. Its own state, revisions and idempotency key, which is why + /// it is a document in its own right rather than a section of the report. + /// + public class IncidentAnalysisData + { + public string AnalysisId { get; set; } + public string IncidentReportId { get; set; } + public string ReportingEntityId { get; set; } + public string ProfileVersion { get; set; } + public int State { get; set; } + public string StateName { get; set; } + public string GeneralCause { get; set; } + public List InvestigationTypes { get; set; } = new List(); + public decimal? EstimatedValueTotal { get; set; } + public decimal? EstimatedLossTotal { get; set; } + public string CurrencyCode { get; set; } + public string AuthorUserId { get; set; } + public string OwnerUserId { get; set; } + public DateTime? FinalizedOn { get; set; } + public string CurrentRevisionId { get; set; } + public int RevisionCount { get; set; } + public string NerisAnalysisId { get; set; } + public int? LastSubmissionState { get; set; } + public string LastSubmissionStateName { get; set; } + public DateTime? LastSubmittedOn { get; set; } + public DateTime? AcceptedOn { get; set; } + public DateTime? RejectedOn { get; set; } + public string RejectionSummary { get; set; } + public DateTime? VoidedOn { get; set; } + public string VoidReasonCode { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public string ETag { get; set; } + public bool IsEditable { get; set; } + + /// + /// False while the incident itself has no destination id. Finalizing is still allowed — the filing waits + /// rather than failing — but a client should say so rather than showing a submit button that defers. + /// + public bool IncidentIsFiled { get; set; } + + public List Modules { get; set; } = new List(); + public List Properties { get; set; } = new List(); + public List Vehicles { get; set; } = new List(); + public List Submissions { get; set; } = new List(); + public List Revisions { get; set; } = new List(); + /// Restricted fields withheld because the caller lacks RecordRestricted_View. + public List WithheldFields { get; set; } = new List(); + } + + public class IncidentPropertyData + { + public string PropertyId { get; set; } + public string LocationUse { get; set; } + public string ConstructionType { get; set; } + public string Foundation { get; set; } + public string ExteriorFinish { get; set; } + public string RoofMaterial { get; set; } + public int? StoriesAboveGrade { get; set; } + public int? StoriesBelowGrade { get; set; } + public int? YearBuilt { get; set; } + public string Vacancy { get; set; } + public string DamageType { get; set; } + public string FireSpread { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public decimal? ContentsValue { get; set; } + public decimal? ContentsLoss { get; set; } + public string CurrencyCode { get; set; } + public int Ordinal { get; set; } + } + + /// VIN, plate and registration state are restricted: they identify a person's vehicle. + public class IncidentVehicleData + { + public string VehicleId { get; set; } + public string VehicleKind { get; set; } + public string Make { get; set; } + public string Model { get; set; } + public int? ModelYear { get; set; } + public string BodyStyle { get; set; } + public string Powertrain { get; set; } + public string DamageType { get; set; } + public string Vin { get; set; } + public string LicensePlate { get; set; } + public string LicenseState { get; set; } + public bool WasOccupied { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public string CurrencyCode { get; set; } + public int Ordinal { get; set; } + } + + public class StartIncidentAnalysisInput + { + public string IncidentReportId { get; set; } + public int? OriginClient { get; set; } + } + + /// Draft save; every list replaces the draft rows, except a null list which leaves that set alone. + public class SaveIncidentAnalysisDraftInput + { + public string AnalysisId { get; set; } + public long? RowVersion { get; set; } + public int? OriginClient { get; set; } + public string GeneralCause { get; set; } + public List InvestigationTypes { get; set; } = new List(); + public string CurrencyCode { get; set; } + public List Modules { get; set; } + public List Properties { get; set; } + public List Vehicles { get; set; } + } + + public class IncidentPropertyInputData + { + public string LocationUse { get; set; } + public string ConstructionType { get; set; } + public string Foundation { get; set; } + public string ExteriorFinish { get; set; } + public string RoofMaterial { get; set; } + public int? StoriesAboveGrade { get; set; } + public int? StoriesBelowGrade { get; set; } + public int? YearBuilt { get; set; } + public string Vacancy { get; set; } + public string DamageType { get; set; } + public string FireSpread { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public decimal? ContentsValue { get; set; } + public decimal? ContentsLoss { get; set; } + public string DetailJson { get; set; } + } + + public class IncidentVehicleInputData + { + public string VehicleKind { get; set; } + public string Make { get; set; } + public string Model { get; set; } + public int? ModelYear { get; set; } + public string BodyStyle { get; set; } + public string Powertrain { get; set; } + public string DamageType { get; set; } + public string Vin { get; set; } + public string LicensePlate { get; set; } + public string LicenseState { get; set; } + public bool WasOccupied { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public string DetailJson { get; set; } + } + + public class IncidentAnalysisCommandInput + { + public string AnalysisId { get; set; } + public long? RowVersion { get; set; } + public string IdempotencyKey { get; set; } + public string ReasonCode { get; set; } + public string ReasonText { get; set; } + public int? OriginClient { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs index 5ba81e14..c10f75b8 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Resgrid.Model; @@ -110,6 +110,17 @@ public class IncidentReportData public string OutcomeNarrative { get; set; } public string SupplementalJson { get; set; } public List Facts { get; set; } = new List(); + // RMS-3 conditional sections. Sections lists what the selected incident types demand, so a client renders + // the same progressive rules the server validates against instead of hard-coding its own copy. + public List Sections { get; set; } = new List(); + public List Modules { get; set; } = new List(); + public List Resources { get; set; } = new List(); + public List Casualties { get; set; } = new List(); + public List Exposures { get; set; } = new List(); + /// Restricted fields withheld from this response because the caller lacks RecordRestricted_View. + public List WithheldFields { get; set; } = new List(); + /// The analysis filing for this incident, when one has been started. + public string IncidentAnalysisId { get; set; } public List Issues { get; set; } = new List(); public List Submissions { get; set; } = new List(); public List Signatures { get; set; } = new List(); @@ -294,6 +305,14 @@ public class SaveIncidentReportDraftInput public string ImpedimentNarrative { get; set; } public string OutcomeNarrative { get; set; } public string SupplementalJson { get; set; } + + // RMS-3 conditional sections. Null means "leave this section alone"; an empty list clears it. A client that + // cannot render a section must not delete what an officer authored on the Web, which is why absence and + // emptiness differ here and nowhere else in this input. + public List Modules { get; set; } + public List Resources { get; set; } + public List Casualties { get; set; } + public List Exposures { get; set; } } public class IncidentReportCommandInput @@ -309,6 +328,180 @@ public class IncidentReportCommandInput public int? OriginClient { get; set; } } + /// One conditional section the selected incident types demand or suggest (plan section 4.2). + public class IncidentSectionRequirementData + { + public int Kind { get; set; } + public string KindName { get; set; } + /// Dotted path in the pinned NERIS contract this section serializes into. + public string PayloadPath { get; set; } + public string SchemaName { get; set; } + public bool IsCollection { get; set; } + /// True blocks finalization; false is a warning the author may answer or ignore. + public bool Required { get; set; } + public string Reason { get; set; } + /// Value set the section's headline code must belong to; null when it has no coded headline. + public string PrimaryCodeSet { get; set; } + public string SecondaryCodeSet { get; set; } + /// Whether the report already carries a section of this kind. + public bool Present { get; set; } + } + + public class IncidentModuleData + { + public string ModuleId { get; set; } + public int Kind { get; set; } + public string KindName { get; set; } + public string PayloadPath { get; set; } + public string SchemaName { get; set; } + public string PrimaryCode { get; set; } + public string SecondaryCode { get; set; } + public decimal? Quantity { get; set; } + public string QuantityUnit { get; set; } + public DateTime? OccurredOn { get; set; } + /// The contract-shaped section body. + public string DetailJson { get; set; } + public int Ordinal { get; set; } + } + + public class IncidentResourceData + { + public string ResourceId { get; set; } + public string ResourceCode { get; set; } + public int? Quantity { get; set; } + public string Detail { get; set; } + public int Ordinal { get; set; } + } + + /// + /// A casualty or rescue. Demographics, the personnel link and the injury detail are restricted: a caller + /// without RecordRestricted_View gets the entry with those fields absent and named in WithheldFields. + /// + public class IncidentCasualtyData + { + public string CasualtyId { get; set; } + public int Kind { get; set; } + public string KindName { get; set; } + public string PersonType { get; set; } + public string PersonnelUserId { get; set; } + public string Rank { get; set; } + public decimal? YearsOfService { get; set; } + public string JobClassification { get; set; } + public string BirthMonthYear { get; set; } + public string Gender { get; set; } + public string Race { get; set; } + public bool? WasInjured { get; set; } + public bool WasFatal { get; set; } + public string CasualtyCause { get; set; } + public string CasualtyAction { get; set; } + public string CasualtyTimeline { get; set; } + public string DutyType { get; set; } + public List Ppe { get; set; } = new List(); + public string InjuryDetailJson { get; set; } + public string RescueType { get; set; } + public List RescueActions { get; set; } = new List(); + public List RescueImpediments { get; set; } = new List(); + public string RescueMode { get; set; } + public string RescuePath { get; set; } + public string RescueElevation { get; set; } + public string PresenceKnown { get; set; } + public DateTime? OccurredOn { get; set; } + public int Ordinal { get; set; } + } + + public class IncidentExposureData + { + public string ExposureId { get; set; } + public string LocationKind { get; set; } + public string ItemType { get; set; } + public string DamageType { get; set; } + public string LocationUse { get; set; } + public bool? PeoplePresent { get; set; } + public int? DisplacementCount { get; set; } + public List DisplacementCauses { get; set; } = new List(); + public string AddressText { get; set; } + public string Street { get; set; } + public string Municipality { get; set; } + public string State { get; set; } + public string PostalCode { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public string CurrencyCode { get; set; } + public int Ordinal { get; set; } + } + + public class IncidentModuleInputData + { + public int Kind { get; set; } + public string PrimaryCode { get; set; } + public string SecondaryCode { get; set; } + public decimal? Quantity { get; set; } + public string QuantityUnit { get; set; } + public DateTime? OccurredOn { get; set; } + public string DetailJson { get; set; } + } + + public class IncidentResourceInputData + { + public string ResourceCode { get; set; } + public int? Quantity { get; set; } + public string Detail { get; set; } + } + + public class IncidentCasualtyInputData + { + public int Kind { get; set; } + public string PersonType { get; set; } + public string PersonnelUserId { get; set; } + public string Rank { get; set; } + public decimal? YearsOfService { get; set; } + public string JobClassification { get; set; } + public string BirthMonthYear { get; set; } + public string Gender { get; set; } + public string Race { get; set; } + public bool? WasInjured { get; set; } + public bool WasFatal { get; set; } + public string CasualtyCause { get; set; } + public string CasualtyAction { get; set; } + public string CasualtyTimeline { get; set; } + public string DutyType { get; set; } + public List Ppe { get; set; } = new List(); + public string InjuryDetailJson { get; set; } + public string RescueType { get; set; } + public List RescueActions { get; set; } = new List(); + public List RescueImpediments { get; set; } = new List(); + public string RescueMode { get; set; } + public string RescuePath { get; set; } + public string RescueElevation { get; set; } + public string PresenceKnown { get; set; } + public DateTime? OccurredOn { get; set; } + public string DetailJson { get; set; } + } + + public class IncidentExposureInputData + { + public string LocationKind { get; set; } + public string ItemType { get; set; } + public string DamageType { get; set; } + public string LocationUse { get; set; } + public bool? PeoplePresent { get; set; } + public int? DisplacementCount { get; set; } + public List DisplacementCauses { get; set; } = new List(); + public string AddressText { get; set; } + public string Street { get; set; } + public string Municipality { get; set; } + public string State { get; set; } + public string PostalCode { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public string CurrencyCode { get; set; } + public string DetailJson { get; set; } + } + /// 409 on a stale incident report save. public class IncidentReportConflictResult : StandardApiResponseV4Base { diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs index 05987231..65ac7b4e 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace Resgrid.Web.Services.Models.v4.Records @@ -154,6 +154,14 @@ public class RecordsChangesData { public long Since { get; set; } public long ServerTimestampMs { get; set; } + + /// + /// Tie-breaker for the next call, sent back as sinceId alongside since. Several records routinely + /// share a modified timestamp; without it the rows that fall after a page break inside one timestamp are never + /// delivered. + /// + public string ServerCursorId { get; set; } + public bool HasMore { get; set; } public List Records { get; set; } = new List(); } diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms3ApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms3ApiModels.cs new file mode 100644 index 00000000..ebb7d9dd --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms3ApiModels.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Records +{ + #region Evidence + + public class RecordEvidenceSourcesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// + /// Whether one of the six evidence sources can produce anything for this department right now. "Unavailable + /// with a reason" is a real answer and is not the same as "there was no evidence", so the client renders the + /// source either way rather than hiding it. + /// + public class RecordEvidenceSourceData + { + public int Kind { get; set; } + public string KindName { get; set; } + public bool Available { get; set; } + public string Reason { get; set; } + } + + public class RecordEvidenceListResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordEvidenceResult : StandardApiResponseV4Base + { + public RecordEvidenceArtifactData Data { get; set; } + } + + /// + /// An immutable evidence artifact. is only populated on the single-artifact read, + /// and only for a caller allowed to see that artifact's classification — a list of twenty artifacts must not + /// ship twenty manifests, and a restricted manifest must not ship at all to an unrestricted caller. + /// + public class RecordEvidenceArtifactData + { + public string ArtifactId { get; set; } + public string RecordId { get; set; } + public int RecordKind { get; set; } + public string RevisionId { get; set; } + public int Kind { get; set; } + public string KindName { get; set; } + public string Title { get; set; } + public string CaptureReason { get; set; } + public string SourceSubsystem { get; set; } + public string SourceEntityType { get; set; } + public string SourceEntityId { get; set; } + public string IdentifierScheme { get; set; } + public string SourceVersion { get; set; } + public DateTime? CoverageStart { get; set; } + public DateTime? CoverageEnd { get; set; } + public string Checksum { get; set; } + public long ByteSize { get; set; } + public int SourceItemCount { get; set; } + public int Classification { get; set; } + public string ClassificationName { get; set; } + public int? RetentionYears { get; set; } + public string CapturedByUserId { get; set; } + public DateTime CapturedOn { get; set; } + public int OriginClient { get; set; } + public string SupersededByArtifactId { get; set; } + public DateTime? SupersededOn { get; set; } + public bool IsCurrent { get; set; } + /// The artifact body; null in list responses and null when the caller may not see its classification. + public string ManifestJson { get; set; } + /// True when the manifest was withheld rather than simply not requested. + public bool ManifestWithheld { get; set; } + } + + /// Capture one artifact. The server bounds the window and decides the classification; neither is a client input. + public class CaptureRecordEvidenceInput + { + public string RecordId { get; set; } + /// RmsRecordKind; Operational by default, Incident for a NERIS report. + public int? RecordKind { get; set; } + public int Kind { get; set; } + /// Required: why this evidence is being put into an official record. + public string CaptureReason { get; set; } + public int? CallId { get; set; } + public DateTime? CoverageStart { get; set; } + public DateTime? CoverageEnd { get; set; } + public List SourceIds { get; set; } = new List(); + public List UnitIds { get; set; } = new List(); + public List UserIds { get; set; } = new List(); + public string IdempotencyKey { get; set; } + public int? OriginClient { get; set; } + } + + public class RecordEvidenceVerifyResult : StandardApiResponseV4Base + { + public RecordEvidenceVerifyData Data { get; set; } + } + + public class RecordEvidenceVerifyData + { + public string ArtifactId { get; set; } + public bool Intact { get; set; } + public string Checksum { get; set; } + } + + #endregion + + #region Disclosures + + public class DisclosureRequestsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + public int Total { get; set; } + } + + public class DisclosureRequestResult : StandardApiResponseV4Base + { + public DisclosureRequestData Data { get; set; } + } + + /// + /// A public-records request. Who asked is restricted in most jurisdictions, so the requester fields are + /// withheld — and named in — from a caller without RecordRestricted_View. + /// + public class DisclosureRequestData + { + public string RequestId { get; set; } + public string RequestNumber { get; set; } + public string RequesterName { get; set; } + public string RequesterOrganization { get; set; } + public string RequesterContact { get; set; } + public DateTime ReceivedOn { get; set; } + public DateTime? StatutoryDueOn { get; set; } + public bool IsOverdue { get; set; } + public string JurisdictionProfile { get; set; } + public string ScopeNarrative { get; set; } + public string ScopeQueryJson { get; set; } + public int State { get; set; } + public string StateName { get; set; } + public string AssignedToUserId { get; set; } + public string RedactionProfile { get; set; } + public DateTime? ClosedOn { get; set; } + public string ClosedByUserId { get; set; } + public string DispositionReason { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + public string ETag { get; set; } + public List WithheldFields { get; set; } = new List(); + } + + public class CreateDisclosureRequestInput + { + public string RequesterName { get; set; } + public string RequesterOrganization { get; set; } + public string RequesterContact { get; set; } + public DateTime? ReceivedOn { get; set; } + public DateTime? StatutoryDueOn { get; set; } + public string JurisdictionProfile { get; set; } + public string ScopeNarrative { get; set; } + public string AssignedToUserId { get; set; } + public string RedactionProfile { get; set; } + public string IdempotencyKey { get; set; } + } + + /// The scope a production runs against, expressed as the same bounded query the Records queue runs. + public class SaveDisclosureScopeInput + { + public string RequestId { get; set; } + public string ScopeNarrative { get; set; } + public string RedactionProfile { get; set; } + public DisclosureScopeQueryInput Scope { get; set; } + } + + public class DisclosureScopeQueryInput + { + public List States { get; set; } + public string DefinitionKey { get; set; } + public int? Year { get; set; } + public int? CallId { get; set; } + public string AuthorUserId { get; set; } + public string OwnerUserId { get; set; } + public int? StationGroupId { get; set; } + public bool IncludeLegacy { get; set; } + public int? Take { get; set; } + } + + public class DisclosureScopePreviewResult : StandardApiResponseV4Base + { + public DisclosureScopePreviewData Data { get; set; } + } + + public class DisclosureScopePreviewData + { + public int MatchedCount { get; set; } + public int ProducibleCount { get; set; } + public int WithheldWholeRecordCount { get; set; } + public bool Truncated { get; set; } + public List Items { get; set; } = new List(); + } + + public class DisclosureScopeItemData + { + public string RecordId { get; set; } + public string RecordNumber { get; set; } + public string DefinitionKey { get; set; } + public string Summary { get; set; } + public DateTime? OccurredOn { get; set; } + public string CurrentRevisionId { get; set; } + public bool Producible { get; set; } + public string NotProducibleReason { get; set; } + } + + public class DisclosureProductionsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class DisclosureProductionResult : StandardApiResponseV4Base + { + public DisclosureProductionData Data { get; set; } + } + + /// + /// One immutable produced set. The redacted artifact itself is only returned on the single-production read, + /// so listing a request's productions never ships several megabytes of released content. + /// + public class DisclosureProductionData + { + public string ProductionId { get; set; } + public string RequestId { get; set; } + public int ProductionNumber { get; set; } + public string RedactionProfile { get; set; } + public string Checksum { get; set; } + public long ByteSize { get; set; } + public int RecordCount { get; set; } + public int WithheldFieldCount { get; set; } + public string PreparedByUserId { get; set; } + public DateTime PreparedOn { get; set; } + public string ReleasedByUserId { get; set; } + public DateTime? ReleasedOn { get; set; } + public bool IsReleased { get; set; } + /// Record and revision ids released, with their checksums at production time. + public string ProducedSetJson { get; set; } + /// The redaction log: which fields were withheld and under what heading. + public string WithheldFieldsJson { get; set; } + /// The redacted content; single-production read only. + public string ArtifactJson { get; set; } + } + + public class DisclosureCommandInput + { + public string RequestId { get; set; } + public string ProductionId { get; set; } + public string RedactionProfile { get; set; } + /// RmsDisclosureState for a close: Denied, Withdrawn or Closed. + public int? Disposition { get; set; } + public string Reason { get; set; } + public string IdempotencyKey { get; set; } + } + + public class DisclosureVerifyResult : StandardApiResponseV4Base + { + public DisclosureVerifyData Data { get; set; } + } + + public class DisclosureVerifyData + { + public string ProductionId { get; set; } + public bool Intact { get; set; } + } + + #endregion + + #region Dashboard + + public class RecordsDashboardResult : StandardApiResponseV4Base + { + public RecordsDashboardData Data { get; set; } + } + + /// The Records work queues, group-scope-aware. A count that could not be produced degrades into . + public class RecordsDashboardData + { + public DateTime GeneratedOn { get; set; } + public int OperationalDrafts { get; set; } + public int OperationalAwaitingReview { get; set; } + public int OperationalReturned { get; set; } + public int IncidentIncomplete { get; set; } + public int IncidentAwaitingReview { get; set; } + public int IncidentSubmitted { get; set; } + public int IncidentAccepted { get; set; } + public int IncidentRejected { get; set; } + public int Overdue { get; set; } + public int AnalysesAwaitingFiling { get; set; } + public int DisclosuresOpen { get; set; } + public int DisclosuresOverdue { get; set; } + public List Warnings { get; set; } = new List(); + } + + public class NerisCrosswalkCoverageResult : StandardApiResponseV4Base + { + public NerisCrosswalkCoverageData Data { get; set; } + } + + /// Crosswalk gap report: an unmapped or stale local code is a filing that will need manual work. + public class NerisCrosswalkCoverageData + { + public string ContractVersion { get; set; } + public int TotalLocalCodes { get; set; } + public int MappedCount { get; set; } + public int UnmappedCount { get; set; } + public int StaleMappingCount { get; set; } + public List Items { get; set; } = new List(); + public List Warnings { get; set; } = new List(); + } + + public class NerisCrosswalkCoverageItemData + { + public string SetKey { get; set; } + public string LocalCode { get; set; } + public string NerisCode { get; set; } + public bool Mapped { get; set; } + } + + #endregion +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 63aebbb9..539cedde 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1310,6 +1310,17 @@ Used for system-level and client-credentials service principals that need full access. + + + Records (RMS) is deliberately absent from . A system principal — + the cross-department system account or a department service account — reads Records only under an + explicitly configured department+purpose grant, and then only with Record_View + (Identifier Allocation Registry section 4.4). No configuration produces a mutating or restricted + Record claim here. The purpose rides along on its own claim so the per-request guard can identify + the principal as non-user and write the purpose to the Record access audit. + + The principal's department, or 0 for the cross-department system account. + Performs a timing-safe comparison of two secret strings to prevent timing attacks. @@ -1513,6 +1524,56 @@ Input to deregister the device for Result for the unregistration + + + Public-records and access-to-information requests over the v4 Records contract (RMS plan section 4.7, RMS-3). + + Every endpoint requires RecordDisclosure_Update, including the reads: a public-records queue names who + asked for what, which is itself restricted in most jurisdictions, so this is not a Record_View surface. A + system principal is refused outright — a statutory release is a human act, and no service account is ever + granted a disclosure policy. + + + + + The disclosure queue, newest first; filters to one state. + + + + What the scope resolves to right now, through the same authorization path as the Records queue. Drafts + are listed but never producible: an unfinished report is not a public record. + + + + The request's productions; the released content itself is only carried by GetProduction. + + + + One production with its redacted artifact. The request id is required rather than inferred: a production + is only meaningful as part of its request, and reading one is the act of re-opening a release. + + + + Re-computes a production's checksum from its stored artifact; false means it was tampered with. + + + Logs a request and starts the statutory clock. + + + Saves the scope query and narrative; refused once a production exists, because the scope is what was produced against. + + + Builds a new immutable production: redacted content, produced-set snapshot, redaction log and checksum. + + + Releases a prepared production to the requester and closes the statutory clock. + + + Closes a request without release — denied under an exemption, or withdrawn. A reason is required. + + + A production is only reachable through its own request, so a stray id cannot walk another department's release. + API Calls that are used for the Dispatch App @@ -1701,6 +1762,48 @@ + + + The NERIS incident-analysis filing (RMS-3): the fire/hazmat investigation the contract posts separately from + the incident, once the destination already holds it. + + It is a second submittable artifact for the same incident rather than a section of it, so it has its own + endpoints, its own ETag and its own lifecycle — an analysis that will not validate must never block the + incident report. Visibility is inherited from the incident: an analysis is never visible to somebody who + cannot see the report it belongs to. + + + + + Same system-principal gate as the other Records controllers (registry section 4.4). + + + The analysis by its own id. + + + The analysis for an incident report, if one has been started. + + + Starts (or returns) the analysis for a report; one per report, so a second start is a 200. + + + ETag-guarded draft save; 409 with the current analysis on a stale row version. + + + Local validation against the pinned contract; the incident's own issue list is never touched. + + + + Writes the immutable revision and queues the filing. Finalizing before the incident is at the + destination is allowed: the submission waits for the incident's NERIS id rather than failing. + + + + Queues (or re-queues) the current revision; used when the incident was filed after the analysis was finalized. + + + An analysis is only as visible as the incident it belongs to. + Live incident command: establish/transfer/close command, edit the command structure (lanes), assign @@ -1911,6 +2014,25 @@ every read, Field Records flags for field clients. + + + Same system-principal gate as : a service account with no configured + Record grant for this department is refused before the action runs, and a granted one is confined to + reads because no mutating Record policy is ever issued to it (registry section 4.4). + + + + The configured grant this request runs under, or null for a user principal or an ungranted system one. + + + Group filter for the caller: the grant's groups for a system principal, the member's own otherwise. + + + Per-record visibility for the caller, routed to the grant rule for a system principal. + + + Audit purpose: the grant's stated purpose for a system principal, so a machine read is never anonymous. + One report with all sections, provenance facts, validation issues and sanitized submission history. Sets a weak ETag. @@ -1935,6 +2057,13 @@ Queues (or re-queues) the current revision for the destination. + + + scopes the idempotency key to the calling action. Without it a client that + reused one key for SubmitForReview and then Finalize matched on the report alone and was told the second + command succeeded without it ever running. + + Incident-scoped ad-hoc resources: create units/personnel on the fly for non-Resgrid resources, build @@ -2446,6 +2575,45 @@ while the Incident Commander/PIO has public sharing enabled. Disabling sharing revokes the token immediately. + + + Evidence capture over the v4 Records contract (RMS plan section 4.5, RMS-3): readiness, run-card activation, + tracking fixes, promoted chat, inventory usage and certification validity. + + A capture is a write to an official record, so it needs Record_Create and a stated reason; reading one only + needs Record_View plus visibility of the Record it supports. Artifacts are immutable: there is no update or + delete endpoint, and a correction is a fresh capture that supersedes the old one. + + + + + Same system-principal gate as the other Records controllers (registry section 4.4). + + + + Which of the six sources can produce evidence for this department right now. A source that is not built + or not configured answers "unavailable, and here is why" — which is a different answer from "there was + no evidence" and is why the list always carries all six. + + + + Artifacts on the working draft, or on a revision when one is named. Manifests are not carried in a list. + + + One artifact with its manifest; the manifest is withheld and flagged when it is classified above the caller. + + + Re-computes the checksum from the stored manifest; false means the artifact was tampered with. + + + + Captures one artifact. 409 when the source has nothing to give for this department — that is a real + answer about the source, not a client error, and the reason is carried in the problem detail. + + + + An artifact is only as visible as the Record it supports. + Records (RMS) client contract, RMS-1B (plan sections 5.3, 5.4, 5.9.1): capability manifest with the locked @@ -2455,6 +2623,29 @@ gates on the Records.System flag first; every read passes the per-record visibility rule. + + + Every action on this controller refuses a system principal that has no configured Record grant for + the department it resolved to, before the action runs (Identifier Allocation Registry section 4.4). + A user principal is untouched. Mutating actions need no further guard: their policies are never + issued to a system principal, so the claim check has already refused them. + + + + + The Records work queues an officer opens the module to look at (RMS-3): incomplete, awaiting review, + rejected, accepted, overdue, plus the disclosure clock. Group-scope aware, so a member is never told that + records exist which their own queue will not show them, and a count that cannot be produced degrades into + a warning rather than failing the whole dashboard. + + + + + NERIS crosswalk coverage: which of the department's own call types map to a NERIS incident type, which do + not, and which map to a code the pinned contract no longer carries. A gap report, not a statistic — every + unmapped type is a filing somebody classifies by hand on the night. + + The Records capability manifest: module/cutover state, the caller's effective Record permissions, per-app @@ -2474,7 +2665,7 @@ when the host is off or unavailable the filtered queue is returned with SearchDegraded set and the text is not applied. - + Delta cursor (plan 5.3): records modified after (Unix epoch ms; 0 = full pull), oldest first, with tombstones for cancelled, voided and deleted records so clients remove them locally. @@ -2505,7 +2696,7 @@ Finalize: validates, writes the immutable revision and attestation, emits the lifecycle events. Attested must be true. Online-only by design. - + Shared command path: flag/usable gate, field-client gate, per-record visibility, scoped idempotency replay, ETag check where the transition takes one, and the 409/400 mapping. Never last-writer-wins. @@ -2526,6 +2717,27 @@ Verifies size and SHA-256, then stores the attachment through hygiene and the scanner. 422 on checksum mismatch or rejection. + + True when the caller is the relay key or a client_credentials service account, not a member. + + + The configured grant this request runs under, or null for a user principal or an ungranted system one. + + + + A system principal with no grant for this department is refused before any read runs. Mutating + endpoints need no equivalent: their policies are never issued to a system principal at all. + + + + Group filter for the caller: the grant's groups for a system principal, the member's own otherwise. + + + Per-record visibility for the caller, routed to the grant rule for a system principal. + + + Audit purpose: the grant's stated purpose for a system principal, so a machine read is never anonymous. + Writes need the module usable: flag on and the department activated. @@ -5784,6 +5996,83 @@ Entity to DTO mapping for the v4 IncidentReports controller. Submissions are sanitized: no payload, no response body. + + + The progressive section requirements, each marked with whether the report already carries that section, + plus the contract's payload path and schema so a client can render and post the right shape. + + + + + A casualty or rescue. The entry itself is not secret — that somebody was hurt is part of the report — + but demographics, the personnel link and the injury detail are restricted, so they are dropped and named + rather than the whole row disappearing, which would misrepresent the incident. + + + + + v4 mapping for the RMS-3 surfaces: the incident-analysis filing, evidence artifacts, public-records + disclosures and the Records dashboard. Kept apart from because these + are separate aggregates with their own lifecycles, not sections of the incident report. + + + + + The vehicle row stays visible without the restricted grant — that a vehicle burned is part of the + incident — but VIN, plate and registration state identify a person's property, so those three are + dropped and named instead of the row disappearing. + + + + Evidence artifacts (RMS plan section 4.5). + + + + is only ever true for a single-artifact read. A restricted artifact's + manifest is withheld and flagged rather than omitted silently, so the caller knows there is content they + are not seeing. + + + + Public-records requests and productions (RMS plan section 4.7). + + + The redacted artifact is only carried on a single-production read; a listing would ship the whole release. + + + + The scope query as the Records queue runs it. The viewer fields are deliberately not accepted from the + client — the service sets them from the caller, so a scope can never be widened past what its author + may see. + + + + Dashboard counts and crosswalk coverage. + + + + Request-side resolution of the Record grant a system principal is acting under (Identifier Allocation + Registry section 4.4). The claim issued at sign-in only says "some grant exists"; this re-reads the + configuration against the department the request actually resolved to, so a token minted while a grant + existed stops working the moment the grant is removed or points at another department. + + Nothing here can widen access. Mutating and restricted Record policies are unreachable for a system + principal because the claim is never issued, not because this class declines to hand one out. + + + + + + Whether the caller is a non-user principal — the SMTP relay key or a client_credentials service + account. A real member never carries either marker. + + + + + The grant covering this request, or null when the caller is a user principal or has no grant for + the department. Callers treat null-with-a-system-principal as a denial, not as "unrestricted". + + Serialises a that is known to hold a UTC instant with an explicit "Z". @@ -5847,6 +6136,8 @@ Used by the SMTP Relay in hosted multi-department mode to bypass OAuth 2.0 entirely. The handler creates a ClaimsPrincipal with full permissions across all departments. Department scoping is achieved via the DepartmentId field on individual API request models. + Records (RMS) is the one exception to "full permissions": the principal gets Record_View only when a + department+purpose grant is configured, and never a mutating or restricted Record claim. @@ -11920,9 +12211,37 @@ Response Data + + + The NERIS incident-analysis filing (RMS-3): the fire/hazmat investigation posted separately from the + incident, once the destination already holds it. Its own state, revisions and idempotency key, which is why + it is a document in its own right rather than a section of the report. + + + + + False while the incident itself has no destination id. Finalizing is still allowed — the filing waits + rather than failing — but a client should say so rather than showing a submit button that defers. + + + + Restricted fields withheld because the caller lacks RecordRestricted_View. + + + VIN, plate and registration state are restricted: they identify a person's vehicle. + + + Draft save; every list replaces the draft rows, except a null list which leaves that set alone. + A hydrated NERIS incident report: header, dispatch facts, sections, provenance, validation issues, sanitized submission history. + + Restricted fields withheld from this response because the caller lacks RecordRestricted_View. + + + The analysis filing for this incident, when one has been started. + Provenance for one prefilled value (plan 4.2): where it came from, what it was, what it is now. @@ -11938,6 +12257,30 @@ Validate: also call the destination's validate endpoint when submission is enabled. + + One conditional section the selected incident types demand or suggest (plan section 4.2). + + + Dotted path in the pinned NERIS contract this section serializes into. + + + True blocks finalization; false is a warning the author may answer or ignore. + + + Value set the section's headline code must belong to; null when it has no coded headline. + + + Whether the report already carries a section of this kind. + + + The contract-shaped section body. + + + + A casualty or rescue. Demographics, the personnel link and the injury detail are restricted: a caller + without RecordRestricted_View gets the entry with those fields absent and named in WithheldFields. + + 409 on a stale incident report save. @@ -11971,6 +12314,13 @@ Delta cursor (plan 5.3): persist ServerTimestampMs and pass it back as since. + + + Tie-breaker for the next call, sent back as sinceId alongside since. Several records routinely + share a modified timestamp; without it the rows that fall after a page break inside one timestamp are never + delivered. + + A hydrated Record: header, working details, participants, units, attachment metadata and revision summaries. Restricted fields are withheld without RecordRestricted_View. @@ -12028,6 +12378,68 @@ Base64 chunk, at most ChunkSize bytes. + + + Whether one of the six evidence sources can produce anything for this department right now. "Unavailable + with a reason" is a real answer and is not the same as "there was no evidence", so the client renders the + source either way rather than hiding it. + + + + + An immutable evidence artifact. is only populated on the single-artifact read, + and only for a caller allowed to see that artifact's classification — a list of twenty artifacts must not + ship twenty manifests, and a restricted manifest must not ship at all to an unrestricted caller. + + + + The artifact body; null in list responses and null when the caller may not see its classification. + + + True when the manifest was withheld rather than simply not requested. + + + Capture one artifact. The server bounds the window and decides the classification; neither is a client input. + + + RmsRecordKind; Operational by default, Incident for a NERIS report. + + + Required: why this evidence is being put into an official record. + + + + A public-records request. Who asked is restricted in most jurisdictions, so the requester fields are + withheld — and named in — from a caller without RecordRestricted_View. + + + + The scope a production runs against, expressed as the same bounded query the Records queue runs. + + + + One immutable produced set. The redacted artifact itself is only returned on the single-production read, + so listing a request's productions never ships several megabytes of released content. + + + + Record and revision ids released, with their checksums at production time. + + + The redaction log: which fields were withheld and under what heading. + + + The redacted content; single-production read only. + + + RmsDisclosureState for a close: Denied, Withdrawn or Closed. + + + The Records work queues, group-scope-aware. A count that could not be produced degrades into . + + + Crosswalk gap report: an unmapped or stale local code is a filing that will need manual work. + Composite dashboard report (scalar totals, dense series, breakdowns). diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs new file mode 100644 index 00000000..993659f4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Records; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Public-records and access-to-information requests (RMS plan section 4.7, RMS-3). + /// + /// For a public agency a records request is a statutory obligation with a clock, so it is tracked as a record + /// with a due date rather than an export button: log the request, settle the scope, produce a redacted + /// immutable artifact with a produced-set snapshot, and release. Every action needs + /// RecordDisclosure_Update — including the reads, because the queue names who asked for what. + /// + /// + [Area("User")] + [Authorize(Policy = ResgridResources.RecordDisclosure_Update)] + public class DisclosuresController : SecureBaseController + { + private readonly IRecordsDisclosureService _disclosures; + private readonly IRecordsCutoverService _cutoverService; + private readonly IDepartmentsService _departmentsService; + private readonly IStringLocalizer _localizer; + + public DisclosuresController(IRecordsDisclosureService disclosures, IRecordsCutoverService cutoverService, IDepartmentsService departmentsService, + IStringLocalizer localizer) + { + _disclosures = disclosures; + _cutoverService = cutoverService; + _departmentsService = departmentsService; + _localizer = localizer; + } + + #region Queue + + [HttpGet] + public async Task Index(int? state) + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return NotFound(); + + var states = state.HasValue ? new List { (RmsDisclosureState)state.Value } : null; + var model = new DisclosureIndexView + { + ModuleState = moduleState, + Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), + StateFilter = state, + States = StateItems(), + RedactionProfiles = ProfileItems(), + CanViewRestricted = ClaimsAuthorizationHelper.CanViewRestrictedRecords(), + PersonnelNames = await PersonnelNamesAsync() + }; + model.Personnel = model.PersonnelNames.OrderBy(kvp => kvp.Value).Select(kvp => new SelectListItem { Value = kvp.Key, Text = kvp.Value }).ToList(); + + if (moduleState.RecordsUsable) + model.Requests = await _disclosures.QueryAsync(DepartmentId, states, 0, 200); + + if (TempData["RecordsMessage"] is string message) + model.Message = message; + if (TempData["RecordsError"] is string error) + model.ErrorMessage = error; + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(string requesterName, string requesterOrganization, string requesterContact, DateTime? receivedOn, + DateTime? statutoryDueOn, string jurisdictionProfile, string scopeNarrative, string assignedToUserId, string redactionProfile, CancellationToken cancellationToken) + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.RecordsUsable) + return NotFound(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + try + { + var created = await _disclosures.CreateRequestAsync(DepartmentId, UserId, new RmsDisclosureRequest + { + RequesterName = requesterName, + RequesterOrganization = requesterOrganization, + RequesterContact = requesterContact, + ReceivedOn = ToUtc(receivedOn, department) ?? DateTime.UtcNow, + StatutoryDueOn = ToUtc(statutoryDueOn, department), + JurisdictionProfile = jurisdictionProfile, + ScopeNarrative = scopeNarrative, + AssignedToUserId = assignedToUserId, + RedactionProfile = redactionProfile + }, cancellationToken); + + TempData["RecordsMessage"] = _localizer["DisclosureCreated"].Value; + return RedirectToAction("Details", new { id = created.RmsDisclosureRequestId }); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + TempData["RecordsError"] = ex.Message; + return RedirectToAction("Index"); + } + } + + #endregion + + #region Request + + [HttpGet] + public async Task Details(string id) + { + var model = await BuildDetailAsync(id); + if (model == null) + return NotFound(); + + if (TempData["RecordsMessage"] is string message) + model.Message = message; + if (TempData["RecordsError"] is string error) + model.ErrorMessage = error; + return View(model); + } + + /// Saves the scope; refused once a production exists, because the scope is what was produced against. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SaveScope(DisclosureScopeForm scope, CancellationToken cancellationToken) + { + if (scope == null || string.IsNullOrWhiteSpace(scope.RequestId)) + return NotFound(); + if (await _disclosures.GetAsync(DepartmentId, scope.RequestId) == null) + return NotFound(); + + try + { + await _disclosures.SaveScopeAsync(DepartmentId, UserId, scope.RequestId, scope.ScopeNarrative, new RmsRecordQuery + { + States = scope.States != null && scope.States.Count > 0 ? scope.States.ToList() : null, + DefinitionKey = string.IsNullOrWhiteSpace(scope.DefinitionKey) ? null : scope.DefinitionKey, + Year = scope.Year, + CallId = scope.CallId, + IncludeLegacy = scope.IncludeLegacy, + Take = 200 + }, scope.RedactionProfile, cancellationToken); + + TempData["RecordsMessage"] = _localizer["DisclosureScopeSaved"].Value; + return RedirectToAction("Details", new { id = scope.RequestId }); + } + catch (Exception ex) when (ex is ArgumentException || ex is RecordTransitionException || ex is InvalidOperationException) + { + return await DetailsWithErrorAsync(scope.RequestId, ex.Message); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Produce(string id, string redactionProfile, CancellationToken cancellationToken) + { + if (await _disclosures.GetAsync(DepartmentId, id) == null) + return NotFound(); + + try + { + await _disclosures.ProduceAsync(DepartmentId, UserId, id, redactionProfile, cancellationToken); + TempData["RecordsMessage"] = _localizer["DisclosureProduced"].Value; + return RedirectToAction("Details", new { id }); + } + catch (Exception ex) when (ex is ArgumentException || ex is RecordTransitionException || ex is InvalidOperationException) + { + return await DetailsWithErrorAsync(id, ex.Message); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Release(string id, string productionId, CancellationToken cancellationToken) + { + if (await LoadProductionAsync(id, productionId) == null) + return NotFound(); + + try + { + await _disclosures.ReleaseAsync(DepartmentId, UserId, productionId, cancellationToken); + TempData["RecordsMessage"] = _localizer["DisclosureReleased"].Value; + return RedirectToAction("Details", new { id }); + } + catch (Exception ex) when (ex is ArgumentException || ex is RecordTransitionException || ex is InvalidOperationException) + { + return await DetailsWithErrorAsync(id, ex.Message); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Close(string id, int disposition, string reason, CancellationToken cancellationToken) + { + if (await _disclosures.GetAsync(DepartmentId, id) == null) + return NotFound(); + if (string.IsNullOrWhiteSpace(reason)) + return await DetailsWithErrorAsync(id, _localizer["DisclosureReasonRequired"]); + + try + { + await _disclosures.CloseAsync(DepartmentId, UserId, id, (RmsDisclosureState)disposition, reason, cancellationToken); + TempData["RecordsMessage"] = _localizer["DisclosureClosed"].Value; + return RedirectToAction("Details", new { id }); + } + catch (Exception ex) when (ex is ArgumentException || ex is RecordTransitionException || ex is InvalidOperationException) + { + return await DetailsWithErrorAsync(id, ex.Message); + } + } + + /// The produced artifact as it was released, so a department can hand over exactly what it recorded handing over. + [HttpGet] + public async Task Download(string id, string productionId) + { + var production = await LoadProductionAsync(id, productionId); + if (production == null || string.IsNullOrWhiteSpace(production.ArtifactJson)) + return NotFound(); + + var request = await _disclosures.GetAsync(DepartmentId, id); + var name = $"{request?.RequestNumber ?? "disclosure"}-{production.ProductionNumber}.json"; + return File(Encoding.UTF8.GetBytes(production.ArtifactJson), "application/json", name); + } + + /// Re-computes the checksum from the stored artifact; a mismatch means it was tampered with. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Verify(string id, string productionId) + { + if (await LoadProductionAsync(id, productionId) == null) + return NotFound(); + + var intact = await _disclosures.VerifyProductionAsync(DepartmentId, productionId); + TempData[intact ? "RecordsMessage" : "RecordsError"] = intact ? _localizer["ProductionIntact"].Value : _localizer["ProductionTampered"].Value; + return RedirectToAction("Details", new { id }); + } + + #endregion + + #region Helpers + + private async Task LoadProductionAsync(string requestId, string productionId) + { + if (string.IsNullOrWhiteSpace(requestId) || string.IsNullOrWhiteSpace(productionId)) + return null; + if (await _disclosures.GetAsync(DepartmentId, requestId) == null) + return null; + + var productions = await _disclosures.GetProductionsAsync(DepartmentId, requestId); + return productions.FirstOrDefault(p => string.Equals(p.RmsDisclosureProductionId, productionId, StringComparison.Ordinal)); + } + + private async Task BuildDetailAsync(string id) + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return null; + + var request = await _disclosures.GetAsync(DepartmentId, id); + if (request == null) + return null; + + var model = new DisclosureDetailView + { + Request = request, + Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), + Productions = await _disclosures.GetProductionsAsync(DepartmentId, id), + PersonnelNames = await PersonnelNamesAsync(), + CanViewRestricted = ClaimsAuthorizationHelper.CanViewRestrictedRecords(), + RedactionProfiles = ProfileItems(), + RecordStates = Enum.GetValues(typeof(RmsRecordState)).Cast() + .Select(s => new SelectListItem { Value = ((int)s).ToString(), Text = s.ToString() }).ToList(), + Dispositions = new List + { + new SelectListItem { Value = ((int)RmsDisclosureState.Denied).ToString(), Text = RmsDisclosureState.Denied.ToString() }, + new SelectListItem { Value = ((int)RmsDisclosureState.Withdrawn).ToString(), Text = RmsDisclosureState.Withdrawn.ToString() }, + new SelectListItem { Value = ((int)RmsDisclosureState.Closed).ToString(), Text = RmsDisclosureState.Closed.ToString() } + }, + DefinitionKeys = RmsDefinitionKeys.LockedTypes.Keys.OrderBy(k => k).Select(k => new SelectListItem { Value = k, Text = k }).ToList() + }; + + model.Scope = new DisclosureScopeForm + { + RequestId = id, + ScopeNarrative = request.ScopeNarrative, + RedactionProfile = request.RedactionProfile ?? RmsRedactionProfiles.Standard + }; + + // The stored scope is the same RmsRecordQuery the queue runs; re-render it so an officer edits what + // will actually be produced against rather than a paraphrase of it. + if (!string.IsNullOrWhiteSpace(request.ScopeQueryJson)) + { + var stored = JsonConvert.DeserializeObject(request.ScopeQueryJson); + if (stored != null) + { + model.Scope.DefinitionKey = stored.DefinitionKey; + model.Scope.Year = stored.Year; + model.Scope.CallId = stored.CallId; + model.Scope.IncludeLegacy = stored.IncludeLegacy; + model.Scope.States = stored.States?.ToList() ?? new List(); + } + + try + { + model.Preview = await _disclosures.PreviewScopeAsync(DepartmentId, UserId, id); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + // A scope that no longer resolves is a thing the officer needs told, not a broken page. + model.ErrorMessage = ex.Message; + } + } + + return model; + } + + private async Task DetailsWithErrorAsync(string id, string error) + { + var model = await BuildDetailAsync(id); + if (model == null) + return NotFound(); + model.ErrorMessage = error; + return View("Details", model); + } + + private static List StateItems() + { + return Enum.GetValues(typeof(RmsDisclosureState)).Cast() + .Select(s => new SelectListItem { Value = ((int)s).ToString(), Text = s.ToString() }).ToList(); + } + + private static List ProfileItems() + { + return new List + { + new SelectListItem { Value = RmsRedactionProfiles.Standard, Text = RmsRedactionProfiles.Standard }, + new SelectListItem { Value = RmsRedactionProfiles.NoPersonalIdentifiers, Text = RmsRedactionProfiles.NoPersonalIdentifiers }, + new SelectListItem { Value = RmsRedactionProfiles.FullDisclosure, Text = RmsRedactionProfiles.FullDisclosure } + }; + } + + private static DateTime? ToUtc(DateTime? local, Department department) + { + if (!local.HasValue || local.Value == DateTime.MinValue) + return null; + if (department == null || string.IsNullOrWhiteSpace(department.TimeZone)) + return DateTime.SpecifyKind(local.Value, DateTimeKind.Utc); + + return DateTimeHelpers.ConvertToUtc(local.Value, department.TimeZone, true); + } + + private async Task> PersonnelNamesAsync() + { + var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var name in names ?? new List()) + { + if (!string.IsNullOrWhiteSpace(name.UserId)) + map[name.UserId] = name.Name; + } + return map; + } + + #endregion + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 0f600069..fcf7dd21 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; @@ -76,6 +76,7 @@ public class DispatchController : SecureBaseController private readonly IDispatchRecommendationService _dispatchRecommendationService; private readonly IFeatureToggleService _featureToggleService; private readonly IProtectedReadService _protectedReadService; + private readonly IRecordsCutoverService _recordsCutoverService; public DispatchController(IDepartmentsService departmentsService, IUsersService usersService, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, ICommunicationService communicationService, IQueueService queueService, @@ -89,7 +90,7 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService ICallDispatchStatusService callDispatchStatusService, IModerationService moderationService, IStringLocalizer dispatchLocalizer, IStringLocalizer commonLocalizer, IDispatchRecommendationService dispatchRecommendationService, IFeatureToggleService featureToggleService, - IProtectedReadService protectedReadService) + IProtectedReadService protectedReadService, IRecordsCutoverService recordsCutoverService) { _departmentsService = departmentsService; _usersService = usersService; @@ -124,6 +125,7 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService _dispatchRecommendationService = dispatchRecommendationService; _featureToggleService = featureToggleService; _protectedReadService = protectedReadService; + _recordsCutoverService = recordsCutoverService; } #endregion Private Members and Constructors @@ -3348,6 +3350,10 @@ private async Task FillViewCallView(ViewCallView model) model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(model.Department.DepartmentId); model.CallPriorities = model.CallPriority.ToSelectList(); + + // The Incident Report control posts to IncidentReports/Start, which 404s when Records is not usable for + // the department; the button has to know that as well as whether the member may create a record. + model.RecordsUsable = (await _recordsCutoverService.GetModuleStateAsync(model.Department.DepartmentId)).RecordsUsable; model.UnGroupedUsers = new List(); model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(model.Call.DepartmentId, model.Call.CallId)).OrderBy(y => y.Timestamp).ToList(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs b/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs new file mode 100644 index 00000000..6cea538c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/IncidentAnalysisController.cs @@ -0,0 +1,463 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Records; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// The NERIS incident-analysis filing (RMS-3): the fire/hazmat investigation posted separately from the + /// incident, once the destination already holds it. + /// + /// It has its own screens rather than a tab on the incident report because it is a second submittable artifact + /// with its own lifecycle: an analysis that will not validate must never block the report, and an analysis may + /// be finalized before its incident is filed — the submission simply waits for the incident's NERIS id. + /// Visibility is inherited from the incident, so nobody reaches an analysis for a report they cannot open. + /// + /// + [Area("User")] + public class IncidentAnalysisController : SecureBaseController + { + private readonly IIncidentAnalysisService _analysis; + private readonly IIncidentReportsService _incidentReports; + private readonly IRecordsCutoverService _cutoverService; + private readonly IRecordsAuthorizationService _recordsAuthorizationService; + private readonly IDepartmentsService _departmentsService; + private readonly INerisProfileService _neris; + private readonly IStringLocalizer _localizer; + + public IncidentAnalysisController(IIncidentAnalysisService analysis, IIncidentReportsService incidentReports, IRecordsCutoverService cutoverService, + IRecordsAuthorizationService recordsAuthorizationService, IDepartmentsService departmentsService, INerisProfileService neris, + IStringLocalizer localizer) + { + _analysis = analysis; + _incidentReports = incidentReports; + _cutoverService = cutoverService; + _recordsAuthorizationService = recordsAuthorizationService; + _departmentsService = departmentsService; + _neris = neris; + _localizer = localizer; + } + + #region Reads + + [HttpGet] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task Details(string id) + { + var model = await BuildDetailAsync(id); + if (model == null) + return NotFound(); + + if (TempData["RecordsMessage"] is string message) + model.Message = message; + if (TempData["RecordsError"] is string error) + model.ErrorMessage = error; + return View(model); + } + + #endregion + + #region Authoring + + /// Starts (or opens) the analysis for a report; one per report. + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task Start(string reportId, CancellationToken cancellationToken) + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.RecordsUsable) + return NotFound(); + if (string.IsNullOrWhiteSpace(reportId) || !await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, reportId, DepartmentId)) + return NotFound(); + + try + { + var aggregate = await _analysis.StartForReportAsync(DepartmentId, UserId, reportId, RmsOriginClient.Web, cancellationToken); + return RedirectToAction("Edit", new { id = aggregate.Analysis.RmsIncidentAnalysisId }); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + TempData["RecordsError"] = ex.Message; + return RedirectToAction("Details", "IncidentReports", new { id = reportId }); + } + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task Edit(string id) + { + var aggregate = await LoadAuthorizedAsync(id); + if (aggregate == null) + return NotFound(); + if (!CanEdit(aggregate)) + return Unauthorized(); + + var model = await BuildEditAsync(aggregate); + if (TempData["RecordsMessage"] is string message) + model.Message = message; + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task Edit(IncidentAnalysisEditView model, CancellationToken cancellationToken) + { + var aggregate = await LoadAuthorizedAsync(model.AnalysisId); + if (aggregate == null) + return NotFound(); + if (!CanEdit(aggregate)) + return Unauthorized(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + try + { + await _analysis.SaveDraftAsync(DepartmentId, UserId, model.AnalysisId, model.RowVersion, BuildInput(model, department), + ClaimsAuthorizationHelper.CanViewRestrictedRecords(), cancellationToken); + + if (model.ValidateAfterSave) + { + var issues = await _analysis.ValidateAsync(DepartmentId, model.AnalysisId, cancellationToken); + TempData["RecordsMessage"] = issues.Count == 0 ? _localizer["NoValidationIssues"].Value : string.Format(_localizer["ValidationRun"].Value, issues.Count); + return RedirectToAction(issues.Any(i => i.Severity == (int)RmsValidationSeverity.Error) ? "Edit" : "Details", new { id = model.AnalysisId }); + } + + TempData["RecordsMessage"] = _localizer["RecordSaved"].Value; + return RedirectToAction("Details", new { id = model.AnalysisId }); + } + catch (RecordConcurrencyException) + { + return await EditWithErrorAsync(aggregate, _localizer["ConcurrencyError"]); + } + catch (Exception ex) when (ex is ArgumentException || ex is RecordTransitionException || ex is InvalidOperationException) + { + return await EditWithErrorAsync(aggregate, ex.Message); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task Validate(string id, CancellationToken cancellationToken) + { + if (await LoadAuthorizedAsync(id) == null) + return NotFound(); + + try + { + var issues = await _analysis.ValidateAsync(DepartmentId, id, cancellationToken); + TempData["RecordsMessage"] = issues.Count == 0 ? _localizer["NoValidationIssues"].Value : string.Format(_localizer["ValidationRun"].Value, issues.Count); + return RedirectToAction("Details", new { id }); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + return await DetailsWithErrorAsync(id, ex.Message); + } + } + + #endregion + + #region Lifecycle + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Finalize)] + public Task Finalize(string id, long rowVersion, CancellationToken cancellationToken) + { + return TransitionAsync(id, () => _analysis.FinalizeAsync(DepartmentId, UserId, id, rowVersion, cancellationToken)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Submit)] + public async Task Submit(string id, CancellationToken cancellationToken) + { + var result = await TransitionAsync(id, () => _analysis.QueueSubmissionAsync(DepartmentId, UserId, id, cancellationToken)); + if (result is RedirectToActionResult) + TempData["RecordsMessage"] = _localizer["SubmissionQueued"].Value; + return result; + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Void)] + public Task Void(string id, string reasonCode, string reasonText, CancellationToken cancellationToken) + { + return TransitionAsync(id, () => _analysis.VoidAsync(DepartmentId, UserId, id, reasonCode, reasonText, cancellationToken)); + } + + private async Task TransitionAsync(string id, Func> action) + { + if (await LoadAuthorizedAsync(id) == null) + return NotFound(); + + try + { + await action(); + return RedirectToAction("Details", new { id }); + } + catch (RecordConcurrencyException) + { + return await DetailsWithErrorAsync(id, _localizer["ConcurrencyError"]); + } + catch (IncidentReportValidationException ex) + { + return await DetailsWithErrorAsync(id, _localizer["ValidationBlocked"] + " " + ex.Message); + } + catch (Exception ex) when (ex is ArgumentException || ex is RecordTransitionException || ex is InvalidOperationException) + { + return await DetailsWithErrorAsync(id, ex.Message); + } + } + + #endregion + + #region Helpers + + /// An analysis is only as visible as the incident it belongs to. + private async Task LoadAuthorizedAsync(string id, bool includeHistory = false) + { + if (string.IsNullOrWhiteSpace(id)) + return null; + + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return null; + + var aggregate = await _analysis.GetAsync(DepartmentId, id, includeHistory); + if (aggregate?.Analysis == null) + return null; + + if (!await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, aggregate.Analysis.IncidentReportId, DepartmentId)) + { + await _incidentReports.RecordAccessAsync(DepartmentId, UserId, aggregate.Analysis.IncidentReportId, null, RmsAccessAuditAction.Denied, null, IpAddressHelper.GetRequestIP(Request, true)); + return null; + } + + return aggregate; + } + + private bool CanEdit(IncidentAnalysisAggregate aggregate) + { + if (!ClaimsAuthorizationHelper.CanCreateRecord()) + return false; + + var analysis = aggregate.Analysis; + return ClaimsAuthorizationHelper.IsUserDepartmentAdmin() + || string.Equals(analysis.OwnerUserId, UserId, StringComparison.OrdinalIgnoreCase) + || string.Equals(analysis.AuthorUserId, UserId, StringComparison.OrdinalIgnoreCase); + } + + private async Task BuildDetailAsync(string id) + { + var aggregate = await LoadAuthorizedAsync(id, true); + if (aggregate == null) + return null; + + var report = aggregate.Report; + return new IncidentAnalysisDetailView + { + Aggregate = aggregate, + Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), + SubmissionEnabled = await _neris.IsSubmissionEnabledAsync(DepartmentId), + Reference = report?.RecordNumber ?? report?.DraftReference, + PersonnelNames = await PersonnelNamesAsync(), + CanEdit = CanEdit(aggregate), + CanFinalize = ClaimsAuthorizationHelper.CanFinalizeRecords(), + CanSubmit = ClaimsAuthorizationHelper.CanSubmitRecords(), + CanVoid = ClaimsAuthorizationHelper.CanVoidRecords(), + CanViewRestricted = ClaimsAuthorizationHelper.CanViewRestrictedRecords(), + IsDepartmentAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() + }; + } + + private async Task DetailsWithErrorAsync(string id, string error) + { + var model = await BuildDetailAsync(id); + if (model == null) + return NotFound(); + model.ErrorMessage = error; + return View("Details", model); + } + + private async Task EditWithErrorAsync(IncidentAnalysisAggregate aggregate, string error) + { + var model = await BuildEditAsync(aggregate); + model.ErrorMessage = error; + return View("Edit", model); + } + + private async Task BuildEditAsync(IncidentAnalysisAggregate aggregate) + { + var analysis = aggregate.Analysis; + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var model = new IncidentAnalysisEditView + { + AnalysisId = analysis.RmsIncidentAnalysisId, + ReportId = analysis.IncidentReportId, + RowVersion = analysis.RowVersion, + Reference = aggregate.Report?.RecordNumber ?? aggregate.Report?.DraftReference, + State = aggregate.State, + GeneralCause = analysis.GeneralCause, + InvestigationTypes = Split(analysis.InvestigationTypesCsv), + CurrencyCode = analysis.CurrencyCode, + CanEditRestricted = ClaimsAuthorizationHelper.CanViewRestrictedRecords(), + Department = department + }; + + // Only the analysis half of the progressive rules belongs here; the incident's own sections stay on the report. + var requirements = (await _incidentReports.GetSectionRequirementsAsync(DepartmentId, analysis.IncidentReportId)) + .Where(r => RmsIncidentModuleCatalog.Get(r.Kind)?.BelongsToAnalysis == true).ToList(); + var kinds = requirements.Select(r => r.Kind).ToList(); + foreach (var kind in aggregate.Modules.Select(m => (RmsIncidentModuleKind)m.ModuleKind).Distinct()) + { + if (!kinds.Contains(kind)) + kinds.Add(kind); + } + + foreach (var kind in kinds) + { + var requirement = requirements.FirstOrDefault(r => r.Kind == kind); + var descriptor = RmsIncidentModuleCatalog.Get(kind); + var rows = aggregate.Modules.Where(m => m.ModuleKind == (int)kind).OrderBy(m => m.Ordinal).ToList(); + + model.Sections.Add(new IncidentSectionView + { + Kind = kind, + Required = requirement?.Required ?? false, + Reason = requirement?.Reason ?? _localizer["SectionNoLongerApplies"].Value, + Present = rows.Count > 0, + IsCollection = descriptor?.IsCollection ?? false, + PayloadPath = descriptor?.PayloadPath, + SchemaName = descriptor?.SchemaName, + PrimaryCodes = Codes(requirement?.PrimaryCodeSet), + SecondaryCodes = Codes(requirement?.SecondaryCodeSet) + }); + + foreach (var row in rows) + { + model.Modules.Add(new IncidentModuleRow + { + Kind = (int)kind, Included = true, PrimaryCode = row.PrimaryCode, SecondaryCode = row.SecondaryCode, Quantity = row.Quantity, + QuantityUnit = row.QuantityUnit, OccurredOn = row.OccurredOn?.TimeConverter(department), DetailJson = row.DetailJson + }); + } + + if (rows.Count == 0 || (descriptor?.IsCollection ?? false)) + model.Modules.Add(new IncidentModuleRow { Kind = (int)kind, Included = false }); + } + + model.Properties = aggregate.Properties.OrderBy(p => p.Ordinal).Select(p => new IncidentPropertyRow + { + Included = true, LocationUse = p.LocationUse, ConstructionType = p.ConstructionType, Foundation = p.Foundation, ExteriorFinish = p.ExteriorFinish, + RoofMaterial = p.RoofMaterial, StoriesAboveGrade = p.StoriesAboveGrade, StoriesBelowGrade = p.StoriesBelowGrade, YearBuilt = p.YearBuilt, + Vacancy = p.Vacancy, DamageType = p.DamageType, FireSpread = p.FireSpread, EstimatedValue = p.EstimatedValue, EstimatedLoss = p.EstimatedLoss, + ContentsValue = p.ContentsValue, ContentsLoss = p.ContentsLoss, DetailJson = p.DetailJson + }).ToList(); + + model.Vehicles = aggregate.Vehicles.OrderBy(v => v.Ordinal).Select(v => new IncidentVehicleRow + { + Included = true, VehicleKind = v.VehicleKind, Make = v.Make, Model = v.Model, ModelYear = v.ModelYear, BodyStyle = v.BodyStyle, Powertrain = v.Powertrain, + DamageType = v.DamageType, Vin = v.Vin, LicensePlate = v.LicensePlate, LicenseState = v.LicenseState, WasOccupied = v.WasOccupied, + EstimatedValue = v.EstimatedValue, EstimatedLoss = v.EstimatedLoss, DetailJson = v.DetailJson + }).ToList(); + + model.GeneralCauses = Codes("fire_cause_general"); + model.InvestigationTypeCodes = Codes("fire_invest"); + model.LocationUses = Codes("location_use"); + model.ConstructionTypes = Codes("construction"); + model.Vacancies = Codes("vacancy"); + model.BuildingDamageTypes = Codes("fire_bldg_damage"); + model.FireSpreads = Codes("fire_spread"); + model.VehicleMakes = Codes("auto_make"); + model.VehicleBodyStyles = Codes("auto_body_style"); + model.Powertrains = Codes("powertrain"); + model.VehicleDamageTypes = Codes("vehicle_damage"); + return model; + } + + private IncidentAnalysisDraftInput BuildInput(IncidentAnalysisEditView model, Department department) + { + return new IncidentAnalysisDraftInput + { + GeneralCause = model.GeneralCause, + InvestigationTypes = model.InvestigationTypes ?? new List(), + CurrencyCode = model.CurrencyCode, + Modules = (model.Modules ?? new List()).Where(m => m.Included && m.Kind > 0).Select(m => new IncidentModuleInput + { + Kind = (RmsIncidentModuleKind)m.Kind, PrimaryCode = m.PrimaryCode, SecondaryCode = m.SecondaryCode, Quantity = m.Quantity, + QuantityUnit = m.QuantityUnit, OccurredOn = ToUtc(m.OccurredOn, department), DetailJson = m.DetailJson + }).ToList(), + Properties = (model.Properties ?? new List()).Where(p => p.Included).Select(p => new IncidentPropertyInput + { + LocationUse = p.LocationUse, ConstructionType = p.ConstructionType, Foundation = p.Foundation, ExteriorFinish = p.ExteriorFinish, + RoofMaterial = p.RoofMaterial, StoriesAboveGrade = p.StoriesAboveGrade, StoriesBelowGrade = p.StoriesBelowGrade, YearBuilt = p.YearBuilt, + Vacancy = p.Vacancy, DamageType = p.DamageType, FireSpread = p.FireSpread, EstimatedValue = p.EstimatedValue, EstimatedLoss = p.EstimatedLoss, + ContentsValue = p.ContentsValue, ContentsLoss = p.ContentsLoss, DetailJson = p.DetailJson + }).ToList(), + Vehicles = (model.Vehicles ?? new List()).Where(v => v.Included).Select(v => new IncidentVehicleInput + { + VehicleKind = v.VehicleKind, Make = v.Make, Model = v.Model, ModelYear = v.ModelYear, BodyStyle = v.BodyStyle, Powertrain = v.Powertrain, + DamageType = v.DamageType, Vin = v.Vin, LicensePlate = v.LicensePlate, LicenseState = v.LicenseState, WasOccupied = v.WasOccupied, + EstimatedValue = v.EstimatedValue, EstimatedLoss = v.EstimatedLoss, DetailJson = v.DetailJson + }).ToList(), + OriginClient = RmsOriginClient.Web + }; + } + + private List Codes(string setKey) + { + if (string.IsNullOrWhiteSpace(setKey)) + return new List(); + + var set = _neris.GetValueSet(setKey); + return (set?.Codes ?? new List()).Select(c => new SelectListItem { Value = c, Text = c.Replace("||", " / ").Replace('_', ' ') }).ToList(); + } + + private static List Split(string csv) + { + return string.IsNullOrWhiteSpace(csv) + ? new List() + : csv.Split(',').Select(c => c.Trim()).Where(c => c.Length > 0).ToList(); + } + + private static DateTime? ToUtc(DateTime? local, Department department) + { + if (!local.HasValue || local.Value == DateTime.MinValue) + return null; + if (department == null || string.IsNullOrWhiteSpace(department.TimeZone)) + return DateTime.SpecifyKind(local.Value, DateTimeKind.Utc); + + return DateTimeHelpers.ConvertToUtc(local.Value, department.TimeZone, true); + } + + private async Task> PersonnelNamesAsync() + { + var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var name in names ?? new List()) + { + if (!string.IsNullOrWhiteSpace(name.UserId)) + map[name.UserId] = name.Name; + } + return map; + } + + #endregion + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs index ee5d3b63..6a426fde 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -40,11 +40,14 @@ public class IncidentReportsController : SecureBaseController private readonly ICallsService _callsService; private readonly INerisProfileService _neris; private readonly IRmsSubmissionsRepository _submissions; + private readonly IIncidentAnalysisService _analysis; + private readonly IRecordsEvidenceService _evidence; private readonly IStringLocalizer _localizer; public IncidentReportsController(IIncidentReportsService incidentReports, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService, ICallsService callsService, - INerisProfileService neris, IRmsSubmissionsRepository submissions, IStringLocalizer localizer) + INerisProfileService neris, IRmsSubmissionsRepository submissions, IIncidentAnalysisService analysis, IRecordsEvidenceService evidence, + IStringLocalizer localizer) { _incidentReports = incidentReports; _cutoverService = cutoverService; @@ -55,6 +58,8 @@ public IncidentReportsController(IIncidentReportsService incidentReports, IRecor _callsService = callsService; _neris = neris; _submissions = submissions; + _analysis = analysis; + _evidence = evidence; _localizer = localizer; } @@ -91,23 +96,21 @@ public async Task Index(int? year, string state, int page = 1) return View(model); model.Years = (await _incidentReports.GetYearsAsync(DepartmentId)).Select(y => new SelectListItem { Value = y.ToString(), Text = y.ToString() }).ToList(); + // Per-record visibility (plan 5.7.1) is applied by the query, not by filtering the page afterwards: a page + // filtered after paging reported its own length as the total, so a member with scoped visibility saw a + // single page of links and could never reach anything older. + var visibleGroups = await _recordsAuthorizationService.GetVisibleGroupIdsAsync(UserId, DepartmentId); var query = new RmsIncidentReportQuery { Year = year, States = int.TryParse(state, out var stateValue) ? new List { stateValue } : null, + VisibleGroupIds = visibleGroups, + ViewerUserId = UserId, Skip = (model.Page - 1) * model.PageSize, Take = model.PageSize }; - var visibleGroups = await _recordsAuthorizationService.GetVisibleGroupIdsAsync(UserId, DepartmentId); - var reports = await _incidentReports.QueryAsync(DepartmentId, query); - // Per-record visibility (plan 5.7.1): the queue never lists a report the viewer cannot open. - model.Reports = new List(); - foreach (var report in reports) - { - if (visibleGroups == null || await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, report.RmsIncidentReportId, DepartmentId)) - model.Reports.Add(report); - } - model.Total = visibleGroups == null ? await _incidentReports.CountAsync(DepartmentId, query) : model.Reports.Count + query.Skip; + model.Reports = (await _incidentReports.QueryAsync(DepartmentId, query)) ?? new List(); + model.Total = await _incidentReports.CountAsync(DepartmentId, query); model.PersonnelNames = await PersonnelNamesAsync(); if (ClaimsAuthorizationHelper.CanCreateRecord()) @@ -231,7 +234,7 @@ public async Task Edit(IncidentReportEditView model, Cancellation var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); try { - await _incidentReports.SaveDraftAsync(DepartmentId, UserId, model.ReportId, model.RowVersion, BuildInput(model, department), cancellationToken); + await _incidentReports.SaveDraftAsync(DepartmentId, UserId, model.ReportId, model.RowVersion, BuildInput(model, department), ClaimsAuthorizationHelper.CanViewRestrictedRecords(), cancellationToken); if (model.ValidateAfterSave) { var issues = await _incidentReports.ValidateAsync(DepartmentId, model.ReportId, true, cancellationToken); @@ -385,6 +388,74 @@ private async Task TransitionAsync(string id, Func + /// Captures one evidence artifact against the report (RMS plan section 4.5). The reason is required and is + /// written to the access audit: putting another subsystem's data into an official record is an act somebody + /// has to own. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task CaptureEvidence(string id, int kind, string captureReason, CancellationToken cancellationToken) + { + var aggregate = await LoadAuthorizedAsync(id); + if (aggregate == null) + return NotFound(); + if (!CanEditReport(aggregate.Report)) + return Unauthorized(); + if (string.IsNullOrWhiteSpace(captureReason)) + return await DetailsWithErrorAsync(id, _localizer["EvidenceReasonRequired"]); + + try + { + var artifact = await _evidence.CaptureAsync(new RecordEvidenceCaptureRequest + { + DepartmentId = DepartmentId, + RecordId = id, + RecordKind = RmsRecordKind.IncidentReport, + Kind = (RmsEvidenceKind)kind, + CaptureReason = captureReason, + CallId = aggregate.Report.CallId, + CoverageStart = aggregate.Report.CallCreatedOn, + CoverageEnd = aggregate.Report.IncidentClearedOn, + UnitIds = aggregate.Units.Where(u => u.UnitId.HasValue).Select(u => u.UnitId.Value).ToList(), + CapturedByUserId = UserId, + OriginClient = RmsOriginClient.Web + }, ClaimsAuthorizationHelper.CanViewRestrictedRecords(), cancellationToken); + + TempData["RecordsMessage"] = artifact == null ? _localizer["EvidenceUnavailable"].Value : _localizer["EvidenceCaptured"].Value; + return RedirectToAction("Details", new { id }); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException || ex is UnauthorizedAccessException) + { + return await DetailsWithErrorAsync(id, ex.Message); + } + } + + /// The stored manifest of one artifact, for an author checking what was actually captured. + [HttpGet] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task EvidenceManifest(string id, string artifactId) + { + if (await LoadAuthorizedAsync(id) == null) + return NotFound(); + + var artifact = await _evidence.GetAsync(DepartmentId, artifactId); + if (artifact == null || !string.Equals(artifact.RecordId, id, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(artifact.ManifestJson)) + return NotFound(); + + // Classification is decided at capture and never widened; a restricted manifest needs the restricted grant. + if (artifact.Classification != (int)RmsEvidenceClassification.Unrestricted && !ClaimsAuthorizationHelper.CanViewRestrictedRecords()) + return Unauthorized(); + + await _incidentReports.RecordAccessAsync(DepartmentId, UserId, id, artifact.RevisionId, RmsAccessAuditAction.Export, $"Evidence {artifact.RmsEvidenceArtifactId}", IpAddressHelper.GetRequestIP(Request, true)); + return File(Encoding.UTF8.GetBytes(artifact.ManifestJson), "application/json", $"evidence-{artifact.RmsEvidenceArtifactId.Substring(0, 8)}.json"); + } + + #endregion + #region Settings (NERIS profile + crosswalk) [HttpGet] @@ -419,6 +490,7 @@ public async Task Settings(NerisSettingsView model, CancellationT try { var profile = await _neris.GetProfileAsync(DepartmentId) ?? new RmsNerisProfile { DepartmentId = DepartmentId }; + var previousGrantType = profile.GrantType; profile.NerisEntityId = string.IsNullOrWhiteSpace(model.NerisEntityId) ? null : model.NerisEntityId.Trim().ToUpperInvariant(); profile.EntityName = string.IsNullOrWhiteSpace(model.EntityName) ? null : model.EntityName.Trim(); profile.Environment = string.IsNullOrWhiteSpace(model.Environment) ? NerisEnvironments.Production : model.Environment; @@ -432,6 +504,12 @@ public async Task Settings(NerisSettingsView model, CancellationT if (!string.IsNullOrWhiteSpace(model.Password) || !string.IsNullOrWhiteSpace(model.ClientSecret) || !string.IsNullOrWhiteSpace(model.Username) || !string.IsNullOrWhiteSpace(model.ClientId)) credential = new NerisCredential { Username = model.Username?.Trim(), Password = model.Password, ClientId = model.ClientId?.Trim(), ClientSecret = model.ClientSecret }; + // The two grant types fill different slots of the stored credential, so keeping the old one across a + // change would send empty credentials on the next token request and read as a rejected credential. + if (credential == null && !string.IsNullOrWhiteSpace(previousGrantType) + && !string.Equals(previousGrantType, profile.GrantType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException(_localizer["CredentialRequiredForGrantTypeChange"]); + await _neris.SaveProfileAsync(profile, credential, UserId, cancellationToken); var existing = (await _neris.GetCrosswalksAsync(DepartmentId)).Where(c => c.SetKey == IncidentTypeSet && c.LocalSource == NerisCrosswalkSources.CallType).ToList(); @@ -503,7 +581,7 @@ private async Task BuildSettingsAsync(RecordsModuleState modu model.Crosswalk = callTypes.Select(t => new NerisCrosswalkRow { CallType = t, NerisCode = crosswalks.FirstOrDefault(c => string.Equals(c.LocalCode, t, StringComparison.OrdinalIgnoreCase))?.NerisCode }).ToList(); foreach (RmsSubmissionState state in Enum.GetValues(typeof(RmsSubmissionState))) - model.QueueCounts[state] = await _submissions.CountByStateAsync((int)state); + model.QueueCounts[state] = await _submissions.CountByStateAsync(DepartmentId, (int)state); return model; } @@ -519,8 +597,14 @@ private async Task BuildDetailAsync(string id) return null; var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId) ?? new List(); + var analysis = await _analysis.GetForReportAsync(DepartmentId, id); return new IncidentReportDetailView { + SectionRequirements = await _incidentReports.GetSectionRequirementsAsync(DepartmentId, id), + Analysis = analysis?.Analysis, + Evidence = await _evidence.GetForRecordAsync(DepartmentId, id), + EvidenceSources = await _evidence.GetSourceStatesAsync(DepartmentId), + CanViewRestricted = ClaimsAuthorizationHelper.CanViewRestrictedRecords(), Aggregate = aggregate, Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), Profile = await _neris.GetProfileAsync(DepartmentId), @@ -642,6 +726,9 @@ private async Task BuildEditAsync(IncidentReportAggregat model.Aids = aggregate.Aids.OrderBy(a => a.Ordinal).Select(a => new IncidentAidRow { Direction = a.Direction, AidType = a.AidType, CounterpartNerisId = a.CounterpartNerisId, CounterpartName = a.CounterpartName, IsNonFireDepartment = a.IsNonFireDepartment, NonFdType = a.NonFdType }).ToList(); model.Tactics = aggregate.Tactics.OrderBy(t => t.Ordinal).Select(t => new IncidentTacticRow { TacticCode = t.TacticCode, ActorUnitId = t.ActorUnitId, OccurredOn = t.OccurredOn?.TimeConverter(department) }).ToList(); + model.CanEditRestricted = ClaimsAuthorizationHelper.CanViewRestrictedRecords(); + await BuildSectionsAsync(model, aggregate); + model.IncidentTypeCodes = Codes(IncidentTypeSet); model.TacticCodes = Codes("action_tactic"); model.AidTypes = Codes("aid_type"); @@ -653,9 +740,110 @@ private async Task BuildEditAsync(IncidentReportAggregat model.ResponseModes = Codes("response_mode"); model.Stations = (await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId) ?? new List()).OrderBy(g => g.Name).Select(g => new SelectListItem { Value = g.DepartmentGroupId.ToString(), Text = g.Name }).ToList(); model.AvailableUnits = (await _unitsService.GetUnitsForDepartmentAsync(DepartmentId) ?? new List()).OrderBy(u => u.Name).Select(u => new SelectListItem { Value = u.UnitId.ToString(), Text = u.Name }).ToList(); + + // A unit that has since been removed from the department is still on this report. The form posts the rows + // it renders and the save replaces the set wholesale, so leaving it out of the list would delete the + // response the night it happened. + foreach (var missing in model.Units.Where(u => model.AvailableUnits.All(a => a.Value != u.UnitId.ToString()))) + { + var name = aggregate.Units.FirstOrDefault(x => x.UnitId == missing.UnitId)?.UnitNameSnapshot; + model.AvailableUnits.Add(new SelectListItem { Value = missing.UnitId.ToString(), Text = string.IsNullOrWhiteSpace(name) ? $"#{missing.UnitId}" : name }); + } + + model.CasualtyPersonTypes = new List + { + new SelectListItem { Value = RmsCasualtyPersonTypes.Firefighter, Text = _localizer["Firefighter"].Value }, + new SelectListItem { Value = RmsCasualtyPersonTypes.Civilian, Text = _localizer["Civilian"].Value } + }; + model.CasualtyCauses = Codes("casualty_cause"); + model.CasualtyActions = Codes("casualty_action"); + model.CasualtyTimelines = Codes("casualty_timeline"); + model.DutyTypes = Codes("duty"); + model.PpeCodes = Codes("casualty_ppe"); + model.RescueActionCodes = Codes("rescue_action"); + model.RescueImpedimentCodes = Codes("rescue_impediment"); + model.RescueModes = Codes("rescue_mode"); + model.RescuePaths = Codes("rescue_path"); + model.RescueElevations = Codes("rescue_elevation"); + model.PresenceKnownCodes = Codes("rescue_presence_known"); + model.ExposureItemTypes = Codes("exposure_item"); + model.ExposureDamageTypes = Codes("exposure_damage"); + model.DisplacementCauseCodes = Codes("displace_cause"); + model.Personnel = (await PersonnelNamesAsync()).OrderBy(kvp => kvp.Value).Select(kvp => new SelectListItem { Value = kvp.Key, Text = kvp.Value }).ToList(); return model; } + /// + /// The conditional sections this report must or may carry, in rule order, each with its existing rows and + /// one spare. Sections already on the record but no longer demanded by the selected types are still shown, + /// so an author who changed the incident type can take the stale section back off rather than being stuck + /// with a section validation will reject. + /// + private async Task BuildSectionsAsync(IncidentReportEditView model, IncidentReportAggregate aggregate) + { + var requirements = await _incidentReports.GetSectionRequirementsAsync(DepartmentId, aggregate.Report.RmsIncidentReportId); + var kinds = requirements.Select(r => r.Kind).ToList(); + foreach (var kind in aggregate.Modules.Select(m => (RmsIncidentModuleKind)m.ModuleKind).Distinct()) + { + if (!kinds.Contains(kind)) + kinds.Add(kind); + } + + foreach (var kind in kinds) + { + var requirement = requirements.FirstOrDefault(r => r.Kind == kind); + var descriptor = RmsIncidentModuleCatalog.Get(kind); + var rows = aggregate.Modules.Where(m => m.ModuleKind == (int)kind).OrderBy(m => m.Ordinal).ToList(); + + model.Sections.Add(new IncidentSectionView + { + Kind = kind, + Required = requirement?.Required ?? false, + Reason = requirement?.Reason ?? _localizer["SectionNoLongerApplies"].Value, + Present = rows.Count > 0, + IsCollection = descriptor?.IsCollection ?? false, + PayloadPath = descriptor?.PayloadPath, + SchemaName = descriptor?.SchemaName, + PrimaryCodes = Codes(requirement?.PrimaryCodeSet), + SecondaryCodes = Codes(requirement?.SecondaryCodeSet) + }); + + foreach (var row in rows) + { + model.Modules.Add(new IncidentModuleRow + { + Kind = (int)kind, Included = true, PrimaryCode = row.PrimaryCode, SecondaryCode = row.SecondaryCode, Quantity = row.Quantity, + QuantityUnit = row.QuantityUnit, OccurredOn = row.OccurredOn?.TimeConverter(model.Department), DetailJson = row.DetailJson + }); + } + + // A singleton section always gets its one row; a collection gets a spare to add the next entry. + if (rows.Count == 0 || (descriptor?.IsCollection ?? false)) + model.Modules.Add(new IncidentModuleRow { Kind = (int)kind, Included = false }); + } + + model.Resources = aggregate.Resources.OrderBy(r => r.Ordinal) + .Select(r => new IncidentResourceRow { ResourceCode = r.ResourceCode, Quantity = r.Quantity, Detail = r.Detail }).ToList(); + + model.Casualties = aggregate.Casualties.OrderBy(c => c.Ordinal).Select(c => new IncidentCasualtyRow + { + Included = true, Kind = c.Kind, PersonType = c.PersonType, PersonnelUserId = c.PersonnelUserId, Rank = c.Rank, YearsOfService = c.YearsOfService, + JobClassification = c.JobClassification, BirthMonthYear = c.BirthMonthYear, Gender = c.Gender, Race = c.Race, WasInjured = c.WasInjured, WasFatal = c.WasFatal, + CasualtyCause = c.CasualtyCause, CasualtyAction = c.CasualtyAction, CasualtyTimeline = c.CasualtyTimeline, DutyType = c.DutyType, Ppe = Split(c.PpeCsv), + InjuryDetailJson = c.InjuryDetailJson, RescueType = c.RescueType, RescueActions = Split(c.RescueActionsCsv), RescueImpediments = Split(c.RescueImpedimentsCsv), + RescueMode = c.RescueMode, RescuePath = c.RescuePath, RescueElevation = c.RescueElevation, PresenceKnown = c.PresenceKnown, + OccurredOn = c.OccurredOn?.TimeConverter(model.Department), DetailJson = c.DetailJson + }).ToList(); + + model.Exposures = aggregate.Exposures.OrderBy(e => e.Ordinal).Select(e => new IncidentExposureRow + { + Included = true, LocationKind = e.LocationKind, ItemType = e.ItemType, DamageType = e.DamageType, LocationUse = e.LocationUse, PeoplePresent = e.PeoplePresent, + DisplacementCount = e.DisplacementCount, DisplacementCauses = Split(e.DisplacementCausesCsv), AddressText = e.AddressText, Street = e.Street, + Municipality = e.Municipality, State = e.State, PostalCode = e.PostalCode, Latitude = e.Latitude, Longitude = e.Longitude, + EstimatedValue = e.EstimatedValue, EstimatedLoss = e.EstimatedLoss, DetailJson = e.DetailJson + }).ToList(); + } + private IncidentReportDraftInput BuildInput(IncidentReportEditView model, Department department) { var hasLocation = new[] { model.AddressText, model.Number, model.Street, model.Municipality, model.PostalCode, model.PlaceType, model.LocationUse, model.CrossStreet1 }.Any(s => !string.IsNullOrWhiteSpace(s)) || model.Latitude.HasValue; @@ -694,12 +882,48 @@ private IncidentReportDraftInput BuildInput(IncidentReportEditView model, Depart Narrative = model.Narrative, ImpedimentNarrative = model.ImpedimentNarrative, OutcomeNarrative = model.OutcomeNarrative, + // The Web form always renders every section, so it always posts every section: an empty list here + // means the author removed the rows, not that the client could not show them. + Modules = (model.Modules ?? new List()).Where(m => m.Included && m.Kind > 0).Select(m => new IncidentModuleInput + { + Kind = (RmsIncidentModuleKind)m.Kind, PrimaryCode = m.PrimaryCode, SecondaryCode = m.SecondaryCode, Quantity = m.Quantity, + QuantityUnit = m.QuantityUnit, OccurredOn = ToUtc(m.OccurredOn, department), DetailJson = m.DetailJson + }).ToList(), + Resources = (model.Resources ?? new List()).Where(r => !string.IsNullOrWhiteSpace(r.ResourceCode)) + .Select(r => new IncidentResourceInput { ResourceCode = r.ResourceCode, Quantity = r.Quantity, Detail = r.Detail }).ToList(), + Casualties = (model.Casualties ?? new List()).Where(c => c.Included).Select(c => new IncidentCasualtyRescueInput + { + Kind = (RmsCasualtyRescueKind)c.Kind, PersonType = c.PersonType, PersonnelUserId = c.PersonnelUserId, Rank = c.Rank, YearsOfService = c.YearsOfService, + JobClassification = c.JobClassification, BirthMonthYear = c.BirthMonthYear, Gender = c.Gender, Race = c.Race, WasInjured = c.WasInjured, WasFatal = c.WasFatal, + CasualtyCause = c.CasualtyCause, CasualtyAction = c.CasualtyAction, CasualtyTimeline = c.CasualtyTimeline, DutyType = c.DutyType, + Ppe = c.Ppe ?? new List(), InjuryDetailJson = c.InjuryDetailJson, RescueType = c.RescueType, + RescueActions = c.RescueActions ?? new List(), RescueImpediments = c.RescueImpediments ?? new List(), + RescueMode = c.RescueMode, RescuePath = c.RescuePath, RescueElevation = c.RescueElevation, PresenceKnown = c.PresenceKnown, + OccurredOn = ToUtc(c.OccurredOn, department), DetailJson = c.DetailJson + }).ToList(), + Exposures = (model.Exposures ?? new List()).Where(e => e.Included).Select(e => new IncidentExposureInput + { + LocationKind = e.LocationKind, ItemType = e.ItemType, DamageType = e.DamageType, LocationUse = e.LocationUse, PeoplePresent = e.PeoplePresent, + DisplacementCount = e.DisplacementCount, DisplacementCauses = e.DisplacementCauses ?? new List(), AddressText = e.AddressText, Street = e.Street, + Municipality = e.Municipality, State = e.State, PostalCode = e.PostalCode, Latitude = e.Latitude, Longitude = e.Longitude, + EstimatedValue = e.EstimatedValue, EstimatedLoss = e.EstimatedLoss, DetailJson = e.DetailJson + }).ToList(), OriginClient = RmsOriginClient.Web }; } + private static List Split(string csv) + { + return string.IsNullOrWhiteSpace(csv) + ? new List() + : csv.Split(',').Select(c => c.Trim()).Where(c => c.Length > 0).ToList(); + } + private List Codes(string setKey) { + if (string.IsNullOrWhiteSpace(setKey)) + return new List(); + var set = _neris.GetValueSet(setKey); return (set?.Codes ?? new List()).Select(c => new SelectListItem { Value = c, Text = c.Replace("||", " / ").Replace('_', ' ') }).ToList(); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index 3eaf4c90..53cfb6f1 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -52,13 +52,14 @@ public class RecordsController : SecureBaseController private readonly IDepartmentProfileMediaService _branding; private readonly IRecordsPrintLayoutService _printLayouts; private readonly IRecordsAccountabilityService _accountability; + private readonly IRecordsDashboardService _dashboard; public RecordsController(IRecordsService recordsService, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService, ICallsService callsService, IDepartmentSettingsService departmentSettingsService, IEventAggregator eventAggregator, IStringLocalizer localizer, ICompositeViewEngine viewEngine, IPdfProvider pdfProvider, IRecordsSearchService recordsSearch, IDepartmentDataProtectionService dataProtection, - IDepartmentProfileMediaService branding, IRecordsPrintLayoutService printLayouts, IRecordsAccountabilityService accountability) + IDepartmentProfileMediaService branding, IRecordsPrintLayoutService printLayouts, IRecordsAccountabilityService accountability, IRecordsDashboardService dashboard) { _accountability = accountability; _recordsService = recordsService; @@ -77,8 +78,46 @@ public RecordsController(IRecordsService recordsService, IRecordsCutoverService _dataProtection = dataProtection; _branding = branding; _printLayouts = printLayouts; + _dashboard = dashboard; } + #region Dashboard + + /// + /// The Records work queues an officer opens the module to look at (RMS-3): incomplete, awaiting review, + /// rejected, accepted, overdue, the disclosure clock, and the NERIS crosswalk gaps that decide whether a + /// filing will map cleanly at all. Counts are group-scope aware and degrade into warnings rather than + /// failing the page. + /// + [HttpGet] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task Dashboard(CancellationToken cancellationToken) + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return NotFound(); + + var model = new RecordsDashboardView + { + ModuleState = moduleState, + Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), + CanManageDisclosures = ClaimsAuthorizationHelper.CanManageRecordDisclosures(), + IsDepartmentAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() + }; + + if (moduleState.RecordsUsable) + { + model.Dashboard = await _dashboard.GetAsync(DepartmentId, UserId, cancellationToken); + model.Coverage = await _dashboard.GetCrosswalkCoverageAsync(DepartmentId, cancellationToken); + } + + if (TempData["RecordsMessage"] is string message) + model.Message = message; + return View(model); + } + + #endregion + #region Queue [HttpGet] diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs index 8b59a5e6..f5f946e7 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs @@ -32,6 +32,9 @@ public class ViewCallView: BaseUserModel public string DestinationAddress { get; set; } public string DestinationTypeName { get; set; } + /// RMS: false hides the Incident Report control, which would otherwise 404 on Start. + public bool RecordsUsable { get; set; } + /// ADP: true when this call carries protected fields rendered as REDACTED (plan 7.2). public bool IsProtectedCall { get; set; } public string ProtectedReason { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/DisclosureViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/DisclosureViewModels.cs new file mode 100644 index 00000000..a45f5d5c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Records/DisclosureViewModels.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Areas.User.Models.Records +{ + /// The public-records queue (RMS plan section 4.7): every open request with its statutory clock. + public class DisclosureIndexView : RecordsBaseView + { + public RecordsModuleState ModuleState { get; set; } + public Department Department { get; set; } + public List Requests { get; set; } = new List(); + public Dictionary PersonnelNames { get; set; } = new Dictionary(); + public int? StateFilter { get; set; } + public List States { get; set; } = new List(); + public List RedactionProfiles { get; set; } = new List(); + public List Personnel { get; set; } = new List(); + + /// Who asked is restricted in most jurisdictions; without the grant the queue shows the reference only. + public bool CanViewRestricted { get; set; } + } + + /// One request: scope, preview of what it resolves to, and the productions already prepared. + public class DisclosureDetailView : RecordsBaseView + { + public RmsDisclosureRequest Request { get; set; } + public Department Department { get; set; } + public RmsDisclosureScopePreview Preview { get; set; } + public List Productions { get; set; } = new List(); + public Dictionary PersonnelNames { get; set; } = new Dictionary(); + public bool CanViewRestricted { get; set; } + public List RedactionProfiles { get; set; } = new List(); + public List DefinitionKeys { get; set; } = new List(); + public List RecordStates { get; set; } = new List(); + public List Dispositions { get; set; } = new List(); + + public DisclosureScopeForm Scope { get; set; } = new DisclosureScopeForm(); + + public RmsDisclosureState State => (RmsDisclosureState)Request.State; + public bool IsOpen => Request.ClosedOn == null; + + /// The scope is frozen once anything has been produced against it. + public bool CanEditScope => IsOpen && Productions.Count == 0; + } + + /// The bounded Records query a production runs against. + public class DisclosureScopeForm + { + public string RequestId { get; set; } + public string ScopeNarrative { get; set; } + public string RedactionProfile { get; set; } + public string DefinitionKey { get; set; } + public int? Year { get; set; } + public int? CallId { get; set; } + public List States { get; set; } = new List(); + public bool IncludeLegacy { get; set; } + } + + /// The Records work queues an officer opens the module to look at (RMS-3). + public class RecordsDashboardView : RecordsBaseView + { + public RecordsModuleState ModuleState { get; set; } + public Department Department { get; set; } + public RecordsDashboard Dashboard { get; set; } + public NerisCrosswalkCoverage Coverage { get; set; } + public bool CanManageDisclosures { get; set; } + public bool IsDepartmentAdmin { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs index 7eb239d9..e4e134b8 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc.Rendering; using Resgrid.Model; +using Resgrid.Model.Services; namespace Resgrid.Web.Areas.User.Models.Records { @@ -45,6 +46,15 @@ public class IncidentReportDetailView : RecordsBaseView public bool CanVoid { get; set; } public bool CanExport { get; set; } public bool IsDepartmentAdmin { get; set; } + public bool CanViewRestricted { get; set; } + + // RMS-3: the progressive section rules the report is judged against, the separate analysis filing, and the + // evidence artifacts captured against this report. + public List SectionRequirements { get; set; } = new List(); + public RmsIncidentAnalysis Analysis { get; set; } + public List Evidence { get; set; } = new List(); + public List EvidenceSources { get; set; } = new List(); + public RmsRecordState State => (RmsRecordState)Aggregate.Report.State; public bool RequiresReview => (RmsLifecyclePreset)Aggregate.Report.LifecyclePreset != RmsLifecyclePreset.QuickEntry; public bool IsEditable => RmsLifecycle.IsEditable(State) || State == RmsRecordState.Rejected || Aggregate.Report.AmendsRevisionId != null; @@ -97,6 +107,9 @@ public class IncidentReportEditView : RecordsBaseView public const int TypeRows = 3; public const int AidRows = 3; public const int TacticRows = 5; + public const int ResourceRows = 4; + public const int CasualtyRows = 4; + public const int ExposureRows = 3; public string ReportId { get; set; } public long RowVersion { get; set; } @@ -147,6 +160,19 @@ public class IncidentReportEditView : RecordsBaseView public List Aids { get; set; } = new List(); public List Tactics { get; set; } = new List(); + // RMS-3 conditional sections. Modules is one flat list across every applicable section; each row carries + // its own Kind so the form can render sections in rule order without a jagged binding shape. + public List Modules { get; set; } = new List(); + public List Resources { get; set; } = new List(); + public List Casualties { get; set; } = new List(); + public List Exposures { get; set; } = new List(); + + /// The progressive section rules for the types currently selected, with their value sets. + public List Sections { get; set; } = new List(); + + /// False hides the restricted casualty fields; the service keeps the stored values rather than erasing them. + public bool CanEditRestricted { get; set; } + public string Narrative { get; set; } public string ImpedimentNarrative { get; set; } public string OutcomeNarrative { get; set; } @@ -163,6 +189,23 @@ public class IncidentReportEditView : RecordsBaseView public List PlaceTypes { get; set; } = new List(); public List LocationUses { get; set; } = new List(); public List ResponseModes { get; set; } = new List(); + public List CasualtyPersonTypes { get; set; } = new List(); + public List CasualtyCauses { get; set; } = new List(); + public List CasualtyActions { get; set; } = new List(); + public List CasualtyTimelines { get; set; } = new List(); + public List DutyTypes { get; set; } = new List(); + public List PpeCodes { get; set; } = new List(); + public List RescueTypes { get; set; } = new List(); + public List RescueActionCodes { get; set; } = new List(); + public List RescueImpedimentCodes { get; set; } = new List(); + public List RescueModes { get; set; } = new List(); + public List RescuePaths { get; set; } = new List(); + public List RescueElevations { get; set; } = new List(); + public List PresenceKnownCodes { get; set; } = new List(); + public List ExposureItemTypes { get; set; } = new List(); + public List ExposureDamageTypes { get; set; } = new List(); + public List DisplacementCauseCodes { get; set; } = new List(); + public List Personnel { get; set; } = new List(); public List Stations { get; set; } = new List(); public List AvailableUnits { get; set; } = new List(); /// Provenance rows keyed by fact key, so the form can show where a prefilled value came from. diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/IncidentSectionViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/IncidentSectionViewModels.cs new file mode 100644 index 00000000..25d439f4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Records/IncidentSectionViewModels.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Records +{ + /// + /// One conditional section on the incident report form (RMS-3). The reportable facts are fields; the rest of + /// the contract-shaped body stays as JSON, because the pinned contract owns that shape and a hand-built form + /// per section would drift from it at the next contract bump. + /// + public class IncidentModuleRow + { + public int Kind { get; set; } + public string PrimaryCode { get; set; } + public string SecondaryCode { get; set; } + public decimal? Quantity { get; set; } + public string QuantityUnit { get; set; } + public DateTime? OccurredOn { get; set; } + public string DetailJson { get; set; } + + /// Unticked rows are dropped on save; that is how a section is removed from the report. + public bool Included { get; set; } + } + + public class IncidentResourceRow + { + public string ResourceCode { get; set; } + public int? Quantity { get; set; } + public string Detail { get; set; } + } + + /// + /// A casualty or rescue. The restricted half (personnel link, demographics, injury detail) is only rendered + /// and only accepted from an author holding RecordRestricted_View; the service keeps the stored values when + /// they are absent rather than erasing them. + /// + public class IncidentCasualtyRow + { + public int Kind { get; set; } + public string PersonType { get; set; } + public string PersonnelUserId { get; set; } + public string Rank { get; set; } + public decimal? YearsOfService { get; set; } + public string JobClassification { get; set; } + public string BirthMonthYear { get; set; } + public string Gender { get; set; } + public string Race { get; set; } + public bool? WasInjured { get; set; } + public bool WasFatal { get; set; } + public string CasualtyCause { get; set; } + public string CasualtyAction { get; set; } + public string CasualtyTimeline { get; set; } + public string DutyType { get; set; } + public List Ppe { get; set; } = new List(); + public string InjuryDetailJson { get; set; } + public string RescueType { get; set; } + public List RescueActions { get; set; } = new List(); + public List RescueImpediments { get; set; } = new List(); + public string RescueMode { get; set; } + public string RescuePath { get; set; } + public string RescueElevation { get; set; } + public string PresenceKnown { get; set; } + public DateTime? OccurredOn { get; set; } + public string DetailJson { get; set; } + public bool Included { get; set; } + } + + public class IncidentExposureRow + { + public string LocationKind { get; set; } + public string ItemType { get; set; } + public string DamageType { get; set; } + public string LocationUse { get; set; } + public bool? PeoplePresent { get; set; } + public int? DisplacementCount { get; set; } + public List DisplacementCauses { get; set; } = new List(); + public string AddressText { get; set; } + public string Street { get; set; } + public string Municipality { get; set; } + public string State { get; set; } + public string PostalCode { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public string DetailJson { get; set; } + public bool Included { get; set; } + } + + public class IncidentPropertyRow + { + public string LocationUse { get; set; } + public string ConstructionType { get; set; } + public string Foundation { get; set; } + public string ExteriorFinish { get; set; } + public string RoofMaterial { get; set; } + public int? StoriesAboveGrade { get; set; } + public int? StoriesBelowGrade { get; set; } + public int? YearBuilt { get; set; } + public string Vacancy { get; set; } + public string DamageType { get; set; } + public string FireSpread { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public decimal? ContentsValue { get; set; } + public decimal? ContentsLoss { get; set; } + public string DetailJson { get; set; } + public bool Included { get; set; } + } + + /// VIN, plate and registration state are restricted and only rendered to an author who may see them. + public class IncidentVehicleRow + { + public string VehicleKind { get; set; } + public string Make { get; set; } + public string Model { get; set; } + public int? ModelYear { get; set; } + public string BodyStyle { get; set; } + public string Powertrain { get; set; } + public string DamageType { get; set; } + public string Vin { get; set; } + public string LicensePlate { get; set; } + public string LicenseState { get; set; } + public bool WasOccupied { get; set; } + public decimal? EstimatedValue { get; set; } + public decimal? EstimatedLoss { get; set; } + public string DetailJson { get; set; } + public bool Included { get; set; } + } + + /// + /// One conditional section as the form renders it: the rule, the value sets its codes come from, and the + /// rows already on the record. Built server-side so the form never keeps its own copy of the progressive + /// rules that validation will enforce. + /// + public class IncidentSectionView + { + public RmsIncidentModuleKind Kind { get; set; } + public bool Required { get; set; } + public string Reason { get; set; } + public bool Present { get; set; } + public bool IsCollection { get; set; } + public string PayloadPath { get; set; } + public string SchemaName { get; set; } + public List PrimaryCodes { get; set; } = new List(); + public List SecondaryCodes { get; set; } = new List(); + } + + /// Draft editor for the separate NERIS incident-analysis filing (RMS-3). + public class IncidentAnalysisEditView : RecordsBaseView + { + public const int ModuleRows = 5; + public const int PropertyRows = 3; + public const int VehicleRows = 3; + + public string AnalysisId { get; set; } + public string ReportId { get; set; } + public long RowVersion { get; set; } + public string Reference { get; set; } + public RmsIncidentAnalysisState State { get; set; } + public string GeneralCause { get; set; } + public List InvestigationTypes { get; set; } = new List(); + public string CurrencyCode { get; set; } + public bool ValidateAfterSave { get; set; } + public bool CanEditRestricted { get; set; } + + public List Modules { get; set; } = new List(); + public List Properties { get; set; } = new List(); + public List Vehicles { get; set; } = new List(); + + public Department Department { get; set; } + public List Sections { get; set; } = new List(); + public List GeneralCauses { get; set; } = new List(); + public List InvestigationTypeCodes { get; set; } = new List(); + public List LocationUses { get; set; } = new List(); + public List ConstructionTypes { get; set; } = new List(); + public List Vacancies { get; set; } = new List(); + public List BuildingDamageTypes { get; set; } = new List(); + public List FireSpreads { get; set; } = new List(); + public List VehicleMakes { get; set; } = new List(); + public List VehicleBodyStyles { get; set; } = new List(); + public List Powertrains { get; set; } = new List(); + public List VehicleDamageTypes { get; set; } = new List(); + public List Issues { get; set; } = new List(); + } + + /// Read view of the incident-analysis filing: state, sections, submission history, revisions. + public class IncidentAnalysisDetailView : RecordsBaseView + { + public IncidentAnalysisAggregate Aggregate { get; set; } + public Department Department { get; set; } + public bool SubmissionEnabled { get; set; } + public bool CanEdit { get; set; } + public bool CanFinalize { get; set; } + public bool CanSubmit { get; set; } + public bool CanVoid { get; set; } + public bool CanViewRestricted { get; set; } + public bool IsDepartmentAdmin { get; set; } + public Dictionary PersonnelNames { get; set; } = new Dictionary(); + public string Reference { get; set; } + public RmsIncidentAnalysisState State => Aggregate.State; + public bool IsEditable => State == RmsIncidentAnalysisState.Draft || State == RmsIncidentAnalysisState.Rejected; + public bool CanQueueSubmission => SubmissionEnabled && State == RmsIncidentAnalysisState.Finalized; + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Disclosures/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/Disclosures/Details.cshtml new file mode 100644 index 00000000..5e1b8f42 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Disclosures/Details.cshtml @@ -0,0 +1,268 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@using Resgrid.Web.Helpers +@model Resgrid.Web.Areas.User.Models.Records.DisclosureDetailView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Disclosure"]; + var request = Model.Request; + string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; + string Name(string userId) => string.IsNullOrEmpty(userId) ? "-" : (Model.PersonnelNames.TryGetValue(userId, out var n) ? n : userId); +} + +
+
+

@request.RequestNumber @Model.State

+ +
+
+ +
+ @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
@Model.ErrorMessage
+ } + @if (!string.IsNullOrEmpty(Model.Message)) + { +
@Model.Message
+ } + @if (request.IsOverdue) + { +
@localizer["DisclosureOverdueNotice"]
+ } + +
+
+
+
@localizer["Request"]
+
+
+ @if (Model.CanViewRestricted) + { +
@localizer["RequesterName"]
@(request.RequesterName ?? "-")
+
@localizer["RequesterOrganization"]
@(request.RequesterOrganization ?? "-")
+
@localizer["RequesterContact"]
@(request.RequesterContact ?? "-")
+ } + else + { +
@localizer["Requester"]
@localizer["RequesterIdentityWithheld"]
+ } +
@localizer["ReceivedOn"]
@When(request.ReceivedOn)
+
@localizer["StatutoryDueOn"]
@When(request.StatutoryDueOn)
+
@localizer["JurisdictionProfile"]
@(request.JurisdictionProfile ?? "-")
+
@localizer["AssignedTo"]
@Name(request.AssignedToUserId)
+
@localizer["RedactionProfile"]
@(request.RedactionProfile ?? RmsRedactionProfiles.Standard)
+ @if (request.ClosedOn.HasValue) + { +
@localizer["ClosedOn"]
@When(request.ClosedOn) — @Name(request.ClosedByUserId)
+
@localizer["DispositionReason"]
@request.DispositionReason
+ } +
+
+
+ +
+
@localizer["Scope"]
+
+ @if (!Model.CanEditScope) + { +
@localizer["ScopeFrozen"]
+ } +
+ @Html.AntiForgeryToken() + +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+ @foreach (var recordState in Model.RecordStates) + { + + } +
+
+
+
+ +
+
+ @if (Model.CanEditScope) + { +
+
+
+ } +
+
+
+ + @if (Model.Preview != null) + { +
+
+
@localizer["ScopePreview"]
+
+ @Model.Preview.MatchedCount @localizer["Matched"] + @Model.Preview.ProducibleCount @localizer["Producible"] + @if (Model.Preview.WithheldWholeRecordCount > 0) + { + @Model.Preview.WithheldWholeRecordCount @localizer["Withheld"] + } +
+
+
+ @if (Model.Preview.Truncated) + { +
@localizer["ScopePreviewTruncated"]
+ } +
+ + + + @foreach (var item in Model.Preview.Items) + { + + + + + + + + } + +
@localizer["RecordNumber"]@localizer["DefinitionKey"]@localizer["Summary"]@localizer["Occurred"]@localizer["Producible"]
@item.RecordNumber@item.DefinitionKey@item.Summary@When(item.OccurredOn)@(item.Producible ? commonLocalizer["Yes"].Value : item.NotProducibleReason)
+
+
+
+ } + +
+
@localizer["Productions"]
+
+ @if (Model.Productions.Count == 0) + { +

@localizer["NoProductions"]

+ } + else + { +
+ + + + @foreach (var production in Model.Productions.OrderByDescending(p => p.ProductionNumber)) + { + + + + + + + + + + + } + +
#@localizer["RedactionProfile"]@localizer["RecordCount"]@localizer["Withheld"]@localizer["PreparedOn"]@localizer["ReleasedOn"]@localizer["Checksum"]
@production.ProductionNumber@production.RedactionProfile@production.RecordCount@production.WithheldFieldCount@When(production.PreparedOn) — @Name(production.PreparedByUserId)@(production.IsReleased ? When(production.ReleasedOn) + " — " + Name(production.ReleasedByUserId) : "-")@(production.Checksum?.Length > 12 ? production.Checksum.Substring(0, 12) : production.Checksum) + +
+ @Html.AntiForgeryToken() + + + +
+ @if (!production.IsReleased && Model.IsOpen) + { +
+ @Html.AntiForgeryToken() + + + +
+ } +
+
+ } +
+
+
+ +
+ @if (Model.IsOpen) + { +
+
@localizer["Produce"]
+
+

@localizer["ProduceIntro"]

+
+ @Html.AntiForgeryToken() + + + +
+
+
+ +
+
@localizer["CloseRequest"]
+
+
+ @Html.AntiForgeryToken() + + + + +
+
+
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Disclosures/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Disclosures/Index.cshtml new file mode 100644 index 00000000..dc52bb92 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Disclosures/Index.cshtml @@ -0,0 +1,170 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@using Resgrid.Web.Helpers +@model Resgrid.Web.Areas.User.Models.Records.DisclosureIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Disclosures"]; + string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; + string Name(string userId) => string.IsNullOrEmpty(userId) ? "-" : (Model.PersonnelNames.TryGetValue(userId, out var n) ? n : userId); +} + +
+
+

@localizer["Disclosures"]

+ +
+
+ +
+ @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
@Model.ErrorMessage
+ } + @if (!string.IsNullOrEmpty(Model.Message)) + { +
@Model.Message
+ } + @if (!Model.ModuleState.RecordsUsable) + { +
@localizer["RecordsNotActivated"]
+ } + +
+
+
+
+
@localizer["DisclosureQueue"]
+
+
+ +
+
+
+
+ @if (Model.Requests.Count == 0) + { +

@localizer["NoDisclosures"]

+ } + else + { +
+ + + + + @if (Model.CanViewRestricted) + { + + } + + + + + + + + @foreach (var request in Model.Requests) + { + + + @if (Model.CanViewRestricted) + { + + } + + + + + + } + +
@localizer["RequestNumber"]@localizer["Requester"]@localizer["ReceivedOn"]@localizer["StatutoryDueOn"]@localizer["State"]@localizer["AssignedTo"]
@request.RequestNumber@request.RequesterName @request.RequesterOrganization@When(request.ReceivedOn) + @When(request.StatutoryDueOn) + @if (request.IsOverdue) + { + @localizer["Overdue"] + } + @(((RmsDisclosureState)request.State).ToString())@Name(request.AssignedToUserId)
+
+ } + @if (!Model.CanViewRestricted) + { +

@localizer["RequesterIdentityWithheld"]

+ } +
+
+
+ +
+
+
@localizer["LogDisclosure"]
+
+

@localizer["DisclosureIntro"]

+
+ @Html.AntiForgeryToken() +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + @localizer["StatutoryDueHint"] +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml index 8c6fb2bc..0037aa57 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml @@ -51,7 +51,7 @@ @localizer["UpdateCallHeader"] @localizer["CloseCallHeader"] } - @if (ClaimsAuthorizationHelper.CanCreateRecord()) + @if (Model.RecordsUsable && ClaimsAuthorizationHelper.CanCreateRecord()) {
@Html.AntiForgeryToken()
} diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentAnalysis/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentAnalysis/Details.cshtml new file mode 100644 index 00000000..dee74090 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentAnalysis/Details.cshtml @@ -0,0 +1,273 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@using Resgrid.Web.Helpers +@model Resgrid.Web.Areas.User.Models.Records.IncidentAnalysisDetailView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["IncidentAnalysis"]; + var a = Model.Aggregate; + var analysis = a.Analysis; + string Name(string userId) => string.IsNullOrEmpty(userId) ? "-" : (Model.PersonnelNames.TryGetValue(userId, out var n) ? n : userId); + string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; +} + +
+
+

@localizer["IncidentAnalysis"] @Model.State

+ +
+
+ +
+ @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
@Model.ErrorMessage
+ } + @if (!string.IsNullOrEmpty(Model.Message)) + { +
@Model.Message
+ } + @if (Model.State == RmsIncidentAnalysisState.Finalized && !a.IncidentIsFiled) + { +
@localizer["AnalysisAwaitingIncident"]
+ } + @if (Model.State == RmsIncidentAnalysisState.Rejected) + { +
@localizer["RejectedNotice"] @analysis.RejectionSummary
+ } + +
+
+
+
@Model.Reference
+
+
+
@localizer["GeneralCause"]
@(analysis.GeneralCause ?? "-")
+
@localizer["InvestigationTypes"]
@(string.IsNullOrWhiteSpace(analysis.InvestigationTypesCsv) ? "-" : analysis.InvestigationTypesCsv.Replace(",", ", "))
+
@localizer["EstimatedValue"]
@(analysis.EstimatedValueTotal?.ToString("0.##") ?? "-") @analysis.CurrencyCode
+
@localizer["EstimatedLoss"]
@(analysis.EstimatedLossTotal?.ToString("0.##") ?? "-") @analysis.CurrencyCode
+
@localizer["NerisAnalysisId"]
@(analysis.NerisAnalysisId ?? "-")
+
@localizer["Author"]
@Name(analysis.AuthorUserId)
+
@localizer["FinalizedOn"]
@When(analysis.FinalizedOn)
+
@localizer["ProfileVersion"]
@analysis.ProfileVersion
+
+
+
+ + @if (a.Modules.Count > 0) + { +
+
@localizer["AnalysisSections"]
+
+
+ + + + @foreach (var module in a.Modules.OrderBy(m => m.Ordinal)) + { + + + + + + + } + +
@localizer["Section"]@localizer["PrimaryCode"]@localizer["SecondaryCode"]@localizer["Quantity"]
@localizer["Section" + ((RmsIncidentModuleKind)module.ModuleKind).ToString()]@module.PrimaryCode@module.SecondaryCode@(module.Quantity.HasValue ? module.Quantity.Value.ToString("0.##") + " " + module.QuantityUnit : "-")
+
+
+
+ } + + @if (a.Properties.Count > 0) + { +
+
@localizer["Properties"]
+
+
+ + + + @foreach (var property in a.Properties.OrderBy(p => p.Ordinal)) + { + + + + + + + + + } + +
@localizer["LocationUse"]@localizer["ConstructionType"]@localizer["DamageType"]@localizer["FireSpread"]@localizer["EstimatedValue"]@localizer["EstimatedLoss"]
@property.LocationUse@property.ConstructionType@property.DamageType@property.FireSpread@(property.EstimatedValue?.ToString("0.##") ?? "-")@(property.EstimatedLoss?.ToString("0.##") ?? "-")
+
+
+
+ } + + @if (a.Vehicles.Count > 0) + { +
+
@localizer["Vehicles"]
+
+
+ + + + + @if (Model.CanViewRestricted) + { + + } + + + + @foreach (var vehicle in a.Vehicles.OrderBy(v => v.Ordinal)) + { + + + + + + @if (Model.CanViewRestricted) + { + + + } + + } + +
@localizer["Make"]@localizer["Model"]@localizer["ModelYear"]@localizer["DamageType"]@localizer["Vin"]@localizer["LicensePlate"]
@vehicle.Make@vehicle.Model@vehicle.ModelYear@vehicle.DamageType@vehicle.Vin@vehicle.LicensePlate @vehicle.LicenseState
+
+ @if (!Model.CanViewRestricted) + { +

@localizer["RestrictedFieldsHidden"]

+ } +
+
+ } + + @if (a.Submissions.Count > 0) + { +
+
@localizer["Submissions"]
+
+
+ + + + @foreach (var submission in a.Submissions.OrderByDescending(s => s.QueuedOn)) + { + + + + + + + + + } + +
@localizer["State"]@localizer["Queued"]@localizer["Sent"]@localizer["Attempts"]@localizer["ExternalId"]@localizer["Error"]
@(((RmsSubmissionState)submission.State).ToString())@When(submission.QueuedOn)@When(submission.SentOn)@submission.Attempts / @submission.MaxAttempts@submission.ExternalId@submission.ErrorSummary
+
+
+
+ } +
+ +
+ @if (Model.CanEdit && Model.IsEditable) + { +
+
@commonLocalizer["Edit"]
+
+ @localizer["EditIncidentAnalysis"] +
+ @Html.AntiForgeryToken() + + +
+
+
+ } + + @if (Model.CanFinalize && Model.IsEditable) + { +
+
@localizer["Finalize"]
+
+

@localizer["FinalizeAnalysisIntro"]

+
+ @Html.AntiForgeryToken() + + + +
+
+
+ } + + @if (Model.CanSubmit && Model.CanQueueSubmission) + { +
+
@localizer["Submit"]
+
+
+ @Html.AntiForgeryToken() + + +
+ @if (!a.IncidentIsFiled) + { +

@localizer["AnalysisAwaitingIncident"]

+ } +
+
+ } + + @if (Model.CanVoid && Model.State != RmsIncidentAnalysisState.Voided) + { +
+
@localizer["Void"]
+
+
+ @Html.AntiForgeryToken() + + + + +
+
+
+ } + +
+
@localizer["History"]
+
+ @if (a.Revisions.Count == 0) + { +

-

+ } + else + { +
    + @foreach (var revision in a.Revisions.OrderByDescending(x => x.RevisionNumber)) + { +
  • + #@revision.RevisionNumber @(((RmsRevisionTransition)revision.Transition).ToString()) + @When(revision.CreatedOn) — @Name(revision.ActorUserId) +
  • + } +
+ } +
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentAnalysis/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentAnalysis/Edit.cshtml new file mode 100644 index 00000000..fa13136e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentAnalysis/Edit.cshtml @@ -0,0 +1,301 @@ +@using Resgrid.Model +@using Resgrid.Web.Helpers +@model Resgrid.Web.Areas.User.Models.Records.IncidentAnalysisEditView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["EditIncidentAnalysis"]; + string Dt(DateTime? v) => v.HasValue ? v.Value.ToString("yyyy-MM-ddTHH:mm") : string.Empty; +} + +
+
+

@localizer["EditIncidentAnalysis"]

+ +
+
+ +
+ @Html.AntiForgeryToken() + @Html.HiddenFor(m => m.AnalysisId) + @Html.HiddenFor(m => m.ReportId) + @Html.HiddenFor(m => m.RowVersion) + +
+
+
+ @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
@Model.ErrorMessage
+ } + @if (!string.IsNullOrEmpty(Model.Message)) + { +
@Model.Message
+ } + +
+
@localizer["IncidentAnalysis"] @Model.Reference @Model.State
+
+
+ +
+ +
+
+
+ +
+ @foreach (var c in Model.InvestigationTypeCodes) + { + + } +
+
+
+
+ +
+
@localizer["AnalysisSections"]
+
+ @if (Model.Sections.Count == 0) + { +

@localizer["NoSectionsYet"]

+ } + @foreach (var sectionView in Model.Sections) + { + var indices = Enumerable.Range(0, Model.Modules.Count).Where(i => Model.Modules[i].Kind == (int)sectionView.Kind).ToList(); +
+

+ @localizer["Section" + sectionView.Kind.ToString()] + @(sectionView.Required ? localizer["Required"] : localizer["Suggested"]) + @sectionView.PayloadPath +

+

@sectionView.Reason

+ @foreach (var i in indices) + { + var row = Model.Modules[i]; +
+
+ + +
+
+ @if (sectionView.PrimaryCodes.Count > 0) + { + + } + else + { + + } +
+
+ @if (sectionView.SecondaryCodes.Count > 0) + { + + } +
+
+
+
+
+
+
+
+
+ } + } +
+
+ +
+
@localizer["Properties"]
+
+ @for (var i = 0; i < Model.Properties.Count + Resgrid.Web.Areas.User.Models.Records.IncidentAnalysisEditView.PropertyRows; i++) + { + var row = i < Model.Properties.Count ? Model.Properties[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentPropertyRow(); +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ } +
+
+ +
+
@localizer["Vehicles"]
+
+ @if (!Model.CanEditRestricted) + { +
@localizer["RestrictedFieldsHidden"]
+ } + @for (var i = 0; i < Model.Vehicles.Count + Resgrid.Web.Areas.User.Models.Records.IncidentAnalysisEditView.VehicleRows; i++) + { + var row = i < Model.Vehicles.Count ? Model.Vehicles[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentVehicleRow(); +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+ @if (Model.CanEditRestricted) + { +
+
+
+
+
+
+ } + } +
+
+ +
+
+ @commonLocalizer["Cancel"] + + +
+
+
+
+
+
+
+
@localizer["IncidentAnalysis"]
+
+

@localizer["IncidentAnalysisIntro"]

+

@localizer["BackToReport"]

+
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml index 761a8e59..5064e62b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml @@ -1,4 +1,4 @@ -@using Resgrid.Model +@using Resgrid.Model @using Resgrid.Model.Helpers @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.Records.IncidentReportDetailView @@ -251,6 +251,206 @@ } + @if (Model.SectionRequirements.Count > 0 || a.Modules.Count > 0 || a.Casualties.Count > 0 || a.Exposures.Count > 0 || a.Resources.Count > 0) + { +
+
@localizer["ConditionalSections"]
+
+
+ + + + @foreach (var requirement in Model.SectionRequirements) + { + var rows = a.Modules.Where(m => m.ModuleKind == (int)requirement.Kind).OrderBy(m => m.Ordinal).ToList(); + + + + + + + + + } + @foreach (var module in a.Modules.Where(m => Model.SectionRequirements.All(x => (int)x.Kind != m.ModuleKind)).OrderBy(m => m.Ordinal)) + { + + + + + + + + } + +
@localizer["Section"]@localizer["Applies"]@localizer["Present"]@localizer["PrimaryCode"]@localizer["SecondaryCode"]@localizer["Quantity"]
@localizer["Section" + requirement.Kind.ToString()]@(requirement.Required ? localizer["Required"] : localizer["Suggested"]) @requirement.Reason@(rows.Count > 0 ? rows.Count.ToString() : "-")@string.Join(", ", rows.Select(m => m.PrimaryCode).Where(c => !string.IsNullOrWhiteSpace(c)))@string.Join(", ", rows.Select(m => m.SecondaryCode).Where(c => !string.IsNullOrWhiteSpace(c)))@string.Join(", ", rows.Where(m => m.Quantity.HasValue).Select(m => m.Quantity.Value.ToString("0.##") + " " + m.QuantityUnit))
@localizer["Section" + ((RmsIncidentModuleKind)module.ModuleKind).ToString()]@localizer["SectionNoLongerApplies"]@module.PrimaryCode@module.SecondaryCode@(module.Quantity.HasValue ? module.Quantity.Value.ToString("0.##") + " " + module.QuantityUnit : "-")
+
+ + @if (a.Casualties.Count > 0) + { +

@localizer["CasualtiesAndRescues"]

+
+ + + + @foreach (var casualty in a.Casualties.OrderBy(c => c.Ordinal)) + { + + + + + + + + + } + +
@localizer["Kind"]@localizer["PersonType"]@localizer["Injured"]@localizer["CasualtyCause"]@localizer["RescueType"]@localizer["Occurred"]
@(((RmsCasualtyRescueKind)casualty.Kind) == RmsCasualtyRescueKind.Rescue ? localizer["Rescue"] : localizer["Casualty"])@casualty.PersonType@(casualty.WasFatal ? localizer["Fatal"] : casualty.WasInjured == true ? commonLocalizer["Yes"] : casualty.WasInjured == false ? commonLocalizer["No"] : (Microsoft.Extensions.Localization.LocalizedString)null)@casualty.CasualtyCause@casualty.RescueType@When(casualty.OccurredOn)
+
+ @if (!Model.CanViewRestricted) + { +

@localizer["RestrictedFieldsHidden"]

+ } + } + + @if (a.Exposures.Count > 0) + { +

@localizer["Exposures"]

+
+ + + + @foreach (var exposure in a.Exposures.OrderBy(e => e.Ordinal)) + { + + + + + + + + } + +
@localizer["DamageType"]@localizer["ExposureItem"]@localizer["LocationUse"]@localizer["AddressText"]@localizer["EstimatedLoss"]
@exposure.DamageType@exposure.ItemType@exposure.LocationUse@(exposure.AddressText ?? exposure.Street)@(exposure.EstimatedLoss?.ToString("0.##") ?? "-")
+
+ } + + @if (a.Resources.Count > 0) + { +

@localizer["Resources"] @localizer["ResourcesNotSubmitted"]

+
    + @foreach (var resource in a.Resources.OrderBy(x => x.Ordinal)) + { +
  • @resource.ResourceCode @(resource.Quantity.HasValue ? "x" + resource.Quantity.Value : string.Empty) @resource.Detail
  • + } +
+ } +
+
+ } + +
+
+
@localizer["IncidentAnalysis"]
+
+
+

@localizer["IncidentAnalysisIntro"]

+ @if (Model.Analysis != null) + { +

+ + @localizer["OpenAnalysis"] + + @Model.Analysis.State.ToString() + @if (!string.IsNullOrWhiteSpace(Model.Analysis.NerisAnalysisId)) + { + @Model.Analysis.NerisAnalysisId + } +

+ } + else if (Model.CanEdit) + { +
+ @Html.AntiForgeryToken() + + +
+ } + else + { +

-

+ } +
+
+ +
+
@localizer["Evidence"]
+
+

@localizer["EvidenceIntro"]

+ @if (Model.Evidence.Count > 0) + { +
+ + + + @foreach (var artifact in Model.Evidence.OrderByDescending(x => x.CapturedOn)) + { + + + + + + + + + + } + +
@localizer["EvidenceSource"]@localizer["Title"]@localizer["Items"]@localizer["CapturedOn"]@localizer["CapturedBy"]@localizer["Checksum"]
@localizer["Evidence" + ((RmsEvidenceKind)artifact.Kind).ToString()] + @artifact.Title + @if ((RmsEvidenceClassification)artifact.Classification != RmsEvidenceClassification.Unrestricted) + { + @((RmsEvidenceClassification)artifact.Classification) + } + @if (!artifact.IsCurrent) + { + @localizer["Superseded"] + } + @artifact.SourceItemCount@When(artifact.CapturedOn)@Name(artifact.CapturedByUserId)@(artifact.Checksum?.Length > 12 ? artifact.Checksum.Substring(0, 12) : artifact.Checksum) + @if (Model.CanViewRestricted || (RmsEvidenceClassification)artifact.Classification == RmsEvidenceClassification.Unrestricted) + { + + @localizer["Manifest"] + + } +
+
+ } + else + { +

@localizer["NoEvidenceCaptured"]

+ } + + @if (Model.CanEdit && Model.IsEditable) + { +
+ @Html.AntiForgeryToken() + + + + +
+ } +
+
+
@localizer["Narrative"]
diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml index 03ed7064..84f36537 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml @@ -1,4 +1,4 @@ -@using Resgrid.Model +@using Resgrid.Model @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView @inject IStringLocalizer localizer @@ -6,6 +6,11 @@ ViewBag.Title = "Resgrid | " + localizer["EditIncidentReport"]; var reference = string.IsNullOrWhiteSpace(Model.RecordNumber) ? Model.DraftReference : Model.RecordNumber; string Dt(DateTime? v) => v.HasValue ? v.Value.ToString("yyyy-MM-ddTHH:mm") : string.Empty; + // Never render fewer rows than the report already holds: SaveDraft replaces each collection with what the + // form posts, so a persisted row the form omitted would be deleted on the next save. + var typeRowCount = Math.Max(Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.TypeRows, Model.Types.Count + 1); + var aidRowCount = Math.Max(Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.AidRows, Model.Aids.Count + 1); + var tacticRowCount = Math.Max(Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.TacticRows, Model.Tactics.Count + 1); Func badge = key => { var p = Model.ProvenanceFor(key); @@ -124,7 +129,7 @@
@localizer["IncidentTypes"] @badge(NerisFactKeys.IncidentType)
- @for (var i = 0; i < Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.TypeRows; i++) + @for (var i = 0; i < typeRowCount; i++) { var row = i < Model.Types.Count ? Model.Types[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentTypeRow();
@@ -198,7 +203,10 @@ @for (var i = 0; i < Model.AvailableUnits.Count; i++) { - var unitId = int.Parse(Model.AvailableUnits[i].Value); + if (!int.TryParse(Model.AvailableUnits[i].Value, out var unitId)) + { + continue; + } var existing = Model.Units.FirstOrDefault(u => u.UnitId == unitId); @@ -230,7 +238,7 @@
@localizer["Aid"]
- @for (var i = 0; i < Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.AidRows; i++) + @for (var i = 0; i < aidRowCount; i++) { var row = i < Model.Aids.Count ? Model.Aids[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentAidRow();
@@ -271,7 +279,7 @@
@localizer["Tactics"]
- @for (var i = 0; i < Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.TacticRows; i++) + @for (var i = 0; i < tacticRowCount; i++) { var row = i < Model.Tactics.Count ? Model.Tactics[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentTacticRow();
@@ -299,6 +307,293 @@
+
+
+
@localizer["ConditionalSections"]
+
@Model.Sections.Count
+
+
+ @if (Model.Sections.Count == 0) + { +

@localizer["NoSectionsYet"]

+ } + @foreach (var sectionView in Model.Sections) + { + var indices = Enumerable.Range(0, Model.Modules.Count).Where(i => Model.Modules[i].Kind == (int)sectionView.Kind).ToList(); +
+

+ @localizer["Section" + sectionView.Kind.ToString()] + @if (sectionView.Required) + { + @localizer["Required"] + } + else + { + @localizer["Suggested"] + } + @sectionView.PayloadPath +

+

@sectionView.Reason

+ @foreach (var i in indices) + { + var row = Model.Modules[i]; +
+
+ + +
+
+ @if (sectionView.PrimaryCodes.Count > 0) + { + + } + else + { + + } +
+
+ @if (sectionView.SecondaryCodes.Count > 0) + { + + } +
+
+
+
+
+
+
+
+
+ } + } +
+
+ +
+
@localizer["CasualtiesAndRescues"]
+
+ @if (!Model.CanEditRestricted) + { +
@localizer["RestrictedFieldsHidden"]
+ } + @for (var i = 0; i < Model.Casualties.Count + Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.CasualtyRows; i++) + { + var row = i < Model.Casualties.Count ? Model.Casualties[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentCasualtyRow(); + var isRescue = row.Kind == (int)RmsCasualtyRescueKind.Rescue; +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+ @if (Model.CanEditRestricted) + { +
+
+
+ +
+
+
+
+
+
+ } + } +
+
+ +
+
@localizer["Exposures"]
+
+ @for (var i = 0; i < Model.Exposures.Count + Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.ExposureRows; i++) + { + var row = i < Model.Exposures.Count ? Model.Exposures[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentExposureRow(); +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @foreach (var c in Model.DisplacementCauseCodes) + { + + } +
+
+ } +
+
+ +
+
+
@localizer["Resources"]
+
@localizer["ResourcesNotSubmitted"]
+
+
+ @for (var i = 0; i < Model.Resources.Count + Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.ResourceRows; i++) + { + var row = i < Model.Resources.Count ? Model.Resources[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentResourceRow(); +
+
+
+
+
+ } +
+
+
@localizer["Narrative"]
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Dashboard.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Dashboard.cshtml new file mode 100644 index 00000000..6ca40ec5 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Dashboard.cshtml @@ -0,0 +1,194 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@using Resgrid.Web.Helpers +@model Resgrid.Web.Areas.User.Models.Records.RecordsDashboardView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["RecordsDashboard"]; + var d = Model.Dashboard; + var coverage = Model.Coverage; +} + +
+
+

@localizer["RecordsDashboard"]

+ +
+
+ +
+ @if (!string.IsNullOrEmpty(Model.Message)) + { +
@Model.Message
+ } + @if (!Model.ModuleState.RecordsUsable) + { +
@localizer["RecordsNotActivated"]
+ } + + @if (d != null) + { + @if (d.Warnings.Count > 0) + { +
+ @localizer["DashboardDegraded"] +
    + @foreach (var warning in d.Warnings) + { +
  • @warning
  • + } +
+
+ } + +
+
+
+
@localizer["IncidentIncomplete"]
+
+

@d.IncidentIncomplete

+ @localizer["IncidentIncompleteHint"] +
+
+
+
+
+
@localizer["AwaitingReview"]
+
+

@(d.IncidentAwaitingReview + d.OperationalAwaitingReview)

+ @localizer["AwaitingReviewHint"] +
+
+
+
+
+
@localizer["Rejected"]
+
+

@d.IncidentRejected

+ @localizer["RejectedHint"] +
+
+
+
+
+
@localizer["Overdue"]
+
+

@d.Overdue

+ @localizer["OverdueHint"] +
+
+
+
+ +
+
+
+
@localizer["IncidentReports"]
+
+ + + + + + + + + +
@localizer["Incomplete"]@d.IncidentIncomplete
@localizer["AwaitingReview"]@d.IncidentAwaitingReview
@localizer["Submitted"]@d.IncidentSubmitted
@localizer["Accepted"]@d.IncidentAccepted
@localizer["Rejected"]@d.IncidentRejected
@localizer["AnalysesAwaitingFiling"]@d.AnalysesAwaitingFiling
+ @localizer["OpenIncidentQueue"] +
+
+
+ +
+
+
@localizer["OperationalRecords"]
+
+ + + + + + +
@localizer["Drafts"]@d.OperationalDrafts
@localizer["AwaitingReview"]@d.OperationalAwaitingReview
@localizer["Returned"]@d.OperationalReturned
+ @localizer["OpenRecordsQueue"] +
+
+ + @if (Model.CanManageDisclosures) + { +
+
@localizer["Disclosures"]
+
+ + + + + +
@localizer["Open"]@d.DisclosuresOpen
@localizer["Overdue"]@d.DisclosuresOverdue
+ @localizer["OpenDisclosureQueue"] +
+
+ } +
+
+ } + + @if (coverage != null) + { +
+
+
@localizer["CrosswalkCoverage"]
+
+ @coverage.MappedCount @localizer["Mapped"] + @coverage.UnmappedCount @localizer["Unmapped"] + @if (coverage.StaleMappingCount > 0) + { + @coverage.StaleMappingCount @localizer["Stale"] + } + @coverage.ContractVersion +
+
+
+

@localizer["CrosswalkCoverageIntro"]

+ @if (coverage.Warnings.Count > 0) + { +
+
    + @foreach (var warning in coverage.Warnings) + { +
  • @warning
  • + } +
+
+ } + @if (coverage.Items.Count > 0) + { +
+ + + + @foreach (var item in coverage.Items.OrderBy(i => i.Mapped).ThenBy(i => i.LocalCode)) + { + + + + + + } + +
@localizer["SetKey"]@localizer["LocalCode"]@localizer["NerisCode"]
@item.SetKey@item.LocalCode@(item.Mapped ? item.NerisCode : localizer["Unmapped"].Value)
+
+ } + @if (Model.IsDepartmentAdmin) + { + @localizer["EditCrosswalk"] + } +
+
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml index 1c95cc2e..d20092e8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml @@ -1,4 +1,4 @@ -@using Resgrid.Model +@using Resgrid.Model @using Resgrid.Model.Helpers @using Resgrid.Web.Helpers @using Resgrid.Web.Areas.User.Models.Records @@ -39,6 +39,11 @@ @if (Model.ModuleState.RecordsUsable) { @localizer["IncidentReports"] + @localizer["RecordsDashboard"] + } + @if (Model.ModuleState.RecordsUsable && ClaimsAuthorizationHelper.CanManageRecordDisclosures()) + { + @localizer["Disclosures"] } @if (Model.ModuleState.RecordsUsable && ClaimsAuthorizationHelper.CanExportRecords()) { diff --git a/Workers/Resgrid.Workers.Console/Commands/RmsDueStateEvaluationCommand.cs b/Workers/Resgrid.Workers.Console/Commands/RmsDueStateEvaluationCommand.cs new file mode 100644 index 00000000..cd131437 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/RmsDueStateEvaluationCommand.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + /// Worker ID 42 (registry section 3.3, RMS-3): the bounded RecordOverdue due-state evaluation (RMS plan section 4.7). + public sealed class RmsDueStateEvaluationCommand : IQuidjiboCommand + { + public RmsDueStateEvaluationCommand(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/RmsRetentionAndPurgeCommand.cs b/Workers/Resgrid.Workers.Console/Commands/RmsRetentionAndPurgeCommand.cs new file mode 100644 index 00000000..38eef0fd --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/RmsRetentionAndPurgeCommand.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + /// Worker ID 43 (registry section 3.3, RMS-3): retention, legal hold and attachment purge, plus the Pending-attachment rescan. + public sealed class RmsRetentionAndPurgeCommand : IQuidjiboCommand + { + public RmsRetentionAndPurgeCommand(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 9e4284dc..2cae5746 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -507,6 +507,23 @@ await Client.ScheduleAsync("Records Submission", Cron.MinuteIntervals(1), stoppingToken); + // Worker ID 42 (Identifier Allocation Registry section 3.3, RMS-3): the bounded RecordOverdue evaluation. + // Daily rather than minutely: an obligation that fell due overnight is chased once the next morning, + // and the emit-once guarantee comes from the persisted due-state row, not from this cadence. + _logger.Log(LogLevel.Information, "Scheduling RMS Due State Evaluation"); + await Client.ScheduleAsync("RMS Due State Evaluation", + new Commands.RmsDueStateEvaluationCommand(42), + Cron.Daily(4, 0), + stoppingToken); + + // Worker ID 43 (Identifier Allocation Registry section 3.3, RMS-3): retention, legal hold and + // attachment purge, plus the rescan of attachments the scanner could not reach at upload time. + _logger.Log(LogLevel.Information, "Scheduling RMS Retention And Purge"); + await Client.ScheduleAsync("RMS Retention And Purge", + new Commands.RmsRetentionAndPurgeCommand(43), + Cron.Daily(3, 30), + stoppingToken); + if (SystemBehaviorConfig.Utf8CleanupEnabled) { var utf8CleanupHour = SystemBehaviorConfig.Utf8CleanupHourUtc >= 0 && SystemBehaviorConfig.Utf8CleanupHourUtc <= 23 diff --git a/Workers/Resgrid.Workers.Console/Tasks/RmsDueStateEvaluationTask.cs b/Workers/Resgrid.Workers.Console/Tasks/RmsDueStateEvaluationTask.cs new file mode 100644 index 00000000..479d0627 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/RmsDueStateEvaluationTask.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class RmsDueStateEvaluationTask : IQuidjiboHandler + { + private readonly ILogger _logger; + + public RmsDueStateEvaluationTask(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string Name => "RMS Due State Evaluation"; + public int Priority => 1; + + public async Task ProcessAsync(RmsDueStateEvaluationCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + progress?.Report(1, $"Starting the {Name} Task"); + + try + { + var logic = new RmsDueStateEvaluationLogic(); + var result = await logic.Process(cancellationToken); + + if (!result.Item1) + throw new InvalidOperationException(result.Item2); + + _logger.LogInformation("RmsDueStateEvaluation::{Summary}", result.Item2); + progress?.Report(100, $"Finishing the {Name} Task"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + } + } +} diff --git a/Workers/Resgrid.Workers.Console/Tasks/RmsRetentionAndPurgeTask.cs b/Workers/Resgrid.Workers.Console/Tasks/RmsRetentionAndPurgeTask.cs new file mode 100644 index 00000000..9af8704c --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/RmsRetentionAndPurgeTask.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class RmsRetentionAndPurgeTask : IQuidjiboHandler + { + private readonly ILogger _logger; + + public RmsRetentionAndPurgeTask(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string Name => "RMS Retention And Purge"; + public int Priority => 1; + + public async Task ProcessAsync(RmsRetentionAndPurgeCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + progress?.Report(1, $"Starting the {Name} Task"); + + try + { + var logic = new RmsRetentionAndPurgeLogic(); + var result = await logic.Process(cancellationToken); + + if (!result.Item1) + throw new InvalidOperationException(result.Item2); + + _logger.LogInformation("RmsRetentionAndPurge::{Summary}", result.Item2); + progress?.Report(100, $"Finishing the {Name} Task"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs index 0bc27e04..1d6cb090 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs @@ -33,6 +33,18 @@ public static async Task ProcessNotificationItem(NotificationItem ni, stri return true; } + if (ni?.Type == (int)EventTypes.RecordReviewOverdue) + { + // Records notification 32 (RMS-3, worker 42): targeted at whoever the obligation rests with, so it + // bypasses the department settings pipeline like 31 and 33. Value is "{recordId}|{obligation}" — + // a record id never contains a pipe, so the split is unambiguous. + var parts = (ni.Value ?? string.Empty).Split('|'); + var obligation = parts.Length > 1 && int.TryParse(parts[1], out var parsed) ? (RmsRecordObligation)parsed : RmsRecordObligation.Review; + var recordsNotificationService = Bootstrapper.GetKernel().Resolve(); + await recordsNotificationService.NotifyObligationOverdueAsync(ni.DepartmentId, parts[0], obligation, cancellationToken); + return true; + } + if (ni?.Type == (int)EventTypes.RecordSubmissionRejected) { // Records notification 33 (RMS-2): the destination rejected the author's incident report; author-targeted like 31. diff --git a/Workers/Resgrid.Workers.Framework/Logic/RmsDueStateEvaluationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/RmsDueStateEvaluationLogic.cs new file mode 100644 index 00000000..0e65b5f6 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/RmsDueStateEvaluationLogic.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Framework; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// Worker command 42 (RMS plan RMS-3): the bounded RecordOverdue evaluation. Drives + /// IRecordsDueStateService, which decides what to emit from the persisted due-state rows rather than from + /// when this worker last ran — so a skipped run emits late instead of never, and a repeated run stays quiet. + /// + public sealed class RmsDueStateEvaluationLogic + { + public async Task> Process(CancellationToken cancellationToken) + { + try + { + var service = Bootstrapper.GetKernel().Resolve(); + var result = await service.SweepAsync(cancellationToken); + + if (result.Errors > 0) + Logging.LogError($"Records due-state sweep finished with {result.Errors} error(s): {result.Message}"); + + return new Tuple(true, result.Message); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex); + return new Tuple(false, ex.ToString()); + } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/RmsRetentionAndPurgeLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/RmsRetentionAndPurgeLogic.cs new file mode 100644 index 00000000..601ec43d --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/RmsRetentionAndPurgeLogic.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Framework; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// Worker command 43 (RMS plan RMS-3): retention, legal hold and attachment purge, plus the rescan pass for + /// attachments the scanner could not reach at upload time. Drives IRecordsRetentionService; every pass is + /// bounded per department so an overdue first run cannot monopolise the worker. + /// + public sealed class RmsRetentionAndPurgeLogic + { + public async Task> Process(CancellationToken cancellationToken) + { + try + { + var service = Bootstrapper.GetKernel().Resolve(); + var result = await service.SweepAsync(cancellationToken); + + if (result.Errors > 0) + Logging.LogError($"Records retention sweep finished with {result.Errors} error(s): {result.Message}"); + + return new Tuple(true, result.Message); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex); + return new Tuple(false, ex.ToString()); + } + } + } +}