diff --git a/Core/Resgrid.Config/RecordsConnectorConfig.cs b/Core/Resgrid.Config/RecordsConnectorConfig.cs new file mode 100644 index 00000000..fef6e166 --- /dev/null +++ b/Core/Resgrid.Config/RecordsConnectorConfig.cs @@ -0,0 +1,37 @@ +namespace Resgrid.Config +{ + /// + /// External ordering-system connectors for mutual-aid deployments (RMS plan section 4.1). Off unless this + /// switch is on and a credential passphrase is set: a connector holds an outbound credential encrypted under + /// the passphrase, so without one no connector can be created. Environment keys: + /// RESGRID:RecordsConnectorConfig:Enabled, :CredentialPassphrase, :TimeoutSeconds, :MaxFeedBytes, + /// :MinPollIntervalMinutes, :DefaultMaxRequestsPerHour, :AllowHttp. + /// + public static class RecordsConnectorConfig + { + /// Master switch for every connector in every process. Off means no poll, no push, no create. + public static bool Enabled = false; + + /// Passphrase for the symmetric encryption of stored connector credentials. Empty refuses connector creation. + public static string CredentialPassphrase = ""; + + public static int TimeoutSeconds = 30; + + /// Largest feed page accepted; anything bigger is refused unread. + public static int MaxFeedBytes = 5 * 1024 * 1024; + + /// A connector cannot poll faster than this, whatever it is configured to. + public static int MinPollIntervalMinutes = 15; + + public static int DefaultMaxRequestsPerHour = 12; + + /// http feed roots are refused unless this is on (local development only). + public static bool AllowHttp = false; + + /// Consecutive failures after which a connector is switched off until an administrator looks. + public static int DisableAfterConsecutiveFailures = 10; + + /// Connector runs kept per department for the run log. + public static int RunHistoryToKeep = 200; + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index 2751a5d2..c58f534f 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -1122,6 +1122,7 @@ عرض التسلسل تعيين الرقم تسلسل لكل محطة + تسلسل لكل حادث إعادة تعيين سنويًا التصنيف الاحتفاظ (سنوات) @@ -1315,4 +1316,106 @@ ملخص الموارد المؤهلات ملخص القيادة + طرح سجلات الميدان + كيف يسير كل تطبيق تشغيلي مع السجلات: من لديه إصدار صالح، وما الذي رُفض للتطبيقات، وأين توقفت الكتابة. أعداد فقط — لا تعرض هذه الصفحة أبدًا ما كتبه أحد. + عرض الطرح غير متاح لحسابك. + لا يوجد تطبيق تشغيلي مفعّل به سجلات الميدان بعد. + {0} يوم + مفعّل + معطّل + الأشخاص المستخدمون + على إصدار صالح + الحد الأدنى {0} + السجلات + {0} بدأت، {1} أُنهيت + طلبات الكتالوج + {0} مرفوضة + الكتابة + {0} بدأت، {1} أُرسلت، {2} فشل إرسالها + المزامنات + التعارضات المعروضة + المرفقات + {0} تم رفعها، {1} فشلت + أُنجزت في التطبيق + {0}، الوسيط {1} دقيقة + تُركت غير مكتملة + أُحيلت إلى تطبيق الويب + الإصدارات المستخدمة + الإصدار + الأشخاص + قديم جدًا + لماذا رُفض الكتالوج + أُنشئ {0}. + Responder + الوحدة + قيادة الحادث + الإرسال + تم الاحتفاظ بموقع الصورة + تمت إزالة موقع الصورة + الاحتفاظ بإحداثيات الصور في المرفقات + معطّل افتراضيًا: تُزال بيانات موقع الصورة وتفاصيل الجهاز عند الرفع. فعّله فقط عندما يحتاج النموذج فعليًا إلى مكان التقاط الصورة، مثل تقييم الأضرار أو تقرير الأدلة. + الموصلات الخارجية + يقرأ طلبات الموارد من نظام طلبات خارجي عبر تغذية موثقة وبيانات اعتماد مشفرة وحد للطلبات وشروط المصدر. يبقى المصدر هو المرجع: كل تغيير يُحفظ كلقطة جديدة ولا يتغير أي شيء محلي تلقائيًا. + موصل جديد + اسم الموصل + مزود التغذية + النظام المصدر + جذر التغذية (https) + يجب أن يوفر المصدر Resgrid Mutual-Aid Order Feed v1 (JSON). لا يُدعم كشط الشاشة ولا التحديثات المستنتجة. + نوع بيانات الاعتماد + اسم الترويسة + بيانات الاعتماد + مشفرة أثناء التخزين ولا تُعرض مرة أخرى. اتركه فارغًا للاحتفاظ ببيانات الاعتماد المخزنة. + تم تخزين بيانات الاعتماد + تم منح صلاحية القراءة + لا تُمنح صلاحية الكتابة في هذا الإصدار. لا يُكتب أي شيء أبدًا إلى نظام الطلبات. + فترة الاستطلاع (دقائق) + طلبات في الساعة + شروط المصدر + الإقرار بشروط المصدر + تم الإقرار بالشروط + لم يتم الإقرار بالشروط بعد + تفعيل الموصل + تعطيل الموصل + مفعّل + معطّل + تم تفعيل الموصل. + تم تعطيل الموصل. + تم الإقرار بشروط المصدر. + قراءة التغذية الآن + إصدار رمز وارد جديد + الرمز الوارد + انسخ هذا الرمز الآن. يُعرض مرة واحدة فقط؛ يحتفظ الخادم بالتجزئة فقط. + نقطة الاستلام الواردة + حذف الموصل + هل تريد حذف هذا الموصل؟ يتم الاحتفاظ بعمليات النشر المستوردة ولقطاتها. + سجل التشغيل + المُشغِّل + النتيجة + المرئية / المنشأة / اللقطات / الطلبات / بدون تغيير / المرفوضة / التعارضات + آخر قراءة + آخر نجاح + آخر خطأ + تم حفظ الموصل. + تم إنشاء الموصل. يبقى معطّلاً حتى يتم الإقرار بشروط المصدر وتفعيله. + تم حذف الموصل. + انتهت قراءة التغذية (المنشأة / اللقطات / الطلبات / التعارضات). + تم إصدار رمز وارد جديد؛ لم يعد الرمز القديم يعمل. + الموصلات الخارجية معطّلة في هذا التثبيت. + حالة الموصل + جاهز + غير جاهز + المطابقة + حيث تختلف أحدث لقطة من المصدر عن سجل النشر لهذا القسم. لا يُطبق أي شيء هنا تلقائيًا؛ يقرر المنسق. + لا توجد اختلافات بين المصدر وسجلاتك. + الاختلاف + يقول المصدر + يقول سجلك + أفرج المصدر عن الطلب، لكن أحد التعيينات لم يعد بعد + أغلق المصدر الطلب، لكن التعيينات لا تزال مفتوحة + الطلب موجود في المصدر لكن ليس محليًا + التعيين موجود محليًا لكن ليس في المصدر + حالة المصدر متقدمة على التعيين المحلي + حالة المصدر متأخرة عن التعيين المحلي + يُدار بواسطة موصل diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index c64259a8..2083ab89 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -1122,6 +1122,7 @@ Sequenzbreite Nummer vergeben Sequenz pro Wache + Sequenz pro Einsatz Jährlich zurücksetzen Klassifizierung Aufbewahrung (Jahre) @@ -1315,4 +1316,106 @@ Ressourcenübersicht Qualifikationen Führungsübersicht + Einführung von Außendienst-Datensätzen + Wie sich jede Einsatz-App mit Datensätzen schlägt: wer eine nutzbare Version hat, was den Apps verweigert wurde und wo das Erfassen abbrach. Nur Zählwerte — diese Seite zeigt nie, was jemand geschrieben hat. + Die Rollout-Ansicht ist für Ihr Konto nicht verfügbar. + Für keine Einsatz-App sind Außendienst-Datensätze aktiviert. + {0} Tage + Ein + Aus + Nutzende Personen + Mit nutzbarer Version + mindestens {0} + Datensätze + {0} begonnen, {1} abgeschlossen + Katalogabrufe + {0} abgelehnt + Erfassen + {0} begonnen, {1} gesendet, {2} nicht gesendet + Synchronisierungen + Angezeigte Konflikte + Anhänge + {0} hochgeladen, {1} fehlgeschlagen + In der App abgeschlossen + {0}, Median {1} Minuten + Unfertig verlassen + An die Web-App weitergegeben + Verwendete Versionen + Version + Personen + Zu alt + Warum der Katalog verweigert wurde + Erstellt {0}. + Responder + Einheit + Einsatzleitung + Disposition + Fotostandort beibehalten + Fotostandort entfernt + Fotokoordinaten an Anhängen behalten + Standardmäßig aus: Standort und Gerätedaten eines Fotos werden beim Hochladen entfernt. Nur aktivieren, wenn das Formular den Aufnahmeort wirklich benötigt, etwa bei einer Schadensbewertung oder einem Hinweisbericht. + Externe Konnektoren + Ressourcenaufträge aus einem externen Bestellsystem über einen dokumentierten Feed, verschlüsselte Zugangsdaten, ein Anfragelimit und die Bedingungen der Quelle lesen. Die Quelle bleibt maßgeblich: jede Änderung wird als neuer Schnappschuss abgelegt, und lokal wird nichts automatisch geändert. + Neuer Konnektor + Konnektorname + Feed-Anbieter + Quellsystem + Feed-Stammadresse (https) + Die Quelle muss den Resgrid Mutual-Aid Order Feed v1 (JSON) bereitstellen. Screen-Scraping und abgeleitete Aktualisierungen werden nicht unterstützt. + Art der Zugangsdaten + Header-Name + Zugangsdaten + Verschlüsselt gespeichert und nie wieder angezeigt. Leer lassen, um die gespeicherten Zugangsdaten zu behalten. + Zugangsdaten gespeichert + Leseberechtigung erteilt + Schreibberechtigung wird in dieser Version nicht erteilt. Es wird nie etwas in das Bestellsystem zurückgeschrieben. + Abfrageintervall (Minuten) + Anfragen pro Stunde + Bedingungen der Quelle + Bedingungen der Quelle bestätigen + Bedingungen bestätigt + Bedingungen noch nicht bestätigt + Konnektor aktivieren + Konnektor deaktivieren + Aktiviert + Deaktiviert + Konnektor aktiviert. + Konnektor deaktiviert. + Die Bedingungen der Quelle wurden bestätigt. + Feed jetzt lesen + Neues Eingangs-Token ausstellen + Eingangs-Token + Kopieren Sie dieses Token jetzt. Es wird nur einmal angezeigt; der Server speichert nur einen Hash. + Endpunkt für eingehende Übermittlung + Konnektor löschen + Diesen Konnektor löschen? Importierte Einsätze und ihre Schnappschüsse bleiben erhalten. + Ausführungsprotokoll + Auslöser + Ergebnis + Gesehen / erstellt / Schnappschüsse / Anfragen / unverändert / abgelehnt / Konflikte + Zuletzt gelesen + Letzter Erfolg + Letzter Fehler + Konnektor gespeichert. + Konnektor erstellt. Er bleibt deaktiviert, bis die Bedingungen der Quelle bestätigt sind und er aktiviert wird. + Konnektor gelöscht. + Feed-Lesevorgang abgeschlossen (erstellt / Schnappschüsse / Anfragen / Konflikte). + Ein neues Eingangs-Token wurde ausgestellt; das alte funktioniert nicht mehr. + Externe Konnektoren sind für diese Installation abgeschaltet. + Konnektorzustand + Bereit + Nicht bereit + Abgleich + Wo der letzte Schnappschuss der Quelle und der Einsatzdatensatz dieser Abteilung voneinander abweichen. Nichts davon wird automatisch angewendet; ein Koordinator entscheidet. + Keine Abweichungen zwischen der Quelle und Ihren Datensätzen. + Abweichung + Quelle meldet + Ihr Datensatz meldet + Quelle hat den Auftrag freigegeben, aber eine Besetzung ist noch nicht zurückgekehrt + Quelle hat den Auftrag geschlossen, aber Besetzungen sind noch offen + Anfrage existiert in der Quelle, aber nicht lokal + Besetzung existiert lokal, aber nicht in der Quelle + Quellstatus ist der lokalen Besetzung voraus + Quellstatus liegt hinter der lokalen Besetzung + Von einem Konnektor gepflegt diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index ee0f9afc..ff416765 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -1122,6 +1122,7 @@ Πλάτος ακολουθίας Ανάθεση αριθμού Ακολουθία ανά σταθμό + Ακολουθία ανά συμβάν Επαναφορά ετησίως Ταξινόμηση Διατήρηση (έτη) @@ -1315,4 +1316,106 @@ Σύνοψη πόρων Προσόντα Σύνοψη διοίκησης + Ανάπτυξη εγγραφών πεδίου + Πώς τα πάει κάθε επιχειρησιακή εφαρμογή με τις εγγραφές: ποιος έχει χρησιμοποιήσιμη έκδοση, τι απορρίφθηκε και πού σταμάτησε η σύνταξη. Μόνο μετρήσεις — η σελίδα δεν δείχνει ποτέ τι έγραψε κάποιος. + Η προβολή ανάπτυξης δεν είναι διαθέσιμη για τον λογαριασμό σας. + Καμία επιχειρησιακή εφαρμογή δεν έχει ενεργές εγγραφές πεδίου. + {0} ημέρες + Ενεργό + Ανενεργό + Άτομα που το χρησιμοποιούν + Σε χρησιμοποιήσιμη έκδοση + ελάχιστη {0} + Εγγραφές + {0} ξεκίνησαν, {1} οριστικοποιήθηκαν + Αιτήματα καταλόγου + {0} απορρίφθηκαν + Σύνταξη + {0} ξεκίνησαν, {1} στάλθηκαν, {2} απέτυχαν + Συγχρονισμοί + Εμφανισμένες συγκρούσεις + Συνημμένα + {0} μεταφορτώθηκαν, {1} απέτυχαν + Ολοκληρώθηκαν στην εφαρμογή + {0}, διάμεσος {1} λεπτά + Έμειναν ημιτελείς + Στάλθηκαν στην εφαρμογή web + Εκδόσεις σε χρήση + Έκδοση + Άτομα + Πολύ παλιά + Γιατί απορρίφθηκε ο κατάλογος + Δημιουργήθηκε {0}. + Responder + Μονάδα + Διοίκηση συμβάντος + Αποστολή + Διατηρήθηκε η τοποθεσία της φωτογραφίας + Αφαιρέθηκε η τοποθεσία της φωτογραφίας + Διατήρηση συντεταγμένων φωτογραφιών στα συνημμένα + Απενεργοποιημένο από προεπιλογή: η τοποθεσία και τα στοιχεία συσκευής αφαιρούνται κατά τη μεταφόρτωση. Ενεργοποιήστε το μόνο όταν η φόρμα χρειάζεται πραγματικά το σημείο λήψης, όπως εκτίμηση ζημιών ή αναφορά ίχνους. + Εξωτερικοί σύνδεσμοι + Διαβάζει παραγγελίες πόρων από εξωτερικό σύστημα παραγγελιών μέσω τεκμηριωμένης ροής, κρυπτογραφημένου διαπιστευτηρίου, ορίου αιτημάτων και των όρων της πηγής. Η πηγή παραμένει αυθεντική: κάθε αλλαγή καταγράφεται ως νέο στιγμιότυπο και τίποτα τοπικό δεν αλλάζει αυτόματα. + Νέος σύνδεσμος + Όνομα συνδέσμου + Πάροχος ροής + Σύστημα πηγής + Ρίζα ροής (https) + Η πηγή πρέπει να παρέχει το Resgrid Mutual-Aid Order Feed v1 (JSON). Η απόξεση οθόνης και οι συναγόμενες ενημερώσεις δεν υποστηρίζονται. + Είδος διαπιστευτηρίου + Όνομα κεφαλίδας + Διαπιστευτήριο + Κρυπτογραφείται σε ηρεμία και δεν εμφανίζεται ξανά. Αφήστε το κενό για να διατηρηθεί το αποθηκευμένο διαπιστευτήριο. + Διαπιστευτήριο αποθηκευμένο + Χορηγήθηκε εξουσιοδότηση ανάγνωσης + Σε αυτήν την έκδοση δεν χορηγείται εξουσιοδότηση εγγραφής. Τίποτα δεν γράφεται ποτέ πίσω στο σύστημα παραγγελιών. + Διάστημα ελέγχου (λεπτά) + Αιτήματα ανά ώρα + Όροι πηγής + Αποδοχή των όρων της πηγής + Όροι αποδεκτοί + Οι όροι δεν έχουν γίνει ακόμη αποδεκτοί + Ενεργοποίηση συνδέσμου + Απενεργοποίηση συνδέσμου + Ενεργός + Ανενεργός + Ο σύνδεσμος ενεργοποιήθηκε. + Ο σύνδεσμος απενεργοποιήθηκε. + Οι όροι της πηγής έγιναν αποδεκτοί. + Ανάγνωση ροής τώρα + Έκδοση νέου εισερχόμενου διακριτικού + Εισερχόμενο διακριτικό + Αντιγράψτε αυτό το διακριτικό τώρα. Εμφανίζεται μία φορά· ο διακομιστής κρατά μόνο ένα hash. + Σημείο εισερχόμενης παράδοσης + Διαγραφή συνδέσμου + Διαγραφή αυτού του συνδέσμου; Οι εισαγόμενες αναπτύξεις και τα στιγμιότυπά τους διατηρούνται. + Ημερολόγιο εκτελέσεων + Έναυσμα + Αποτέλεσμα + Είδαμε / δημιουργήθηκαν / στιγμιότυπα / αιτήματα / αμετάβλητα / απορρίφθηκαν / συγκρούσεις + Τελευταία ανάγνωση + Τελευταία επιτυχία + Τελευταίο σφάλμα + Ο σύνδεσμος αποθηκεύτηκε. + Ο σύνδεσμος δημιουργήθηκε. Παραμένει ανενεργός μέχρι να γίνουν αποδεκτοί οι όροι της πηγής και να ενεργοποιηθεί. + Ο σύνδεσμος διαγράφηκε. + Η ανάγνωση της ροής ολοκληρώθηκε (δημιουργήθηκαν / στιγμιότυπα / αιτήματα / συγκρούσεις). + Εκδόθηκε νέο εισερχόμενο διακριτικό· το παλιό δεν λειτουργεί πλέον. + Οι εξωτερικοί σύνδεσμοι είναι απενεργοποιημένοι για αυτήν την εγκατάσταση. + Κατάσταση συνδέσμου + Έτοιμος + Μη έτοιμος + Συμφωνία + Όπου το τελευταίο στιγμιότυπο της πηγής και η εγγραφή ανάπτυξης αυτού του τμήματος διαφωνούν. Τίποτα δεν εφαρμόζεται αυτόματα· αποφασίζει ένας συντονιστής. + Καμία διαφωνία μεταξύ της πηγής και των εγγραφών σας. + Διαφωνία + Η πηγή αναφέρει + Η εγγραφή σας αναφέρει + Η πηγή αποδέσμευσε την παραγγελία, αλλά μια κάλυψη δεν έχει επιστρέψει ακόμη + Η πηγή έκλεισε την παραγγελία, αλλά υπάρχουν ακόμη ανοιχτές καλύψεις + Το αίτημα υπάρχει στην πηγή αλλά όχι τοπικά + Η κάλυψη υπάρχει τοπικά αλλά όχι στην πηγή + Η κατάσταση της πηγής προηγείται της τοπικής κάλυψης + Η κατάσταση της πηγής υστερεί της τοπικής κάλυψης + Συντηρείται από σύνδεσμο diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index 7958c84e..c80b208e 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -1122,6 +1122,7 @@ Sequence width Assign number Sequence per station + Sequence per incident Reset yearly Classification Retention (years) @@ -1315,4 +1316,106 @@ Resource summary Qualifications Command summary + Field Records rollout + How each operational app is doing with Records: who is on a version that can use it, what the apps were refused, and where authoring stopped. Counts only — this page never shows what anybody wrote. + The rollout view is not available for your account. + No operational app has Field Records switched on yet. + {0} days + On + Off + People using it + On a usable version + minimum {0} + Records + {0} started, {1} finalized + Catalog requests + {0} refused + Authoring + {0} started, {1} sent, {2} failed to send + Syncs + Conflicts shown + Attachments + {0} uploaded, {1} failed + Completed in the app + {0}, median {1} minutes + Left unfinished + Sent to the web app + Versions in use + Version + People + Too old + Why the catalog was refused + Generated {0}. + Responder + Unit + Incident Command + Dispatch + Photo location kept + Photo location removed + Keep photo coordinates on attachments + Off by default: a photo's location and device details are stripped on upload. Turn this on only when the form genuinely needs where the photo was taken, such as a damage assessment or a clue report. + External connectors + Read resource orders from an external ordering system under a documented feed, an encrypted credential, a request limit and the source's terms. The source stays authoritative: every change lands as a new snapshot and nothing local is changed automatically. + New connector + Connector name + Feed provider + Source system + Feed root (https) + The source must serve the Resgrid Mutual-Aid Order Feed v1 (JSON). Screen-scraping and inferred updates are not supported. + Credential kind + Header name + Credential + Encrypted at rest and never shown again. Leave blank to keep the stored credential. + Credential stored + Read authority granted + Write authority is not granted in this release. Nothing is ever written back to the ordering system. + Poll interval (minutes) + Requests per hour + Source terms + Acknowledge the source's terms + Terms acknowledged + Terms not yet acknowledged + Enable connector + Disable connector + Enabled + Disabled + Connector enabled. + Connector disabled. + The source's terms were acknowledged. + Read feed now + Issue a new inbound token + Inbound token + Copy this token now. It is shown once; the server keeps only a hash. + Inbound push endpoint + Delete connector + Delete this connector? Imported deployments and their snapshots are kept. + Run log + Trigger + Outcome + Seen / created / snapshots / requests / unchanged / rejected / conflicts + Last read + Last success + Last error + Connector saved. + Connector created. It stays disabled until the source's terms are acknowledged and it is enabled. + Connector deleted. + Feed read finished (created / snapshots / requests / conflicts). + A new inbound token was issued; the old one no longer works. + External connectors are switched off for this installation. + Connector state + Ready + Not ready + Reconciliation + Where the source's latest snapshot and this department's deployment record disagree. Nothing here is applied automatically; a coordinator decides. + No disagreements between the source and your records. + Disagreement + Source says + Your record says + Source released the order, but a fill is not yet returned + Source closed the order, but fills are still open + Request exists in the source but not locally + Fill exists locally but not in the source + Source status is ahead of the local fill + Source status is behind the local fill + Maintained by a connector diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index c8f61428..e5a9c610 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -1122,6 +1122,7 @@ Ancho de secuencia Asignar número Secuencia por estación + Secuencia por incidente Reiniciar anualmente Clasificación Retención (años) @@ -1315,4 +1316,106 @@ Resumen de recursos Cualificaciones Resumen de mando + Despliegue de registros en campo + Cómo va cada aplicación operativa con los registros: quién tiene una versión utilizable, qué se les denegó y dónde se detuvo la redacción. Solo recuentos: esta página nunca muestra lo que alguien escribió. + La vista de despliegue no está disponible para su cuenta. + Ninguna aplicación operativa tiene activados los registros en campo. + {0} días + Activado + Desactivado + Personas que lo usan + Con una versión utilizable + mínimo {0} + Registros + {0} iniciados, {1} finalizados + Solicitudes de catálogo + {0} rechazadas + Redacción + {0} iniciados, {1} enviados, {2} fallaron al enviar + Sincronizaciones + Conflictos mostrados + Adjuntos + {0} subidos, {1} fallidos + Completados en la aplicación + {0}, mediana {1} minutos + Dejados sin terminar + Enviados a la aplicación web + Versiones en uso + Versión + Personas + Demasiado antigua + Por qué se denegó el catálogo + Generado {0}. + Responder + Unidad + Mando de incidentes + Despacho + Ubicación de la foto conservada + Ubicación de la foto eliminada + Conservar las coordenadas de las fotos en los adjuntos + Desactivado por defecto: la ubicación y los datos del dispositivo se eliminan al subir. Actívelo solo si el formulario necesita realmente dónde se tomó la foto, como una evaluación de daños o un informe de indicios. + Conectores externos + Lee órdenes de recursos de un sistema de pedidos externo mediante un feed documentado, una credencial cifrada, un límite de solicitudes y las condiciones de la fuente. La fuente sigue siendo la autoridad: cada cambio se guarda como una nueva instantánea y nada local cambia automáticamente. + Nuevo conector + Nombre del conector + Proveedor del feed + Sistema de origen + Raíz del feed (https) + La fuente debe servir el Resgrid Mutual-Aid Order Feed v1 (JSON). No se admite el raspado de pantalla ni las actualizaciones inferidas. + Tipo de credencial + Nombre del encabezado + Credencial + Se cifra en reposo y no vuelve a mostrarse. Déjelo en blanco para conservar la credencial guardada. + Credencial guardada + Autoridad de lectura concedida + En esta versión no se concede autoridad de escritura. Nunca se escribe nada de vuelta en el sistema de pedidos. + Intervalo de sondeo (minutos) + Solicitudes por hora + Condiciones de la fuente + Aceptar las condiciones de la fuente + Condiciones aceptadas + Condiciones aún no aceptadas + Activar conector + Desactivar conector + Activado + Desactivado + Conector activado. + Conector desactivado. + Se aceptaron las condiciones de la fuente. + Leer el feed ahora + Emitir un nuevo token de entrada + Token de entrada + Copie este token ahora. Se muestra una sola vez; el servidor conserva solo un hash. + Punto de entrada para envíos + Eliminar conector + ¿Eliminar este conector? Los despliegues importados y sus instantáneas se conservan. + Registro de ejecuciones + Desencadenante + Resultado + Vistos / creados / instantáneas / solicitudes / sin cambios / rechazados / conflictos + Última lectura + Último éxito + Último error + Conector guardado. + Conector creado. Permanece desactivado hasta que se acepten las condiciones de la fuente y se active. + Conector eliminado. + Lectura del feed finalizada (creados / instantáneas / solicitudes / conflictos). + Se emitió un nuevo token de entrada; el anterior ya no funciona. + Los conectores externos están desactivados en esta instalación. + Estado del conector + Listo + No listo + Conciliación + Dónde difieren la última instantánea de la fuente y el registro de despliegue de este departamento. Nada de esto se aplica automáticamente; decide un coordinador. + No hay discrepancias entre la fuente y sus registros. + Discrepancia + La fuente indica + Su registro indica + La fuente liberó la orden, pero una asignación aún no ha regresado + La fuente cerró la orden, pero hay asignaciones aún abiertas + La solicitud existe en la fuente pero no localmente + La asignación existe localmente pero no en la fuente + El estado de la fuente va por delante de la asignación local + El estado de la fuente va por detrás de la asignación local + Mantenido por un conector diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index cbcc50cc..3152008d 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -1122,6 +1122,7 @@ Largeur de séquence Attribuer le numéro Séquence par caserne + Séquence par incident Réinitialiser chaque année Classification Conservation (années) @@ -1315,4 +1316,106 @@ Synthèse des ressources Qualifications Synthèse du commandement + Déploiement des enregistrements terrain + Où en est chaque application opérationnelle avec les enregistrements : qui dispose d'une version utilisable, ce qui a été refusé aux applications et où la saisie s'est arrêtée. Uniquement des comptages : cette page ne montre jamais ce qui a été écrit. + La vue de déploiement n'est pas disponible pour votre compte. + Aucune application opérationnelle n'a activé les enregistrements terrain. + {0} jours + Activé + Désactivé + Personnes l'utilisant + Sur une version utilisable + minimum {0} + Enregistrements + {0} commencés, {1} finalisés + Requêtes de catalogue + {0} refusées + Saisie + {0} commencés, {1} envoyés, {2} échecs d'envoi + Synchronisations + Conflits présentés + Pièces jointes + {0} téléversés, {1} échecs + Terminés dans l'application + {0}, médiane {1} minutes + Abandonnés en cours + Renvoyés à l'application web + Versions utilisées + Version + Personnes + Trop ancienne + Pourquoi le catalogue a été refusé + Généré {0}. + Responder + Unité + Commandement d'incident + Répartition + Localisation de la photo conservée + Localisation de la photo supprimée + Conserver les coordonnées des photos sur les pièces jointes + Désactivé par défaut : la localisation et les données de l'appareil sont supprimées au téléversement. À activer uniquement si le formulaire a réellement besoin du lieu de prise de vue, comme une évaluation des dégâts ou un rapport d'indice. + Connecteurs externes + Lit les ordres de ressources d'un système de commande externe via un flux documenté, un identifiant chiffré, une limite de requêtes et les conditions de la source. La source reste l'autorité : chaque changement est enregistré comme un nouvel instantané et rien n'est modifié automatiquement en local. + Nouveau connecteur + Nom du connecteur + Fournisseur du flux + Système source + Racine du flux (https) + La source doit servir le Resgrid Mutual-Aid Order Feed v1 (JSON). Le screen-scraping et les mises à jour déduites ne sont pas pris en charge. + Type d'identifiant + Nom de l'en-tête + Identifiant + Chiffré au repos et jamais réaffiché. Laissez vide pour conserver l'identifiant enregistré. + Identifiant enregistré + Autorité de lecture accordée + L'autorité d'écriture n'est pas accordée dans cette version. Rien n'est jamais réécrit dans le système de commande. + Intervalle d'interrogation (minutes) + Requêtes par heure + Conditions de la source + Reconnaître les conditions de la source + Conditions reconnues + Conditions pas encore reconnues + Activer le connecteur + Désactiver le connecteur + Activé + Désactivé + Connecteur activé. + Connecteur désactivé. + Les conditions de la source ont été reconnues. + Lire le flux maintenant + Émettre un nouveau jeton entrant + Jeton entrant + Copiez ce jeton maintenant. Il n'est affiché qu'une fois ; le serveur ne conserve qu'un hachage. + Point de réception entrant + Supprimer le connecteur + Supprimer ce connecteur ? Les déploiements importés et leurs instantanés sont conservés. + Journal des exécutions + Déclencheur + Résultat + Vus / créés / instantanés / demandes / inchangés / rejetés / conflits + Dernière lecture + Dernier succès + Dernière erreur + Connecteur enregistré. + Connecteur créé. Il reste désactivé tant que les conditions de la source ne sont pas reconnues et qu'il n'est pas activé. + Connecteur supprimé. + Lecture du flux terminée (créés / instantanés / demandes / conflits). + Un nouveau jeton entrant a été émis ; l'ancien ne fonctionne plus. + Les connecteurs externes sont désactivés pour cette installation. + État du connecteur + Prêt + Pas prêt + Rapprochement + Là où le dernier instantané de la source et le dossier de déploiement de ce service divergent. Rien n'est appliqué automatiquement ; un coordinateur décide. + Aucune divergence entre la source et vos dossiers. + Divergence + La source indique + Votre dossier indique + La source a libéré l'ordre, mais une affectation n'est pas encore rentrée + La source a clôturé l'ordre, mais des affectations sont encore ouvertes + La demande existe dans la source mais pas en local + L'affectation existe en local mais pas dans la source + Le statut de la source est en avance sur l'affectation locale + Le statut de la source est en retard sur l'affectation locale + Maintenu par un connecteur diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index 0f30540d..6d37df03 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -1122,6 +1122,7 @@ Larghezza sequenza Assegna numero Sequenza per stazione + Sequenza per incidente Azzera ogni anno Classificazione Conservazione (anni) @@ -1315,4 +1316,106 @@ Riepilogo risorse Qualifiche Riepilogo del comando + Rollout dei record sul campo + Come procede ogni app operativa con i record: chi ha una versione utilizzabile, cosa è stato rifiutato alle app e dove la compilazione si è fermata. Solo conteggi: questa pagina non mostra mai ciò che è stato scritto. + La vista del rollout non è disponibile per il tuo account. + Nessuna app operativa ha i record sul campo attivi. + {0} giorni + Attivo + Non attivo + Persone che lo usano + Su una versione utilizzabile + minimo {0} + Record + {0} avviati, {1} finalizzati + Richieste di catalogo + {0} rifiutate + Compilazione + {0} avviati, {1} inviati, {2} invii falliti + Sincronizzazioni + Conflitti mostrati + Allegati + {0} caricati, {1} falliti + Completati nell'app + {0}, mediana {1} minuti + Lasciati incompiuti + Passati all'app web + Versioni in uso + Versione + Persone + Troppo vecchia + Perché il catalogo è stato rifiutato + Generato {0}. + Responder + Unità + Comando dell'incidente + Centrale + Posizione della foto mantenuta + Posizione della foto rimossa + Mantieni le coordinate delle foto negli allegati + Disattivo per impostazione predefinita: posizione e dati del dispositivo vengono rimossi al caricamento. Attivalo solo se il modulo ha davvero bisogno del luogo dello scatto, come una valutazione danni o una segnalazione di indizi. + Connettori esterni + Legge gli ordini di risorse da un sistema di ordinazione esterno tramite un feed documentato, una credenziale cifrata, un limite di richieste e le condizioni della fonte. La fonte resta autorevole: ogni modifica viene salvata come nuova istantanea e nulla in locale cambia automaticamente. + Nuovo connettore + Nome del connettore + Fornitore del feed + Sistema di origine + Radice del feed (https) + La fonte deve servire il Resgrid Mutual-Aid Order Feed v1 (JSON). Lo screen-scraping e gli aggiornamenti dedotti non sono supportati. + Tipo di credenziale + Nome dell'intestazione + Credenziale + Cifrata a riposo e mai più mostrata. Lasciare vuoto per mantenere la credenziale salvata. + Credenziale salvata + Autorità di lettura concessa + In questa versione non viene concessa l'autorità di scrittura. Nulla viene mai riscritto nel sistema di ordinazione. + Intervallo di polling (minuti) + Richieste all'ora + Condizioni della fonte + Confermare le condizioni della fonte + Condizioni confermate + Condizioni non ancora confermate + Attiva connettore + Disattiva connettore + Attivo + Disattivato + Connettore attivato. + Connettore disattivato. + Le condizioni della fonte sono state confermate. + Leggi il feed ora + Emetti un nuovo token in ingresso + Token in ingresso + Copia questo token ora. Viene mostrato una sola volta; il server conserva solo un hash. + Endpoint di ricezione + Elimina connettore + Eliminare questo connettore? Le missioni importate e le loro istantanee vengono conservate. + Registro delle esecuzioni + Attivazione + Esito + Visti / creati / istantanee / richieste / invariati / rifiutati / conflitti + Ultima lettura + Ultimo successo + Ultimo errore + Connettore salvato. + Connettore creato. Resta disattivato finché le condizioni della fonte non vengono confermate e non viene attivato. + Connettore eliminato. + Lettura del feed completata (creati / istantanee / richieste / conflitti). + È stato emesso un nuovo token in ingresso; quello vecchio non funziona più. + I connettori esterni sono disattivati per questa installazione. + Stato del connettore + Pronto + Non pronto + Riconciliazione + Dove l'ultima istantanea della fonte e il registro di missione di questo dipartimento non concordano. Nulla viene applicato automaticamente; decide un coordinatore. + Nessuna discrepanza tra la fonte e i tuoi registri. + Discrepanza + La fonte dice + Il tuo registro dice + La fonte ha rilasciato l'ordine, ma un'assegnazione non è ancora rientrata + La fonte ha chiuso l'ordine, ma ci sono assegnazioni ancora aperte + La richiesta esiste nella fonte ma non in locale + L'assegnazione esiste in locale ma non nella fonte + Lo stato della fonte è più avanti dell'assegnazione locale + Lo stato della fonte è indietro rispetto all'assegnazione locale + Mantenuto da un connettore diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index a20f0bba..17190f5b 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -1122,6 +1122,7 @@ Szerokość sekwencji Przydziel numer Sekwencja na stację + Numeracja na zdarzenie Resetuj co rok Klasyfikacja Przechowywanie (lata) @@ -1315,4 +1316,106 @@ Podsumowanie zasobów Kwalifikacje Podsumowanie dowodzenia + Wdrożenie rekordów terenowych + Jak radzi sobie każda aplikacja operacyjna z rekordami: kto ma działającą wersję, czego aplikacjom odmówiono i gdzie zatrzymało się tworzenie. Tylko liczby — ta strona nigdy nie pokazuje, co ktoś napisał. + Widok wdrożenia nie jest dostępny dla Twojego konta. + Żadna aplikacja operacyjna nie ma włączonych rekordów terenowych. + {0} dni + Włączone + Wyłączone + Osoby korzystające + Na działającej wersji + minimum {0} + Rekordy + {0} rozpoczętych, {1} sfinalizowanych + Zapytania o katalog + {0} odrzucone + Tworzenie + {0} rozpoczętych, {1} wysłanych, {2} nieudanych wysyłek + Synchronizacje + Pokazane konflikty + Załączniki + {0} przesłanych, {1} nieudanych + Ukończone w aplikacji + {0}, mediana {1} minut + Porzucone + Przekazane do aplikacji internetowej + Używane wersje + Wersja + Osoby + Za stara + Dlaczego odmówiono katalogu + Wygenerowano {0}. + Responder + Jednostka + Dowodzenie zdarzeniem + Dyspozytornia + Zachowano lokalizację zdjęcia + Usunięto lokalizację zdjęcia + Zachowaj współrzędne zdjęć w załącznikach + Domyślnie wyłączone: lokalizacja i dane urządzenia są usuwane przy przesyłaniu. Włącz tylko wtedy, gdy formularz naprawdę potrzebuje miejsca wykonania zdjęcia, np. ocena szkód lub zgłoszenie śladu. + Zewnętrzne łączniki + Odczytuje zamówienia zasobów z zewnętrznego systemu zamówień przez udokumentowany kanał, zaszyfrowane poświadczenie, limit żądań i warunki źródła. Źródło pozostaje wiążące: każda zmiana zapisywana jest jako nowa migawka, a nic lokalnie nie zmienia się automatycznie. + Nowy łącznik + Nazwa łącznika + Dostawca kanału + System źródłowy + Adres główny kanału (https) + Źródło musi udostępniać Resgrid Mutual-Aid Order Feed v1 (JSON). Zbieranie danych z ekranu i wnioskowane aktualizacje nie są obsługiwane. + Rodzaj poświadczenia + Nazwa nagłówka + Poświadczenie + Szyfrowane w spoczynku i nigdy ponownie nie wyświetlane. Pozostaw puste, aby zachować zapisane poświadczenie. + Poświadczenie zapisane + Uprawnienie do odczytu przyznane + W tej wersji nie przyznaje się uprawnienia do zapisu. Nic nigdy nie jest zapisywane z powrotem do systemu zamówień. + Interwał odpytywania (minuty) + Żądań na godzinę + Warunki źródła + Potwierdź warunki źródła + Warunki potwierdzone + Warunki jeszcze niepotwierdzone + Włącz łącznik + Wyłącz łącznik + Włączony + Wyłączony + Łącznik włączony. + Łącznik wyłączony. + Warunki źródła zostały potwierdzone. + Odczytaj kanał teraz + Wydaj nowy token przychodzący + Token przychodzący + Skopiuj ten token teraz. Jest wyświetlany raz; serwer przechowuje tylko skrót. + Punkt końcowy dla przesyłek przychodzących + Usuń łącznik + Usunąć ten łącznik? Zaimportowane rozmieszczenia i ich migawki zostaną zachowane. + Dziennik uruchomień + Wyzwalacz + Wynik + Widziane / utworzone / migawki / żądania / bez zmian / odrzucone / konflikty + Ostatni odczyt + Ostatnie powodzenie + Ostatni błąd + Łącznik zapisany. + Łącznik utworzony. Pozostaje wyłączony, dopóki nie zostaną potwierdzone warunki źródła i nie zostanie włączony. + Łącznik usunięty. + Odczyt kanału zakończony (utworzone / migawki / żądania / konflikty). + Wydano nowy token przychodzący; stary już nie działa. + Zewnętrzne łączniki są wyłączone w tej instalacji. + Stan łącznika + Gotowy + Niegotowy + Uzgodnienie + Gdzie najnowsza migawka źródła i rekord rozmieszczenia tego wydziału się różnią. Nic nie jest stosowane automatycznie; decyduje koordynator. + Brak rozbieżności między źródłem a Twoimi rekordami. + Rozbieżność + Źródło podaje + Twój rekord podaje + Źródło zwolniło zamówienie, ale obsada jeszcze nie wróciła + Źródło zamknęło zamówienie, ale obsady są nadal otwarte + Żądanie istnieje w źródle, ale nie lokalnie + Obsada istnieje lokalnie, ale nie w źródle + Status źródła wyprzedza lokalną obsadę + Status źródła jest za lokalną obsadą + Utrzymywane przez łącznik diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index 50a1ab19..b5b1fd40 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -1122,6 +1122,7 @@ Sekvensbredd Tilldela nummer Sekvens per station + Sekvens per insats Återställ årligen Klassificering Bevarande (år) @@ -1315,4 +1316,106 @@ Resurssammanfattning Kvalifikationer Ledningssammanfattning + Utrullning av fältposter + Hur varje operativ app klarar poster: vem som har en användbar version, vad apparna nekades och var författandet stannade. Endast antal — sidan visar aldrig vad någon skrivit. + Utrullningsvyn är inte tillgänglig för ditt konto. + Ingen operativ app har fältposter påslaget ännu. + {0} dagar + + Av + Personer som använder + På en användbar version + minst {0} + Poster + {0} påbörjade, {1} slutförda + Katalogförfrågningar + {0} nekade + Författande + {0} påbörjade, {1} skickade, {2} misslyckades + Synkroniseringar + Visade konflikter + Bilagor + {0} uppladdade, {1} misslyckades + Slutförda i appen + {0}, median {1} minuter + Lämnade oavslutade + Skickade till webbappen + Versioner i bruk + Version + Personer + För gammal + Varför katalogen nekades + Genererad {0}. + Responder + Enhet + Insatsledning + Larmcentral + Fotots plats behållen + Fotots plats borttagen + Behåll fotokoordinater på bilagor + Av som standard: fotots plats och enhetsdata tas bort vid uppladdning. Slå på detta endast när formuläret verkligen behöver var fotot togs, till exempel en skadebedömning eller en spårrapport. + Externa kopplingar + Läser resursbeställningar från ett externt beställningssystem via ett dokumenterat flöde, en krypterad behörighet, en begärandegräns och källans villkor. Källan förblir auktoritativ: varje ändring sparas som en ny ögonblicksbild och inget lokalt ändras automatiskt. + Ny koppling + Kopplingens namn + Flödesleverantör + Källsystem + Flödets rotadress (https) + Källan måste tillhandahålla Resgrid Mutual-Aid Order Feed v1 (JSON). Skärmskrapning och härledda uppdateringar stöds inte. + Typ av behörighet + Rubriknamn + Behörighet + Krypteras i vila och visas aldrig igen. Lämna tomt för att behålla den sparade behörigheten. + Behörighet sparad + Läsbehörighet beviljad + Skrivbehörighet beviljas inte i denna version. Ingenting skrivs någonsin tillbaka till beställningssystemet. + Avfrågningsintervall (minuter) + Begäranden per timme + Källans villkor + Bekräfta källans villkor + Villkor bekräftade + Villkor ännu inte bekräftade + Aktivera koppling + Inaktivera koppling + Aktiverad + Inaktiverad + Kopplingen aktiverad. + Kopplingen inaktiverad. + Källans villkor bekräftades. + Läs flödet nu + Utfärda en ny inkommande token + Inkommande token + Kopiera denna token nu. Den visas en gång; servern sparar bara en hash. + Inkommande mottagningspunkt + Ta bort koppling + Ta bort denna koppling? Importerade insatser och deras ögonblicksbilder behålls. + Körningslogg + Utlösare + Utfall + Sedda / skapade / ögonblicksbilder / begäranden / oförändrade / avvisade / konflikter + Senast läst + Senaste lyckade + Senaste fel + Kopplingen sparad. + Kopplingen skapad. Den förblir inaktiverad tills källans villkor bekräftats och den aktiverats. + Kopplingen borttagen. + Flödesläsning klar (skapade / ögonblicksbilder / begäranden / konflikter). + En ny inkommande token utfärdades; den gamla fungerar inte längre. + Externa kopplingar är avstängda för denna installation. + Kopplingens tillstånd + Redo + Inte redo + Avstämning + Där källans senaste ögonblicksbild och denna avdelnings insatspost skiljer sig åt. Inget tillämpas automatiskt; en samordnare avgör. + Inga avvikelser mellan källan och dina poster. + Avvikelse + Källan anger + Din post anger + Källan frigav beställningen, men en tillsättning har ännu inte återvänt + Källan stängde beställningen, men tillsättningar är fortfarande öppna + Begäran finns i källan men inte lokalt + Tillsättningen finns lokalt men inte i källan + Källans status ligger före den lokala tillsättningen + Källans status ligger efter den lokala tillsättningen + Underhålls av en koppling diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index 27b59712..850355c9 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -1122,6 +1122,7 @@ Ширина послідовності Призначити номер Послідовність на станцію + Послідовність за інцидентом Скидати щороку Класифікація Зберігання (роки) @@ -1315,4 +1316,106 @@ Зведення ресурсів Кваліфікації Зведення командування + Розгортання польових записів + Як кожен оперативний додаток працює із записами: хто має придатну версію, що додаткам відмовлено і де зупинилося створення. Лише підрахунки — ця сторінка ніколи не показує, що хтось написав. + Перегляд розгортання недоступний для вашого облікового запису. + У жодному оперативному додатку польові записи ще не ввімкнено. + {0} днів + Увімкнено + Вимкнено + Люди, які користуються + З придатною версією + мінімум {0} + Записи + {0} розпочато, {1} завершено + Запити каталогу + {0} відхилено + Створення + {0} розпочато, {1} надіслано, {2} не надіслано + Синхронізації + Показані конфлікти + Вкладення + {0} завантажено, {1} невдало + Завершено в додатку + {0}, медіана {1} хвилин + Залишено незавершеними + Передано у вебдодаток + Версії у використанні + Версія + Люди + Застара + Чому відмовлено в каталозі + Створено {0}. + Responder + Підрозділ + Командування інцидентом + Диспетчер + Розташування фото збережено + Розташування фото видалено + Зберігати координати фото у вкладеннях + Вимкнено за замовчуванням: розташування фото та дані пристрою видаляються під час завантаження. Вмикайте, лише коли формі справді потрібне місце зйомки, наприклад для оцінки збитків чи звіту про знахідку. + Зовнішні конектори + Зчитує замовлення ресурсів із зовнішньої системи замовлень через задокументований канал, зашифрований обліковий запис, ліміт запитів і умови джерела. Джерело залишається авторитетним: кожна зміна зберігається як новий знімок, і нічого локального не змінюється автоматично. + Новий конектор + Назва конектора + Постачальник каналу + Система-джерело + Коренева адреса каналу (https) + Джерело має надавати Resgrid Mutual-Aid Order Feed v1 (JSON). Скрапінг екрана та виведені оновлення не підтримуються. + Тип облікових даних + Назва заголовка + Облікові дані + Шифрується у сховищі й більше не показується. Залиште порожнім, щоб зберегти збережені облікові дані. + Облікові дані збережено + Право читання надано + У цьому випуску право запису не надається. Нічого ніколи не записується назад у систему замовлень. + Інтервал опитування (хвилини) + Запитів на годину + Умови джерела + Підтвердити умови джерела + Умови підтверджено + Умови ще не підтверджено + Увімкнути конектор + Вимкнути конектор + Увімкнено + Вимкнено + Конектор увімкнено. + Конектор вимкнено. + Умови джерела підтверджено. + Прочитати канал зараз + Видати новий вхідний токен + Вхідний токен + Скопіюйте цей токен зараз. Він показується один раз; сервер зберігає лише хеш. + Кінцева точка для вхідних надсилань + Видалити конектор + Видалити цей конектор? Імпортовані розгортання та їхні знімки зберігаються. + Журнал запусків + Тригер + Результат + Побачено / створено / знімки / запити / без змін / відхилено / конфлікти + Останнє читання + Останній успіх + Остання помилка + Конектор збережено. + Конектор створено. Він залишається вимкненим, доки не буде підтверджено умови джерела і його не буде увімкнено. + Конектор видалено. + Читання каналу завершено (створено / знімки / запити / конфлікти). + Видано новий вхідний токен; старий більше не працює. + Зовнішні конектори вимкнено для цієї інсталяції. + Стан конектора + Готовий + Не готовий + Звірка + Де останній знімок джерела та запис розгортання цього підрозділу розходяться. Нічого не застосовується автоматично; вирішує координатор. + Розбіжностей між джерелом і вашими записами немає. + Розбіжність + Джерело повідомляє + Ваш запис повідомляє + Джерело звільнило замовлення, але заповнення ще не повернулося + Джерело закрило замовлення, але заповнення ще відкриті + Запит існує в джерелі, але не локально + Заповнення існує локально, але не в джерелі + Статус джерела випереджає локальне заповнення + Статус джерела відстає від локального заповнення + Підтримується конектором diff --git a/Core/Resgrid.Model/Records/ExternalOrderFeed.cs b/Core/Resgrid.Model/Records/ExternalOrderFeed.cs new file mode 100644 index 00000000..08e8f784 --- /dev/null +++ b/Core/Resgrid.Model/Records/ExternalOrderFeed.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// The Resgrid Mutual-Aid Order Feed, contract v1 (RMS plan section 4.1). This is the documented API a + /// connector reads: an ordering system, or the integration middleware in front of it, publishes its orders, + /// requests and fills in this shape. Resgrid never scrapes a screen and never infers an order update — what + /// is not in this feed did not happen as far as import is concerned. Identifiers are opaque and + /// source-qualified; the source stays authoritative for what was ordered, the department for what it did. + /// + public static class ExternalOrderFeedContract + { + public const string Version = "resgrid.mutual-aid-order-feed.v1"; + + /// Most orders one feed page may carry; larger sources page with the cursor. + public const int MaxOrdersPerPage = 200; + + /// Most requests one order may carry in a page. + public const int MaxRequestsPerOrder = 500; + + /// Order statuses the feed may declare. + public static class OrderStatuses + { + public const string Open = "open"; + public const string Mobilized = "mobilized"; + public const string Released = "released"; + public const string Closed = "closed"; + public static readonly IReadOnlyList All = new[] { Open, Mobilized, Released, Closed }; + } + + /// Request statuses the feed may declare, in lifecycle order. + public static class RequestStatuses + { + public const string Requested = "requested"; + public const string Filled = "filled"; + public const string Mobilized = "mobilized"; + public const string CheckedIn = "checked-in"; + public const string Assigned = "assigned"; + public const string Released = "released"; + public const string Demobilized = "demobilized"; + public const string Cancelled = "cancelled"; + public static readonly IReadOnlyList All = new[] { Requested, Filled, Mobilized, CheckedIn, Assigned, Released, Demobilized, Cancelled }; + + /// Lifecycle rank so source and local states can be compared; cancelled is outside the ladder. + public static int Rank(string status) + { + switch ((status ?? string.Empty).Trim().ToLowerInvariant()) + { + case Requested: return 1; + case Filled: return 2; + case Mobilized: return 4; + case CheckedIn: return 5; + case Assigned: return 6; + case Released: return 7; + case Demobilized: return 8; + default: return 0; + } + } + } + + /// The local fill status expressed on the feed's ladder, for reconciliation. + public static string LocalStatusOf(RmsDeploymentFillStatus status) + { + switch (status) + { + case RmsDeploymentFillStatus.Requested: return RequestStatuses.Requested; + case RmsDeploymentFillStatus.Accepted: return RequestStatuses.Filled; + case RmsDeploymentFillStatus.Declined: return RequestStatuses.Cancelled; + case RmsDeploymentFillStatus.Mobilized: return RequestStatuses.Mobilized; + case RmsDeploymentFillStatus.CheckedIn: return RequestStatuses.CheckedIn; + case RmsDeploymentFillStatus.Assigned: return RequestStatuses.Assigned; + case RmsDeploymentFillStatus.Released: return RequestStatuses.Released; + case RmsDeploymentFillStatus.Demobilized: return RequestStatuses.Demobilized; + case RmsDeploymentFillStatus.Returned: return "returned"; + default: return RequestStatuses.Requested; + } + } + + /// Parses and validates one feed document. Problems are returned, never thrown, so a run can log them. + public static ExternalOrderFeed Parse(string json, out List problems) + { + problems = new List(); + if (string.IsNullOrWhiteSpace(json)) + { + problems.Add("The feed is empty."); + return null; + } + + ExternalOrderFeed feed; + try + { + feed = JsonConvert.DeserializeObject(json, new JsonSerializerSettings { DateParseHandling = DateParseHandling.DateTimeOffset }); + } + catch (JsonException ex) + { + problems.Add("The feed is not valid JSON: " + ex.Message); + return null; + } + + if (feed == null) + { + problems.Add("The feed is empty."); + return null; + } + if (!string.Equals(feed.Contract, Version, StringComparison.Ordinal)) + { + problems.Add($"The feed declares contract '{feed.Contract}'; this connector speaks '{Version}'."); + return null; + } + feed.Orders ??= new List(); + if (feed.Orders.Count > MaxOrdersPerPage) + { + problems.Add($"The feed carries {feed.Orders.Count} orders in one page; the contract allows {MaxOrdersPerPage}. Page with the cursor."); + return null; + } + + for (var i = 0; i < feed.Orders.Count; i++) + { + var order = feed.Orders[i]; + var label = $"orders[{i}]"; + if (order == null) { problems.Add(label + " is null."); continue; } + if (string.IsNullOrWhiteSpace(order.OrderNumber)) problems.Add(label + ".orderNumber is required."); + if (string.IsNullOrWhiteSpace(order.IncidentName)) problems.Add(label + ".incidentName is required."); + if (!string.IsNullOrWhiteSpace(order.Status) && !OrderStatuses.All.Contains(order.Status.Trim().ToLowerInvariant())) + problems.Add($"{label}.status '{order.Status}' is not one of {string.Join(", ", OrderStatuses.All)}."); + order.Requests ??= new List(); + if (order.Requests.Count > MaxRequestsPerOrder) + problems.Add($"{label} carries {order.Requests.Count} requests; the contract allows {MaxRequestsPerOrder}."); + for (var j = 0; j < order.Requests.Count; j++) + { + var request = order.Requests[j]; + if (request == null) { problems.Add($"{label}.requests[{j}] is null."); continue; } + if (string.IsNullOrWhiteSpace(request.RequestNumber)) problems.Add($"{label}.requests[{j}].requestNumber is required."); + if (!string.IsNullOrWhiteSpace(request.Status) && !RequestStatuses.All.Contains(request.Status.Trim().ToLowerInvariant())) + problems.Add($"{label}.requests[{j}].status '{request.Status}' is not one of {string.Join(", ", RequestStatuses.All)}."); + } + if (order.Artifact != null && !string.IsNullOrWhiteSpace(order.Artifact.Url) && !order.Artifact.Url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + problems.Add($"{label}.artifact.url must be https."); + } + + return problems.Count == 0 ? feed : null; + } + } + + public class ExternalOrderFeed + { + [JsonProperty("contract")] + public string Contract { get; set; } + + [JsonProperty("source")] + public ExternalOrderFeedSource Source { get; set; } + + /// Opaque resume cursor; the next request sends it back. Null means the page is complete. + [JsonProperty("cursor")] + public string Cursor { get; set; } + + [JsonProperty("orders")] + public List Orders { get; set; } = new List(); + } + + public class ExternalOrderFeedSource + { + [JsonProperty("system")] + public string System { get; set; } + + [JsonProperty("scheme")] + public string Scheme { get; set; } + + [JsonProperty("version")] + public string Version { get; set; } + + [JsonProperty("generatedOn")] + public DateTimeOffset? GeneratedOn { get; set; } + } + + public class ExternalOrderFeedOrder + { + [JsonProperty("orderNumber")] + public string OrderNumber { get; set; } + + [JsonProperty("incidentName")] + public string IncidentName { get; set; } + + [JsonProperty("incidentNumber")] + public string IncidentNumber { get; set; } + + [JsonProperty("incidentCountry")] + public string IncidentCountry { get; set; } + + [JsonProperty("incidentSubdivision")] + public string IncidentSubdivision { get; set; } + + [JsonProperty("orderingOffice")] + public string OrderingOffice { get; set; } + + [JsonProperty("dispatchOffice")] + public string DispatchOffice { get; set; } + + [JsonProperty("requestingAgency")] + public string RequestingAgency { get; set; } + + [JsonProperty("receivingAgency")] + public string ReceivingAgency { get; set; } + + [JsonProperty("sendingAgency")] + public string SendingAgency { get; set; } + + [JsonProperty("costCode")] + public string CostCode { get; set; } + + [JsonProperty("agreementReference")] + public string AgreementReference { get; set; } + + [JsonProperty("currencyCode")] + public string CurrencyCode { get; set; } + + [JsonProperty("measurementSystem")] + public string MeasurementSystem { get; set; } + + [JsonProperty("timeZoneId")] + public string TimeZoneId { get; set; } + + /// The source's own version of this order; a change here is what makes a new snapshot. + [JsonProperty("sourceVersion")] + public string SourceVersion { get; set; } + + [JsonProperty("capturedOn")] + public DateTimeOffset? CapturedOn { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("requests")] + public List Requests { get; set; } = new List(); + + [JsonProperty("artifact")] + public ExternalOrderFeedArtifact Artifact { get; set; } + } + + public class ExternalOrderFeedRequest + { + [JsonProperty("requestNumber")] + public string RequestNumber { get; set; } + + [JsonProperty("parentRequestNumber")] + public string ParentRequestNumber { get; set; } + + [JsonProperty("category")] + public string Category { get; set; } + + [JsonProperty("fillNumber")] + public string FillNumber { get; set; } + + [JsonProperty("resourceKind")] + public string ResourceKind { get; set; } + + [JsonProperty("resourceType")] + public string ResourceType { get; set; } + + [JsonProperty("resourceTypeScheme")] + public string ResourceTypeScheme { get; set; } + + [JsonProperty("position")] + public string Position { get; set; } + + [JsonProperty("positionScheme")] + public string PositionScheme { get; set; } + + [JsonProperty("isTrainee")] + public bool IsTrainee { get; set; } + + [JsonProperty("homeUnit")] + public string HomeUnit { get; set; } + + [JsonProperty("hostAgency")] + public string HostAgency { get; set; } + + [JsonProperty("agencyUnitId")] + public string AgencyUnitId { get; set; } + + [JsonProperty("pointOfHire")] + public string PointOfHire { get; set; } + + [JsonProperty("costCode")] + public string CostCode { get; set; } + + [JsonProperty("agreementReference")] + public string AgreementReference { get; set; } + + [JsonProperty("requestedOn")] + public DateTimeOffset? RequestedOn { get; set; } + + [JsonProperty("neededOn")] + public DateTimeOffset? NeededOn { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + } + + public class ExternalOrderFeedArtifact + { + /// An https link to the source's own artifact. Kept as a reference only; never fetched or executed. + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("contentType")] + public string ContentType { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordDefinitionCategories.cs b/Core/Resgrid.Model/Records/RecordDefinitionCategories.cs new file mode 100644 index 00000000..e5fda47a --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordDefinitionCategories.cs @@ -0,0 +1,35 @@ +namespace Resgrid.Model +{ + /// + /// The category labels the product template packs ship (RMS plan section 4.1). A department may type any + /// category it likes on its own definition — this is the product's spelling, not a constraint — but every + /// shipped template uses one of these so the template browser groups them the same way each release. + /// + public static class RecordDefinitionCategories + { + public const string Operations = "Operations"; + public const string Security = "Security"; + public const string Delivery = "Delivery"; + public const string Transit = "Transit"; + public const string FieldService = "Field service"; + public const string Cert = "CERT"; + public const string Sar = "SAR"; + public const string Disaster = "Disaster"; + public const string Eoc = "EOC"; + public const string Hazmat = "HAZMAT"; + public const string Industrial = "Industrial"; + public const string Exercise = "Exercise"; + public const string MutualAid = "Mutual aid"; + + /// + /// Incident business (Back Office plan E6): the finance and administration half of a large incident — + /// time, equipment use, expenses, agreements, delegations and cost share. These Records are documents with + /// a lifecycle and a signature; the arithmetic behind them stays in the owning ledger and is referenced, + /// never re-keyed into record values. + /// + public const string IncidentBusiness = "Incident business"; + + /// Incident support: the ICS planning, resource, communications and safety products of an incident. + public const string IncidentSupport = "Incident support"; + } +} diff --git a/Core/Resgrid.Model/Records/RmsExternalOrderConnector.cs b/Core/Resgrid.Model/Records/RmsExternalOrderConnector.cs new file mode 100644 index 00000000..e41acf1e --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsExternalOrderConnector.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; + +namespace Resgrid.Model +{ + /// + /// The ordering systems a connector can speak for (RMS plan section 4.1, "External ordering system + /// connectors"). Every provider consumes the same documented Resgrid Mutual-Aid Order Feed; the provider + /// fixes the identifier scheme, the default deployment profile and which identifiers are mandatory, so an + /// IROC feed that omits a resource-order number is refused rather than guessed at. + /// + public static class RmsExternalOrderConnectorProviders + { + /// U.S. wildland: IROC resource orders and requests, NWCG code schemes. + public const string Iroc = "iroc-feed"; + /// Canada wildland: CIFFC / member-agency exchanges under MARS. + public const string Ciffc = "ciffc-feed"; + /// A member agency's own ordering system publishing the feed under an opaque agency scheme. + public const string Agency = "agency-feed"; + /// Any all-hazard or local mutual-aid system publishing the feed with opaque identifiers. + public const string Generic = "generic-feed"; + public static readonly IReadOnlyList All = new[] { Iroc, Ciffc, Agency, Generic }; + public static bool IsKnown(string key) => key != null && All.Contains(key.Trim().ToLowerInvariant()); + } + + /// How the connector presents itself to the source. + public static class RmsConnectorCredentialKinds + { + public const string None = "none"; + public const string Bearer = "bearer"; + public const string Header = "header"; + public static readonly IReadOnlyList All = new[] { None, Bearer, Header }; + public static bool IsKnown(string kind) => kind != null && All.Contains(kind.Trim().ToLowerInvariant()); + } + + /// Who owns an external order's facts in Resgrid: a person who keyed it, or a connector that imports it. + public static class RmsExternalOrderOwnership + { + public const string Manual = "manual"; + public const string Connector = "connector"; + } + + public static class RmsConnectorRunTriggers + { + public const string Poll = "poll"; + public const string Manual = "manual"; + public const string Inbound = "inbound"; + } + + public static class RmsConnectorRunOutcomes + { + public const string Ok = "ok"; + public const string Failed = "failed"; + public const string RateLimited = "rate_limited"; + public const string Disabled = "disabled"; + public const string Rejected = "rejected"; + } + + /// + /// One department's connection to one external ordering system (RMS plan section 4.1). The connector holds + /// exactly what the plan demands before any connector may exist: a documented API (the feed contract), a + /// credential held encrypted, a rate limit, an acknowledgement of the source's terms, and explicit read and + /// write authority. Write authority cannot be granted in this release; the property exists so the refusal is + /// a recorded decision rather than an absence. Import never overwrites signed deployment history: every change + /// from the source lands as a new versioned snapshot, and disagreements go to reconciliation for a person. + /// + public class RmsExternalOrderConnector : IEntity + { + public string RmsExternalOrderConnectorId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// . + public string ProviderKey { get; set; } + + public string Name { get; set; } + + /// The ordering system as the department names it (IROC, CIFFC, an agency's system). + public string SourceSystem { get; set; } + + /// Identifier scheme stamped on every order this connector imports (iroc, ciffc, agency:<code>, local). + public string SourceScheme { get; set; } + + /// The deployment profile orders from this source are provisioned under (). + public string ProfileKey { get; set; } + + /// https root of the feed. http is refused outside development. + public string BaseUrl { get; set; } + + /// . + public string CredentialKind { get; set; } + + /// Header name for the header credential kind; ignored otherwise. + public string CredentialHeaderName { get; set; } + + /// The outbound credential, encrypted under RecordsConnectorConfig.CredentialPassphrase. Never returned by any read. + public string CredentialCiphertext { get; set; } + + /// SHA-256 of the inbound push token. The token itself is shown once at creation or rotation and never stored. + public string InboundTokenHash { get; set; } + + /// The department authorizes reading from the source. + public bool ReadEnabled { get; set; } + + /// The department authorizes writing back to the source. Always false in this release (plan: "no P0 external writes"). + public bool WriteEnabled { get; set; } + + public int PollIntervalMinutes { get; set; } + + public int MaxRequestsPerHour { get; set; } + + public int RequestsThisHour { get; set; } + + public DateTime? RateWindowStartedOn { get; set; } + + /// Where the source's terms of use live, so the acknowledgement names what was agreed to. + public string TermsReference { get; set; } + + public DateTime? TermsAcknowledgedOn { get; set; } + + public string TermsAcknowledgedByUserId { get; set; } + + public bool IsEnabled { get; set; } + + /// Opaque resume cursor the feed handed back last time. + public string LastCursor { get; set; } + + public DateTime? LastPolledOn { get; set; } + + public DateTime? LastSuccessOn { get; set; } + + public string LastError { get; set; } + + public int ConsecutiveFailures { get; set; } + + public DateTime CreatedOn { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime ModifiedOn { get; set; } + + public string ModifiedByUserId { get; set; } + + [Key] + [Required] + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + /// Everything the plan requires before a connector may run. + [NotMapped] + public bool IsReadyToRun => IsEnabled && ReadEnabled && TermsAcknowledgedOn.HasValue && !DeletedOn.HasValue && !string.IsNullOrWhiteSpace(BaseUrl); + + [NotMapped] + public object IdValue + { + get { return RmsExternalOrderConnectorId; } + set { RmsExternalOrderConnectorId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsExternalOrderConnectors"; + + [NotMapped] + public string IdName => "RmsExternalOrderConnectorId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "IsReadyToRun" }; + } + + /// One import pass: what was read, what changed, and what needs a person. Counts and codes only. + public class RmsExternalOrderConnectorRun : IEntity + { + public string RmsExternalOrderConnectorRunId { get; set; } + + public int DepartmentId { get; set; } + + public string RmsExternalOrderConnectorId { get; set; } + + /// . + public string Trigger { get; set; } + + public string TriggeredByUserId { get; set; } + + public DateTime StartedOn { get; set; } + + public DateTime? FinishedOn { get; set; } + + /// . + public string Outcome { get; set; } + + public string Error { get; set; } + + public int RequestCount { get; set; } + + public int OrdersSeen { get; set; } + + public int OrdersCreated { get; set; } + + public int SnapshotsRecorded { get; set; } + + public int RequestsAdded { get; set; } + + public int Unchanged { get; set; } + + public int Rejected { get; set; } + + public int Conflicts { get; set; } + + /// The feed's declared source version, for the run log. + public string SourceVersion { get; set; } + + [NotMapped] + public object IdValue + { + get { return RmsExternalOrderConnectorRunId; } + set { RmsExternalOrderConnectorRunId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsExternalOrderConnectorRuns"; + + [NotMapped] + public string IdName => "RmsExternalOrderConnectorRunId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Create or update a connector. The credential is write-only; leaving it null on update keeps the stored one. + public class RecordDeploymentConnectorInput + { + public string ProviderKey { get; set; } = RmsExternalOrderConnectorProviders.Generic; + public string Name { get; set; } + public string SourceSystem { get; set; } + public string SourceScheme { get; set; } + public string ProfileKey { get; set; } + public string BaseUrl { get; set; } + public string CredentialKind { get; set; } = RmsConnectorCredentialKinds.None; + public string CredentialHeaderName { get; set; } + /// The outbound secret. Encrypted at rest; never echoed back. + public string Credential { get; set; } + public bool ReadEnabled { get; set; } = true; + /// Refused when true: this release grants no write authority. + public bool WriteEnabled { get; set; } + public int PollIntervalMinutes { get; set; } = 60; + public int MaxRequestsPerHour { get; set; } = 12; + public string TermsReference { get; set; } + } + + /// The connector as created plus the one-time inbound token; the token is not recoverable later. + public class RecordDeploymentConnectorCreated + { + public RmsExternalOrderConnector Connector { get; set; } + public string InboundToken { get; set; } + } + + /// A disagreement between the source's latest snapshot and what the department recorded. Shown, never applied. + public class RecordDeploymentReconciliationItem + { + public const string SourceReleasedLocalOut = "source_released_local_not_returned"; + public const string SourceClosedLocalOpen = "source_closed_local_open"; + public const string SourceRequestMissingLocally = "source_request_missing_locally"; + public const string LocalFillMissingInSource = "local_fill_missing_in_source"; + public const string SourceStatusAhead = "source_status_ahead"; + public const string SourceStatusBehind = "source_status_behind"; + + public string ConnectorId { get; set; } + public string OrderId { get; set; } + public string RecordId { get; set; } + public string OrderNumber { get; set; } + public string RequestNumber { get; set; } + public string Kind { get; set; } + public string SourceStatus { get; set; } + public string LocalStatus { get; set; } + public string SourceVersion { get; set; } + public DateTime? SourceCapturedOn { get; set; } + } + + /// The outcome of one connector run, as returned to whoever triggered it. + public class RecordDeploymentConnectorRunResult + { + public RmsExternalOrderConnectorRun Run { get; set; } + public List Messages { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/Records/RmsExternalOrders.cs b/Core/Resgrid.Model/Records/RmsExternalOrders.cs index 7efaaf9f..f1b98c83 100644 --- a/Core/Resgrid.Model/Records/RmsExternalOrders.cs +++ b/Core/Resgrid.Model/Records/RmsExternalOrders.cs @@ -87,6 +87,10 @@ public class RmsExternalOrder : IEntity public string ArtifactChecksum { get; set; } public byte[] ArtifactData { get; set; } public string ArtifactSafeUrl { get; set; } + /// Connector that provisioned and maintains this order's snapshots; null for orders a person captured. + public string ConnectorId { get; set; } + /// : who owns the source view of the order. + public string OwnershipMarker { get; set; } = RmsExternalOrderOwnership.Manual; public int Status { get; set; } public DateTime? MobilizedOn { get; set; } public DateTime? ReleasedOn { get; set; } @@ -220,6 +224,9 @@ public class RecordDeploymentCreateInput public int? StationGroupId { get; set; } public string IdempotencyKey { get; set; } public RmsOriginClient OriginClient { get; set; } = RmsOriginClient.Web; + /// Set only by a connector import. + public string ConnectorId { get; set; } + public string OwnershipMarker { get; set; } public List Fills { get; set; } = new List(); } @@ -253,6 +260,8 @@ public class RecordDeploymentFillInput /// A lifecycle step on one fill (accept/decline/mobilize/check-in/assign/release/demobilize/return). public class RecordDeploymentFillTransitionInput { + /// The fill row version the caller last saw; a mismatch rejects the step instead of losing a concurrent one. + public long? ExpectedRowVersion { get; set; } public RmsDeploymentFillStatus Status { get; set; } public DateTime? OccurredOn { get; set; } public int? CapturedOffsetMinutes { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsExternalReferenceSchemes.cs b/Core/Resgrid.Model/Records/RmsExternalReferenceSchemes.cs new file mode 100644 index 00000000..dcaced64 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsExternalReferenceSchemes.cs @@ -0,0 +1,43 @@ +namespace Resgrid.Model +{ + /// + /// Identifier schemes for . The column is a free string by + /// design — RMS never validates another system's identifier format — so this is the product's spelling, kept in + /// one place so a Deployment, a pack template and a report all name the same source the same way. + /// The incident-business schemes are the Back Office plan's E3 list; the first four already shipped with + /// "Create Deployment from External Order" in RMS-1C. + /// + public static class RmsExternalReferenceSchemes + { + /// U.S. wildland Interagency Resource Ordering Capability order/request/fill identifiers. + public const string Iroc = "iroc"; + /// Canadian Interagency Forest Fire Centre resource-exchange identifiers. + public const string Ciffc = "ciffc"; + /// A member or home agency's own order identifier. + public const string Agency = "agency"; + /// Generic all-hazard mutual-aid order identifier. + public const string Generic = "generic"; + + /// e-ISuite incident-business data exchange identifiers. + public const string EIsuite = "e-isuite"; + /// EMAC mission order / REQ-A identifiers. + public const string Emac = "emac"; + /// WebEOC resource-request and mission identifiers. + public const string WebEoc = "webeoc"; + /// Logistics Supply Chain Management System supply-request identifiers. + public const string Lscms = "lscms"; + /// NFES cache / ICLIP supply-request identifiers. + public const string NfesIclip = "nfes-iclip"; + /// Lodging confirmation number from a property or booking service. + public const string LodgingConfirmation = "lodging-confirmation"; + /// Vendor invoice number as printed by the vendor. + public const string VendorInvoice = "vendor-invoice"; + /// Finance/accounting posting reference in the department's system of record. + public const string FinancePosting = "finance-posting"; + + public static readonly string[] All = + { + Iroc, Ciffc, Agency, Generic, EIsuite, Emac, WebEoc, Lscms, NfesIclip, LodgingConfirmation, VendorInvoice, FinancePosting + }; + } +} diff --git a/Core/Resgrid.Model/Records/RmsFieldRolloutEvent.cs b/Core/Resgrid.Model/Records/RmsFieldRolloutEvent.cs new file mode 100644 index 00000000..3b25e157 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsFieldRolloutEvent.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; + +namespace Resgrid.Model +{ + /// + /// What a Field Records rollout event describes (RMS plan RMS-1D, "per-app rollout dashboards"). The set is + /// closed so a dashboard can count it; anything else is refused rather than stored as a free-text label. + /// + public static class RmsFieldRolloutEventTypes + { + /// The app asked whether Records is usable for it at this version. + public const string Preflight = "preflight"; + /// The app asked for its catalog in a context. + public const string Catalog = "catalog"; + /// A Record was started from the catalog. + public const string DraftStarted = "draft_started"; + /// A draft was pushed to the server. + public const string DraftSaved = "draft_saved"; + /// A sync bundle was pulled. + public const string Sync = "sync"; + /// A conflict was presented to a person. + public const string Conflict = "conflict"; + /// An attachment upload finished, failed or was abandoned. + public const string Attachment = "attachment"; + /// A Record reached a lifecycle end in the app: submitted or finalized. + public const string Completed = "completed"; + /// Authoring was left without saving or sending. + public const string Abandoned = "abandoned"; + /// The person gave up on the app for this Record and was pointed at the web app. + public const string WebHandoff = "web_handoff"; + + public static readonly IReadOnlyList All = new[] { Preflight, Catalog, DraftStarted, DraftSaved, Sync, Conflict, Attachment, Completed, Abandoned, WebHandoff }; + + public static bool IsKnown(string eventType) => eventType != null && All.Contains(eventType.Trim().ToLowerInvariant()); + } + + /// + /// One safe rollout datapoint from a field app (RMS plan RMS-1D). Identifiers, counts, durations and outcome + /// codes only: no field values, no free text, no location, nothing that could carry record content out of the + /// aggregate. Rows are department-scoped and read only through the rollout dashboard. + /// + public class RmsFieldRolloutEvent : IEntity + { + public string RmsFieldRolloutEventId { get; set; } + + public int DepartmentId { get; set; } + + /// — which app. + public int OriginClient { get; set; } + + public string AppVersion { get; set; } + + /// The renderer capability the client reported (records.v1 / v1b / v1c). + public string ClientCapability { get; set; } + + /// . + public string EventType { get; set; } + + /// A coded outcome: ok, or the refusal/conflict code the client was given. + public string Outcome { get; set; } + + public string DefinitionKey { get; set; } + + public int? DefinitionVersion { get; set; } + + public string RecordId { get; set; } + + public string UserId { get; set; } + + /// Time-to-complete and similar durations, in milliseconds. + public long? DurationMs { get; set; } + + /// A count the event carries: rows synced, attachment bytes in kilobytes, retries. + public int? ItemCount { get; set; } + + public DateTime OccurredOn { get; set; } + + public DateTime RecordedOn { get; set; } + + [NotMapped] + public object IdValue + { + get { return RmsFieldRolloutEventId; } + set { RmsFieldRolloutEventId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsFieldRolloutEvents"; + + [NotMapped] + public string IdName => "RmsFieldRolloutEventId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// One event as a client reports it; the server supplies department, user and the recorded time. + public class RecordFieldRolloutInput + { + public string EventType { get; set; } + public string Outcome { get; set; } + public string DefinitionKey { get; set; } + public int? DefinitionVersion { get; set; } + public string RecordId { get; set; } + public long? DurationMs { get; set; } + public int? ItemCount { get; set; } + public DateTime? OccurredOn { get; set; } + } + + /// A batch from one app at one version; batching keeps a busy shift from making a request per tap. + public class RecordFieldRolloutBatch + { + /// Most events one batch may carry; a longer report is truncated rather than refused. + public const int MaxEvents = 100; + + public RmsOriginClient OriginClient { get; set; } = RmsOriginClient.Api; + public string AppVersion { get; set; } + public string ClientCapability { get; set; } + public List Events { get; set; } = new List(); + } + + /// Adoption and outcomes for one app over the dashboard window. + public class RecordsFieldRolloutApp + { + public string OriginClient { get; set; } + /// Distinct members who used Records in this app in the window. + public int ActiveUsers { get; set; } + /// Version string to the number of members reporting it, newest-reported first. + public List Versions { get; set; } = new List(); + /// Members whose reported version met the department's minimum at the time they reported it. + public int CompatibleUsers { get; set; } + public int CatalogRequests { get; set; } + public int CatalogFailures { get; set; } + /// Refusal code to the number of times the server gave it. + public Dictionary CatalogFailureReasons { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public int DraftsStarted { get; set; } + public int DraftsSaved { get; set; } + public int DraftSaveFailures { get; set; } + public int Syncs { get; set; } + public int SyncFailures { get; set; } + public int Conflicts { get; set; } + public Dictionary ConflictKinds { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public int AttachmentsUploaded { get; set; } + public int AttachmentFailures { get; set; } + public int Completed { get; set; } + public int Abandoned { get; set; } + public int WebHandoffs { get; set; } + /// Median milliseconds from starting a Record to submitting or finalizing it. + public long? MedianTimeToCompleteMs { get; set; } + /// Records this app actually created in the window, counted from the Records themselves. + public int RecordsCreated { get; set; } + public int RecordsFinalized { get; set; } + + /// Abandonment over the drafts started in the window; null when nothing was started. + public double? AbandonmentRate => DraftsStarted == 0 ? (double?)null : Math.Round((double)Abandoned / DraftsStarted, 3); + } + + public class RecordsFieldRolloutVersion + { + public string AppVersion { get; set; } + public int Users { get; set; } + public int Events { get; set; } + } + + /// The per-app rollout dashboard (RMS plan RMS-1D). + public class RecordsFieldRollout + { + public DateTime GeneratedOn { get; set; } = DateTime.UtcNow; + public int WindowDays { get; set; } + public DateTime WindowStart { get; set; } + /// The minimum version each app must report, from RecordsFieldConfig; blank means any. + public Dictionary MinimumAppVersions { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public List Apps { get; set; } = new List(); + /// True when the department has at least one field app flag on. + public bool AnyAppEnabled { get; set; } + public Dictionary AppFlags { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/Core/Resgrid.Model/Records/RmsRecordAttachment.cs b/Core/Resgrid.Model/Records/RmsRecordAttachment.cs index cac5496d..15c30fee 100644 --- a/Core/Resgrid.Model/Records/RmsRecordAttachment.cs +++ b/Core/Resgrid.Model/Records/RmsRecordAttachment.cs @@ -48,6 +48,13 @@ public class RmsRecordAttachment : IEntity /// True when location/device metadata was stripped on upload (plan section 4.7, media hygiene). public bool MetadataStripped { get; set; } + /// + /// True when this image kept its EXIF GPS block because the definition's profile needs the coordinates + /// (RMS plan RMS-1D media capture hygiene). False on everything else, including every non-image, so the + /// decision is readable on the attachment rather than inferred from the definition later. + /// + public bool MediaLocationRetained { get; set; } + /// RmsEvidenceClassification. Null means legacy/unclassified and requires restricted access. public int? Classification { get; set; } [NotMapped, JsonIgnore] diff --git a/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs b/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs index eab6a534..be2b90ab 100644 --- a/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs +++ b/Core/Resgrid.Model/Records/RmsRecordDefinitions.cs @@ -429,6 +429,14 @@ public class RecordDefinitionNumbering public string Prefix { get; set; } public RmsNumberAssignment Assignment { get; set; } = RmsNumberAssignment.OnFinalize; public bool PerGroupSequence { get; set; } + + /// + /// Incident-scoped uniqueness and reset (Back Office plan E2), alongside the existing department and group + /// scopes. An ICS form is numbered per incident by definition — the third ICS 214 on a Call is 003 on that + /// Call, not 003 for the department this year. Requires the definition to permit the "call" subject; a Record + /// with no Call falls back to the wider scope rather than colliding. + /// + public bool PerIncidentSequence { get; set; } public bool ResetYearly { get; set; } = true; public int SequenceWidth { get; set; } = 4; @@ -446,8 +454,30 @@ public class RecordDefinitionClientSurface public List LaunchContexts { get; set; } = new List(); public bool AllowOffline { get; set; } public bool AllowAttachments { get; set; } = true; + + /// + /// Media capture hygiene (RMS plan RMS-1D): photo EXIF location and device metadata are stripped on upload + /// by default, because a photo of a protected facility, a SAR subject's location or a security client's site + /// is a disclosure nobody intended. A definition whose profile genuinely needs the coordinates — a damage + /// assessment, a clue report — sets this, and the decision is recorded on every attachment either way. + /// + public bool RetainMediaLocation { get; set; } + public string MinimumAppVersion { get; set; } + /// + /// No field app may author on this version — the definition is Web only (Back Office plan E7). This is the + /// mechanical expression of a desk-authored pack: the four apps exclude it with SurfaceNotEnabled, and no + /// bounded sync bundle ever puts it on a device. Web authoring is unaffected, because the Web renderer is + /// not gated by the client surface. + /// + [JsonIgnore] + public bool IsWebOnly => !Responder && !Unit && !IncidentCommand && !Dispatch; + + /// A Web-only surface: no app authoring, and therefore no offline drafts. Attachments stay available on the Web. + public static RecordDefinitionClientSurface WebOnly(bool allowAttachments = true) + => new RecordDefinitionClientSurface { Responder = false, Unit = false, IncidentCommand = false, Dispatch = false, AllowOffline = false, AllowAttachments = allowAttachments }; + public static RecordDefinitionClientSurface Parse(string json) => string.IsNullOrWhiteSpace(json) ? new RecordDefinitionClientSurface() : JsonConvert.DeserializeObject(json) ?? new RecordDefinitionClientSurface(); } diff --git a/Core/Resgrid.Model/Records/RmsSubmission.cs b/Core/Resgrid.Model/Records/RmsSubmission.cs index 60032a33..971bdcd8 100644 --- a/Core/Resgrid.Model/Records/RmsSubmission.cs +++ b/Core/Resgrid.Model/Records/RmsSubmission.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace Resgrid.Model { @@ -35,6 +36,33 @@ public static class RmsSubmissionDestinations /// belongs to without reading the payload. /// public const string NerisIncidentAnalysis = "NERIS_ANALYSIS"; + + // Non-NERIS destinations (Back Office plan E4). The submission model is a general outbound-exchange + // record — destination/version, idempotency key, payload checksum and artifact, attempts, status, + // response — and nothing about it is NERIS-specific. These four name the incident-business exchanges + // that program stages. No dispatcher owns them yet: worker 41 speaks NERIS only and skips them by + // destination rather than claiming a row it cannot deliver. + /// Finance/accounting export of an approved cost or time artifact. + public const string FinanceExport = "FINANCE_EXPORT"; + /// e-ISuite incident-business data exchange. + public const string EIsuiteExchange = "EISUITE_EXCHANGE"; + /// EMAC reimbursement package filing. + public const string EmacReimbursement = "EMAC_REIMBURSEMENT"; + /// Agency records filing of a produced record set. + public const string AgencyRecordsFiling = "AGENCY_RECORDS_FILING"; + + /// Every destination RMS recognizes. An unknown destination is refused at queue time. + public static readonly string[] All = + { + Neris, NerisIncidentAnalysis, FinanceExport, EIsuiteExchange, EmacReimbursement, AgencyRecordsFiling + }; + + /// The destinations worker 41 (RmsSubmissionCommand) delivers. Everything else waits for its own dispatcher. + public static readonly string[] NerisOwned = { Neris, NerisIncidentAnalysis }; + + public static bool IsNerisOwned(string destination) => NerisOwned.Contains(destination, StringComparer.Ordinal); + + public static bool IsKnown(string destination) => All.Contains(destination, StringComparer.Ordinal); } /// diff --git a/Core/Resgrid.Model/Records/RmsTemplatePacks.cs b/Core/Resgrid.Model/Records/RmsTemplatePacks.cs index c6fdd5b9..a2f6028b 100644 --- a/Core/Resgrid.Model/Records/RmsTemplatePacks.cs +++ b/Core/Resgrid.Model/Records/RmsTemplatePacks.cs @@ -139,6 +139,8 @@ public class RecordTemplateDefinition public string Description { get; set; } public RmsLifecyclePreset LifecyclePreset { get; set; } = RmsLifecyclePreset.QuickEntry; public string NumberPrefix { get; set; } + /// Template default for ; ICS-style forms number per incident. + public bool PerIncidentSequence { get; set; } public string PermittedSubjectTypes { get; set; } public RmsFieldClassification Classification { get; set; } public int? RetentionYears { get; set; } diff --git a/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs index 343c3612..b39c4166 100644 --- a/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsDefinitionRepositories.cs @@ -88,6 +88,8 @@ public interface IRmsExternalOrdersRepository : IRepository public interface IRmsExternalOrderFillsRepository : IRepository { Task> GetForOrderAsync(int departmentId, string orderId); + /// Every fill of a set of orders in one pass; the deployment list would otherwise query once per order. + Task> GetForOrdersAsync(int departmentId, IEnumerable orderIds); Task GetByIdForDepartmentAsync(int departmentId, string fillId); } } diff --git a/Core/Resgrid.Model/Repositories/IRmsExternalOrderConnectorRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsExternalOrderConnectorRepositories.cs new file mode 100644 index 00000000..eaa93b06 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsExternalOrderConnectorRepositories.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// External ordering-system connectors (RMS-1C completion, registry M0181). + public interface IRmsExternalOrderConnectorsRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string connectorId); + + /// The connector regardless of department, for the inbound endpoint that has only the id. + Task GetByIdAsync(string connectorId); + + Task> GetForDepartmentAsync(int departmentId); + + /// Enabled connectors across departments whose poll interval has elapsed, bounded. + Task> GetDueAsync(DateTime utcNow, int take); + + Task TryBumpRowVersionAsync(int departmentId, string connectorId, long expectedVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsExternalOrderConnectorRunsRepository : IRepository + { + Task> GetForConnectorAsync(int departmentId, string connectorId, int take); + + /// Trims the run log to the newest rows for one connector. + Task TrimAsync(int departmentId, string connectorId, int keep, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsFieldRolloutRepository.cs b/Core/Resgrid.Model/Repositories/IRmsFieldRolloutRepository.cs new file mode 100644 index 00000000..ef983b77 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsFieldRolloutRepository.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// Field Records rollout events (RMS-1D, registry M0180). + public interface IRmsFieldRolloutEventsRepository : IRepository + { + /// Every event in the window, oldest first. Bounded by so a dashboard cannot pull the archive. + Task> GetForWindowAsync(int departmentId, DateTime sinceUtc, int take); + + /// Inserts a batch in one round trip; returns how many rows were written. + Task InsertBatchAsync(IEnumerable events, CancellationToken cancellationToken = default); + + /// Deletes events older than the cutoff; rollout telemetry is operational, not a record. + Task DeleteOlderThanAsync(int departmentId, DateTime cutoffUtc, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs index 2f9a95c8..5bf002b1 100644 --- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs @@ -15,6 +15,9 @@ public sealed class RmsRecordQuery public IList States { get; set; } public string DefinitionKey { get; set; } public int? Year { get; set; } + /// Lower bound on COALESCE(FinalizedOn, OccurredOn, RecordCreatedOn): a report window belongs in the + /// query, never after the page has been cut, or matching Records fall off the end without any warning. + public DateTime? OccurredSince { get; set; } public int? CallId { get; set; } public string AuthorUserId { get; set; } public string OwnerUserId { get; set; } @@ -51,6 +54,9 @@ 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); + + /// Records created in the window, newest first. Bounded; used by the Field Records rollout dashboard (RMS-1D). + Task> GetCreatedSinceAsync(int departmentId, DateTime sinceUtc, int take); /// Retention candidates (RMS-3, worker 43): live, closed Records finalized before the cutoff, oldest first. Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take, string afterId = null); /// Live Records with no RmsRecordGroupScope row: they stay department-wide under group scoping (plan 5.7.1). diff --git a/Core/Resgrid.Model/Services/IRecordDeploymentConnectorsService.cs b/Core/Resgrid.Model/Services/IRecordDeploymentConnectorsService.cs new file mode 100644 index 00000000..6cdf22bd --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordDeploymentConnectorsService.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// External ordering-system connectors for mutual-aid deployments (RMS plan section 4.1). A connector may + /// exist only with a documented feed, an encrypted credential, a rate limit, an acknowledgement of the + /// source's terms and explicit read authority; write authority is refused in this release. Import creates + /// deployments for orders the department has not seen and records every later change as a new versioned + /// snapshot. Nothing local is ever transitioned by a connector: a disagreement is reconciliation for a person. + /// Management is department administration; every method that reads or changes a connector checks it. + /// + public interface IRecordDeploymentConnectorsService + { + Task> ListAsync(int departmentId, string userId); + + Task GetAsync(int departmentId, string userId, string connectorId); + + /// Creates the connector and returns the one-time inbound token; it is not recoverable afterwards. + Task CreateAsync(int departmentId, string userId, RecordDeploymentConnectorInput input, CancellationToken cancellationToken = default); + + Task UpdateAsync(int departmentId, string userId, string connectorId, long expectedRowVersion, RecordDeploymentConnectorInput input, CancellationToken cancellationToken = default); + + /// Enabling needs acknowledged terms, read authority and a stored credential where the kind needs one. + Task SetEnabledAsync(int departmentId, string userId, string connectorId, bool enabled, CancellationToken cancellationToken = default); + + Task AcknowledgeTermsAsync(int departmentId, string userId, string connectorId, CancellationToken cancellationToken = default); + + /// Issues a new inbound token, invalidating the old one; returns the new token once. + Task RotateInboundTokenAsync(int departmentId, string userId, string connectorId, CancellationToken cancellationToken = default); + + Task DeleteAsync(int departmentId, string userId, string connectorId, CancellationToken cancellationToken = default); + + /// Reads the feed now, on behalf of the administrator who asked, and imports it. + Task RunAsync(int departmentId, string userId, string connectorId, CancellationToken cancellationToken = default); + + /// Imports a feed document pushed to the inbound endpoint after the token has been verified. + Task ImportInboundAsync(string connectorId, string inboundToken, string feedJson, CancellationToken cancellationToken = default); + + /// Every enabled connector whose interval has elapsed; the worker calls this on its own cadence. + Task RunDueAsync(CancellationToken cancellationToken = default); + + Task> GetRunsAsync(int departmentId, string userId, string connectorId, int take); + + /// Where the source's latest snapshot and the department's own record disagree. Never applied automatically. + Task> GetReconciliationAsync(int departmentId, string userId, string connectorId = null); + } + + /// Reads one provider's feed. Every provider speaks the same contract; the provider fixes scheme, profile and mandatory identifiers. + public interface IExternalOrderFeedProvider + { + /// . + string Key { get; } + + string DefaultScheme { get; } + + string DefaultProfileKey { get; } + + /// Fetches one page of the feed. The credential is already decrypted; the cursor is what the last page returned. + Task FetchAsync(RmsExternalOrderConnector connector, string credential, string cursor, CancellationToken cancellationToken = default); + + /// Provider-specific identifier rules on a parsed feed; problems are reported per order and that order is rejected. + List ValidateOrder(ExternalOrderFeedOrder order); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordDeploymentsService.cs b/Core/Resgrid.Model/Services/IRecordDeploymentsService.cs index 44ee638f..833137ce 100644 --- a/Core/Resgrid.Model/Services/IRecordDeploymentsService.cs +++ b/Core/Resgrid.Model/Services/IRecordDeploymentsService.cs @@ -16,6 +16,12 @@ public interface IRecordDeploymentsService Task GetAsync(int departmentId, string userId, string orderId, bool includeArtifact = false); Task GetForRecordAsync(int departmentId, string userId, string recordId); Task> ListAsync(int departmentId, string userId, bool includeClosed); + /// + /// A bounded page of deployments with their fills, loaded in one pass. The list shape only needs the order, + /// its fills and the Record's number/state, so this deliberately skips the full Record hydrate and the + /// jurisdiction profiles that loads; use GetAsync for a single deployment. + /// + Task> ListAggregatesAsync(int departmentId, string userId, bool includeClosed, int take); Task AddFillAsync(int departmentId, string userId, string orderId, RecordDeploymentFillInput input, CancellationToken cancellationToken = default); Task TransitionFillAsync(int departmentId, string userId, string fillId, RecordDeploymentFillTransitionInput input, CancellationToken cancellationToken = default); /// Records a later snapshot of the same external order (a new versioned artifact); never overwrites signed history. diff --git a/Core/Resgrid.Model/Services/IRecordSavedReportsService.cs b/Core/Resgrid.Model/Services/IRecordSavedReportsService.cs index 7388cbf0..0b67063d 100644 --- a/Core/Resgrid.Model/Services/IRecordSavedReportsService.cs +++ b/Core/Resgrid.Model/Services/IRecordSavedReportsService.cs @@ -11,7 +11,8 @@ public interface IRecordSavedReportsService Task GetAsync(int departmentId, string reportId); Task ValidateAsync(int departmentId, RmsSavedReportDefinition report); Task SaveAsync(int departmentId, string userId, RmsSavedReportDefinition report, CancellationToken cancellationToken = default); - Task DeleteAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default); + /// Pass the row version the caller saw to reject a delete that would discard somebody else's edit. + Task DeleteAsync(int departmentId, string userId, string reportId, long? expectedRowVersion = null, CancellationToken cancellationToken = default); Task RunAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default); /// RFC 4180 CSV of a run (formula-guarded), for download. string ToCsv(RecordReportResult result); diff --git a/Core/Resgrid.Model/Services/IRecordsFieldRolloutService.cs b/Core/Resgrid.Model/Services/IRecordsFieldRolloutService.cs new file mode 100644 index 00000000..6ebaf4c6 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsFieldRolloutService.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Per-app Field Records rollout telemetry and its dashboard (RMS plan RMS-1D). Clients report bounded, coded + /// outcomes; the server records them against the authenticated department and member and answers aggregates. + /// Nothing here reads or returns record content — a rollout number is a count, never a disclosure. + /// + public interface IRecordsFieldRolloutService + { + /// Records a batch from one app; returns how many events were kept after validation and truncation. + Task RecordBatchAsync(int departmentId, string userId, RecordFieldRolloutBatch batch, CancellationToken cancellationToken = default); + + /// Records one server-observed outcome, such as a catalog refusal the client could not report itself. + Task RecordAsync(int departmentId, string userId, RmsOriginClient origin, string appVersion, string clientCapability, string eventType, string outcome, CancellationToken cancellationToken = default); + + /// The dashboard over the last days. Needs department administration. + Task GetAsync(int departmentId, string userId, int windowDays = 30, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Services/Records/Connectors/ExternalOrderFeedProviders.cs b/Core/Resgrid.Services/Records/Connectors/ExternalOrderFeedProviders.cs new file mode 100644 index 00000000..2c78660d --- /dev/null +++ b/Core/Resgrid.Services/Records/Connectors/ExternalOrderFeedProviders.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records.Connectors +{ + /// + /// Reads the Resgrid Mutual-Aid Order Feed over https (RMS plan section 4.1). One shared client, a bounded + /// timeout and a bounded body: a slow or enormous source cannot hold a worker or exhaust memory. The + /// credential travels only as the connector says it should — a bearer token or a named header — and is + /// never logged. + /// + public abstract class ExternalOrderFeedProviderBase : IExternalOrderFeedProvider + { + private static readonly HttpClient SharedHttpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(Math.Max(5, RecordsConnectorConfig.TimeoutSeconds)) }; + + private readonly HttpClient _http; + + protected ExternalOrderFeedProviderBase(HttpClient http = null) + { + _http = http ?? SharedHttpClient; + } + + public abstract string Key { get; } + public abstract string DefaultScheme { get; } + public abstract string DefaultProfileKey { get; } + + public virtual async Task FetchAsync(RmsExternalOrderConnector connector, string credential, string cursor, CancellationToken cancellationToken = default) + { + if (connector == null) throw new ArgumentNullException(nameof(connector)); + var root = (connector.BaseUrl ?? string.Empty).Trim(); + if (!Uri.TryCreate(root, UriKind.Absolute, out var uri)) + throw new InvalidOperationException("The connector has no usable feed root."); + if (!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) && !(RecordsConnectorConfig.AllowHttp && string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException("The feed root must be https."); + + var builder = new UriBuilder(uri); + if (!string.IsNullOrWhiteSpace(cursor)) + { + var query = builder.Query.TrimStart('?'); + builder.Query = (query.Length > 0 ? query + "&" : string.Empty) + "cursor=" + Uri.EscapeDataString(cursor); + } + + using var request = new HttpRequestMessage(HttpMethod.Get, builder.Uri); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.TryAddWithoutValidation("X-Resgrid-Feed-Contract", ExternalOrderFeedContract.Version); + switch ((connector.CredentialKind ?? RmsConnectorCredentialKinds.None).ToLowerInvariant()) + { + case RmsConnectorCredentialKinds.Bearer: + if (!string.IsNullOrEmpty(credential)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", credential); + break; + case RmsConnectorCredentialKinds.Header: + if (!string.IsNullOrEmpty(credential) && !string.IsNullOrWhiteSpace(connector.CredentialHeaderName)) request.Headers.TryAddWithoutValidation(connector.CredentialHeaderName.Trim(), credential); + break; + } + + using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"The source answered {(int)response.StatusCode} {response.ReasonPhrase}."); + if (response.Content.Headers.ContentLength.HasValue && response.Content.Headers.ContentLength.Value > RecordsConnectorConfig.MaxFeedBytes) + throw new InvalidOperationException($"The feed page is larger than the {RecordsConnectorConfig.MaxFeedBytes / (1024 * 1024)} MB limit."); + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var buffer = new System.IO.MemoryStream(); + var chunk = new byte[64 * 1024]; + int read; + while ((read = await stream.ReadAsync(chunk, 0, chunk.Length, cancellationToken)) > 0) + { + buffer.Write(chunk, 0, read); + if (buffer.Length > RecordsConnectorConfig.MaxFeedBytes) + throw new InvalidOperationException($"The feed page is larger than the {RecordsConnectorConfig.MaxFeedBytes / (1024 * 1024)} MB limit."); + } + return System.Text.Encoding.UTF8.GetString(buffer.ToArray()); + } + + public virtual List ValidateOrder(ExternalOrderFeedOrder order) => new List(); + } + + /// Any all-hazard or local mutual-aid system: opaque identifiers, no scheme-specific demands. + public sealed class GenericOrderFeedProvider : ExternalOrderFeedProviderBase + { + public GenericOrderFeedProvider(HttpClient http = null) : base(http) { } + public override string Key => RmsExternalOrderConnectorProviders.Generic; + public override string DefaultScheme => "local"; + public override string DefaultProfileKey => RmsDeploymentProfiles.LocalMutualAid; + } + + /// A member agency's own ordering system under its own opaque scheme. + public sealed class AgencyOrderFeedProvider : ExternalOrderFeedProviderBase + { + public AgencyOrderFeedProvider(HttpClient http = null) : base(http) { } + public override string Key => RmsExternalOrderConnectorProviders.Agency; + public override string DefaultScheme => "agency"; + public override string DefaultProfileKey => RmsDeploymentProfiles.Generic; + } + + /// + /// IROC-shaped U.S. wildland feed. IROC is the ordering authority; an order without a resource-order number + /// and a request without a request number are not IROC facts and are refused rather than provisioned. + /// + public sealed class IrocOrderFeedProvider : ExternalOrderFeedProviderBase + { + public IrocOrderFeedProvider(HttpClient http = null) : base(http) { } + public override string Key => RmsExternalOrderConnectorProviders.Iroc; + public override string DefaultScheme => "iroc"; + public override string DefaultProfileKey => RmsDeploymentProfiles.UsWildland; + + public override List ValidateOrder(ExternalOrderFeedOrder order) + { + var problems = new List(); + if (order == null) return problems; + if (string.IsNullOrWhiteSpace(order.IncidentNumber)) problems.Add("IROC orders carry an incident number."); + if (string.IsNullOrWhiteSpace(order.OrderingOffice) && string.IsNullOrWhiteSpace(order.DispatchOffice)) problems.Add("IROC orders name an ordering or dispatch office."); + foreach (var request in order.Requests ?? new List()) + { + if (string.IsNullOrWhiteSpace(request?.Category)) problems.Add($"Request {request?.RequestNumber} has no category (overhead, crew, equipment, aircraft, supply)."); + } + return problems; + } + } + + /// + /// CIFFC / member-agency Canadian exchange under MARS. The agency and the exchange identifier are what make + /// a request traceable to its agreement, so both are mandatory. + /// + public sealed class CiffcOrderFeedProvider : ExternalOrderFeedProviderBase + { + public CiffcOrderFeedProvider(HttpClient http = null) : base(http) { } + public override string Key => RmsExternalOrderConnectorProviders.Ciffc; + public override string DefaultScheme => "ciffc"; + public override string DefaultProfileKey => RmsDeploymentProfiles.CaWildland; + + public override List ValidateOrder(ExternalOrderFeedOrder order) + { + var problems = new List(); + if (order == null) return problems; + if (string.IsNullOrWhiteSpace(order.RequestingAgency) && string.IsNullOrWhiteSpace(order.ReceivingAgency)) problems.Add("CIFFC exchanges name a requesting or receiving agency."); + if (string.IsNullOrWhiteSpace(order.AgreementReference)) problems.Add("CIFFC exchanges carry the MARS agreement or exchange reference."); + return problems; + } + } +} diff --git a/Core/Resgrid.Services/Records/Connectors/RecordDeploymentConnectorsService.cs b/Core/Resgrid.Services/Records/Connectors/RecordDeploymentConnectorsService.cs new file mode 100644 index 00000000..01887b61 --- /dev/null +++ b/Core/Resgrid.Services/Records/Connectors/RecordDeploymentConnectorsService.cs @@ -0,0 +1,666 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records.Connectors +{ + /// + /// External ordering-system connectors (RMS plan section 4.1). The plan permits a connector only with a + /// documented API, credentials, rate limits, source terms, reconciliation and explicit read/write authority, + /// and forbids screen-scraping and inferred order updates. This service is where each of those is enforced: + /// a connector cannot be enabled without acknowledged terms and read authority; write authority is refused + /// outright; every fetch counts against the connector's hourly limit; an order the department has not seen + /// is provisioned from the feed, a changed order becomes a new versioned snapshot, a new request becomes a + /// Requested fill, and every other disagreement is surfaced to a person rather than applied. + /// + public class RecordDeploymentConnectorsService : IRecordDeploymentConnectorsService + { + /// Pages one run will follow; a source that never ends its cursor cannot run a worker forever. + public const int MaxPagesPerRun = 20; + + private readonly IRmsExternalOrderConnectorsRepository _connectors; + private readonly IRmsExternalOrderConnectorRunsRepository _runs; + private readonly IRmsExternalOrdersRepository _orders; + private readonly IRmsExternalOrderFillsRepository _fills; + private readonly IRecordDeploymentsService _deployments; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IReadOnlyDictionary _providers; + + public RecordDeploymentConnectorsService(IRmsExternalOrderConnectorsRepository connectors, IRmsExternalOrderConnectorRunsRepository runs, IRmsExternalOrdersRepository orders, + IRmsExternalOrderFillsRepository fills, IRecordDeploymentsService deployments, IRecordsAuthorizationService authorization, IRmsAccessAuditsRepository audits, + IEnumerable providers) + { + _connectors = connectors; + _runs = runs; + _orders = orders; + _fills = fills; + _deployments = deployments; + _authorization = authorization; + _audits = audits; + _providers = (providers ?? Enumerable.Empty()).ToDictionary(p => p.Key, StringComparer.OrdinalIgnoreCase); + } + + #region Management + + public async Task> ListAsync(int departmentId, string userId) + { + await RequireAdminAsync(departmentId, userId); + return (await _connectors.GetForDepartmentAsync(departmentId))?.Where(c => !c.DeletedOn.HasValue).OrderBy(c => c.Name).Select(Scrub).ToList() ?? new List(); + } + + public async Task GetAsync(int departmentId, string userId, string connectorId) + { + await RequireAdminAsync(departmentId, userId); + var connector = await _connectors.GetByIdForDepartmentAsync(departmentId, connectorId); + return connector == null || connector.DeletedOn.HasValue ? null : Scrub(connector); + } + + public async Task CreateAsync(int departmentId, string userId, RecordDeploymentConnectorInput input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + RequireConfigured(); + if (input == null) throw new ArgumentNullException(nameof(input)); + var provider = RequireProvider(input.ProviderKey); + var now = DateTime.UtcNow; + var token = NewInboundToken(); + + var connector = new RmsExternalOrderConnector + { + RmsExternalOrderConnectorId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + ProviderKey = provider.Key, + InboundTokenHash = Hash(token), + IsEnabled = false, + CreatedOn = now, + CreatedByUserId = userId, + ModifiedOn = now, + ModifiedByUserId = userId, + RowVersion = 1 + }; + Apply(connector, input, provider, userId); + if (!string.IsNullOrEmpty(input.Credential)) + connector.CredentialCiphertext = SymmetricEncryption.Encrypt(input.Credential, RecordsConnectorConfig.CredentialPassphrase); + + await _connectors.InsertAsync(connector, cancellationToken, true); + await AuditAsync(departmentId, userId, connector, "Create connector", cancellationToken, new { connector.ProviderKey, connector.BaseUrl, connector.ReadEnabled }); + return new RecordDeploymentConnectorCreated { Connector = Scrub(connector), InboundToken = token }; + } + + public async Task UpdateAsync(int departmentId, string userId, string connectorId, long expectedRowVersion, RecordDeploymentConnectorInput input, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + if (input == null) throw new ArgumentNullException(nameof(input)); + var connector = await LoadAsync(departmentId, connectorId); + if (connector.RowVersion != expectedRowVersion) throw new RecordConcurrencyException(connectorId, expectedRowVersion, connector.RowVersion); + var provider = RequireProvider(input.ProviderKey ?? connector.ProviderKey); + + Apply(connector, input, provider, userId); + if (!string.IsNullOrEmpty(input.Credential)) + { + RequireConfigured(); + connector.CredentialCiphertext = SymmetricEncryption.Encrypt(input.Credential, RecordsConnectorConfig.CredentialPassphrase); + } + // A change to where or how the connector reads means the terms it agreed to may no longer describe the + // source; the acknowledgement is dropped and the connector waits for a person to confirm again. + if (connector.IsEnabled && !connector.IsReadyToRun) + connector.IsEnabled = false; + + await SaveAsync(connector, userId, cancellationToken); + await AuditAsync(departmentId, userId, connector, "Update connector", cancellationToken, new { connector.ProviderKey, connector.BaseUrl, connector.ReadEnabled, connector.IsEnabled }); + return Scrub(connector); + } + + public async Task SetEnabledAsync(int departmentId, string userId, string connectorId, bool enabled, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var connector = await LoadAsync(departmentId, connectorId); + if (enabled) + { + RequireConfigured(); + if (!connector.TermsAcknowledgedOn.HasValue) throw new InvalidOperationException("Acknowledge the source's terms before enabling the connector."); + if (!connector.ReadEnabled) throw new InvalidOperationException("The connector has no read authority; nothing would be imported."); + if (RequiresCredential(connector) && string.IsNullOrEmpty(connector.CredentialCiphertext)) throw new InvalidOperationException("The connector's credential kind needs a stored credential."); + connector.ConsecutiveFailures = 0; + connector.LastError = null; + } + connector.IsEnabled = enabled; + await SaveAsync(connector, userId, cancellationToken); + await AuditAsync(departmentId, userId, connector, enabled ? "Enable connector" : "Disable connector", cancellationToken); + return Scrub(connector); + } + + public async Task AcknowledgeTermsAsync(int departmentId, string userId, string connectorId, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var connector = await LoadAsync(departmentId, connectorId); + if (string.IsNullOrWhiteSpace(connector.TermsReference)) throw new InvalidOperationException("Record where the source's terms are before acknowledging them."); + connector.TermsAcknowledgedOn = DateTime.UtcNow; + connector.TermsAcknowledgedByUserId = userId; + await SaveAsync(connector, userId, cancellationToken); + await AuditAsync(departmentId, userId, connector, "Acknowledge source terms", cancellationToken, new { connector.TermsReference }); + return Scrub(connector); + } + + public async Task RotateInboundTokenAsync(int departmentId, string userId, string connectorId, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var connector = await LoadAsync(departmentId, connectorId); + var token = NewInboundToken(); + connector.InboundTokenHash = Hash(token); + await SaveAsync(connector, userId, cancellationToken); + await AuditAsync(departmentId, userId, connector, "Rotate inbound token", cancellationToken); + return token; + } + + public async Task DeleteAsync(int departmentId, string userId, string connectorId, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var connector = await LoadAsync(departmentId, connectorId); + connector.IsEnabled = false; + connector.DeletedOn = DateTime.UtcNow; + // The credential and the token die with the connector; orders it imported keep their snapshots. + connector.CredentialCiphertext = null; + connector.InboundTokenHash = null; + await SaveAsync(connector, userId, cancellationToken); + await AuditAsync(departmentId, userId, connector, "Delete connector", cancellationToken); + } + + #endregion + + #region Running + + public async Task RunAsync(int departmentId, string userId, string connectorId, CancellationToken cancellationToken = default) + { + await RequireAdminAsync(departmentId, userId); + var connector = await LoadAsync(departmentId, connectorId); + return await ExecuteAsync(connector, RmsConnectorRunTriggers.Manual, userId, null, cancellationToken); + } + + public async Task ImportInboundAsync(string connectorId, string inboundToken, string feedJson, CancellationToken cancellationToken = default) + { + var connector = string.IsNullOrWhiteSpace(connectorId) ? null : await _connectors.GetByIdAsync(connectorId); + // The same refusal for an unknown connector and a wrong token: an attacker learns nothing from the difference. + if (connector == null || connector.DeletedOn.HasValue || string.IsNullOrEmpty(connector.InboundTokenHash) || string.IsNullOrEmpty(inboundToken) || !FixedTimeEquals(Hash(inboundToken), connector.InboundTokenHash)) + throw new UnauthorizedAccessException("The connector token was not accepted."); + return await ExecuteAsync(connector, RmsConnectorRunTriggers.Inbound, null, feedJson, cancellationToken); + } + + public async Task RunDueAsync(CancellationToken cancellationToken = default) + { + if (!RecordsConnectorConfig.Enabled) + return 0; + var due = (await _connectors.GetDueAsync(DateTime.UtcNow, 50))?.ToList() ?? new List(); + var ran = 0; + foreach (var connector in due) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await ExecuteAsync(connector, RmsConnectorRunTriggers.Poll, null, null, cancellationToken); + ran++; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex, "Connector poll failed for " + connector.RmsExternalOrderConnectorId); + } + } + return ran; + } + + /// One run: gates, fetch (unless the feed was pushed), import, bookkeeping. Every outcome leaves a run row. + private async Task ExecuteAsync(RmsExternalOrderConnector connector, string trigger, string triggeredByUserId, string pushedFeed, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var run = new RmsExternalOrderConnectorRun + { + RmsExternalOrderConnectorRunId = Guid.NewGuid().ToString(), DepartmentId = connector.DepartmentId, RmsExternalOrderConnectorId = connector.RmsExternalOrderConnectorId, + Trigger = trigger, TriggeredByUserId = triggeredByUserId, StartedOn = now, Outcome = RmsConnectorRunOutcomes.Ok + }; + var result = new RecordDeploymentConnectorRunResult { Run = run }; + // The administrator who set the connector up (or last acknowledged its terms) is the authority every + // import acts under; a poll has no person of its own. + var actor = triggeredByUserId ?? connector.TermsAcknowledgedByUserId ?? connector.CreatedByUserId; + + try + { + if (!RecordsConnectorConfig.Enabled) + return await FinishAsync(connector, run, result, RmsConnectorRunOutcomes.Disabled, "Connectors are switched off for this installation.", cancellationToken); + if (!connector.IsReadyToRun) + return await FinishAsync(connector, run, result, RmsConnectorRunOutcomes.Disabled, "The connector is not enabled, has no read authority, or its terms are unacknowledged.", cancellationToken); + if (connector.WriteEnabled) + return await FinishAsync(connector, run, result, RmsConnectorRunOutcomes.Rejected, "Write authority is not granted in this release.", cancellationToken); + + var provider = RequireProvider(connector.ProviderKey); + var pages = new List(); + if (pushedFeed != null) + { + // A push counts against the same hourly limit as a poll; a chatty source cannot bypass it by pushing. + if (!TakeRequestSlot(connector, now)) + return await FinishAsync(connector, run, result, RmsConnectorRunOutcomes.RateLimited, $"The connector's limit of {connector.MaxRequestsPerHour} requests per hour is spent.", cancellationToken); + run.RequestCount++; + var pushed = ExternalOrderFeedContract.Parse(pushedFeed, out var problems); + if (pushed == null) + return await FinishAsync(connector, run, result, RmsConnectorRunOutcomes.Rejected, string.Join(" ", problems), cancellationToken); + pages.Add(pushed); + } + else + { + var credential = string.IsNullOrEmpty(connector.CredentialCiphertext) ? null : SymmetricEncryption.Decrypt(connector.CredentialCiphertext, RecordsConnectorConfig.CredentialPassphrase); + var cursor = connector.LastCursor; + for (var page = 0; page < MaxPagesPerRun; page++) + { + if (!TakeRequestSlot(connector, now)) + { + if (pages.Count == 0) + return await FinishAsync(connector, run, result, RmsConnectorRunOutcomes.RateLimited, $"The connector's limit of {connector.MaxRequestsPerHour} requests per hour is spent.", cancellationToken); + result.Messages.Add("Stopped at the hourly request limit; the rest follows on the next run."); + break; + } + run.RequestCount++; + var body = await provider.FetchAsync(connector, credential, cursor, cancellationToken); + var feed = ExternalOrderFeedContract.Parse(body, out var problems); + if (feed == null) + return await FinishAsync(connector, run, result, RmsConnectorRunOutcomes.Rejected, string.Join(" ", problems), cancellationToken); + pages.Add(feed); + cursor = feed.Cursor; + if (string.IsNullOrWhiteSpace(cursor)) + break; + } + connector.LastCursor = cursor; + } + + foreach (var feed in pages) + { + run.SourceVersion = feed.Source?.Version ?? run.SourceVersion; + await ImportFeedAsync(connector, provider, feed, actor, run, result, cancellationToken); + } + + connector.LastSuccessOn = DateTime.UtcNow; + connector.ConsecutiveFailures = 0; + connector.LastError = null; + return await FinishAsync(connector, run, result, RmsConnectorRunOutcomes.Ok, null, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex, "Connector run failed for " + connector.RmsExternalOrderConnectorId); + connector.ConsecutiveFailures++; + if (connector.ConsecutiveFailures >= RecordsConnectorConfig.DisableAfterConsecutiveFailures) + { + // A source that keeps failing is switched off rather than hammered; an administrator re-enables it. + connector.IsEnabled = false; + result.Messages.Add("The connector was disabled after repeated failures."); + } + return await FinishAsync(connector, run, result, RmsConnectorRunOutcomes.Failed, ex.Message, cancellationToken); + } + } + + private async Task FinishAsync(RmsExternalOrderConnector connector, RmsExternalOrderConnectorRun run, RecordDeploymentConnectorRunResult result, string outcome, string error, CancellationToken cancellationToken) + { + run.Outcome = outcome; + run.Error = Trim(error, 1000); + run.FinishedOn = DateTime.UtcNow; + connector.LastPolledOn = run.StartedOn; + if (error != null && outcome != RmsConnectorRunOutcomes.Ok) + connector.LastError = Trim(error, 500); + try + { + await _runs.InsertAsync(run, cancellationToken, true); + await _runs.TrimAsync(connector.DepartmentId, connector.RmsExternalOrderConnectorId, RecordsConnectorConfig.RunHistoryToKeep, cancellationToken); + connector.ModifiedOn = DateTime.UtcNow; + connector.RowVersion += 1; + await _connectors.UpdateAsync(connector, cancellationToken, true); + } + catch (Exception ex) + { + Logging.LogException(ex, "Connector run bookkeeping failed for " + connector.RmsExternalOrderConnectorId); + } + if (!string.IsNullOrEmpty(error)) + result.Messages.Add(error); + return result; + } + + /// Hourly window: the first request after an hour opens a new window; a spent window refuses. + private static bool TakeRequestSlot(RmsExternalOrderConnector connector, DateTime now) + { + if (!connector.RateWindowStartedOn.HasValue || now - connector.RateWindowStartedOn.Value >= TimeSpan.FromHours(1)) + { + connector.RateWindowStartedOn = now; + connector.RequestsThisHour = 0; + } + if (connector.MaxRequestsPerHour > 0 && connector.RequestsThisHour >= connector.MaxRequestsPerHour) + return false; + connector.RequestsThisHour++; + return true; + } + + #endregion + + #region Import + + /// + /// The import rules, in the plan's words: the source stays authoritative for what was ordered; a later + /// import lands as a new versioned snapshot rather than overwriting signed history; nothing local is + /// transitioned on the source's say-so. So an unseen order is provisioned, a changed order gets a new + /// snapshot, an unseen request becomes a Requested fill, and everything else is reconciliation. + /// + private async Task ImportFeedAsync(RmsExternalOrderConnector connector, IExternalOrderFeedProvider provider, ExternalOrderFeed feed, string actor, RmsExternalOrderConnectorRun run, RecordDeploymentConnectorRunResult result, CancellationToken cancellationToken) + { + var scheme = string.IsNullOrWhiteSpace(connector.SourceScheme) ? provider.DefaultScheme : connector.SourceScheme; + var existing = (await _orders.GetForDepartmentAsync(connector.DepartmentId, true))?.Where(o => !o.DeletedOn.HasValue).ToList() ?? new List(); + + foreach (var order in feed.Orders) + { + cancellationToken.ThrowIfCancellationRequested(); + run.OrdersSeen++; + var problems = provider.ValidateOrder(order); + if (problems.Count > 0) + { + run.Rejected++; + result.Messages.Add($"Order {order.OrderNumber}: {string.Join(" ", problems)}"); + continue; + } + + var snapshot = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(order, Formatting.None)); + var version = string.IsNullOrWhiteSpace(order.SourceVersion) ? feed.Source?.Version : order.SourceVersion.Trim(); + var match = existing.FirstOrDefault(o => string.Equals(o.OrderNumber, order.OrderNumber.Trim(), StringComparison.OrdinalIgnoreCase) && string.Equals(o.SourceScheme, scheme, StringComparison.OrdinalIgnoreCase)); + + if (match == null) + { + var input = ToCreateInput(connector, provider, scheme, order, snapshot, version); + var created = await _deployments.CreateFromExternalOrderAsync(connector.DepartmentId, actor, input, cancellationToken); + existing.Add(created.Order); + run.OrdersCreated++; + run.RequestsAdded += input.Fills.Count; + await AuditAsync(connector.DepartmentId, actor, connector, $"Import order {order.OrderNumber} ({run.Trigger})", cancellationToken, new { created.Order.RmsExternalOrderId, version }); + continue; + } + + if (match.Status == (int)RmsExternalOrderStatus.ClosedOut) + { + // A closed-out deployment is signed history; the source's later view is a reconciliation note, not a change. + run.Unchanged++; + continue; + } + + var checksum = RecordSnapshotSerializer.Checksum(snapshot); + var changed = !string.Equals(match.ArtifactChecksum, checksum, StringComparison.Ordinal); + if (changed) + { + await _deployments.RecordSourceSnapshotAsync(connector.DepartmentId, actor, match.RmsExternalOrderId, version, snapshot, FileNameFor(scheme, order.OrderNumber, version), "application/json", cancellationToken); + run.SnapshotsRecorded++; + } + else + { + run.Unchanged++; + } + + var fills = (await _fills.GetForOrderAsync(connector.DepartmentId, match.RmsExternalOrderId))?.ToList() ?? new List(); + foreach (var request in order.Requests) + { + if (fills.Any(f => string.Equals(f.RequestNumber, request.RequestNumber?.Trim(), StringComparison.OrdinalIgnoreCase))) + continue; + if (string.Equals(request.Status, ExternalOrderFeedContract.RequestStatuses.Cancelled, StringComparison.OrdinalIgnoreCase)) + continue; + fills.Add(await _deployments.AddFillAsync(connector.DepartmentId, actor, match.RmsExternalOrderId, ToFillInput(request), cancellationToken)); + run.RequestsAdded++; + } + + // What is left after the source's own facts have landed is for a person. + run.Conflicts += Reconcile(connector, match, order, fills).Count; + } + } + + private static RecordDeploymentCreateInput ToCreateInput(RmsExternalOrderConnector connector, IExternalOrderFeedProvider provider, string scheme, ExternalOrderFeedOrder order, byte[] snapshot, string version) + { + var profile = string.IsNullOrWhiteSpace(connector.ProfileKey) ? provider.DefaultProfileKey : connector.ProfileKey; + return new RecordDeploymentCreateInput + { + ProfileKey = profile, SourceScheme = scheme, SourceSystem = connector.SourceSystem, OrderNumber = order.OrderNumber?.Trim(), IncidentName = order.IncidentName?.Trim(), + IncidentNumber = order.IncidentNumber, IncidentCountry = order.IncidentCountry, IncidentSubdivision = order.IncidentSubdivision, OrderingOffice = order.OrderingOffice, + DispatchOffice = order.DispatchOffice, RequestingAgency = order.RequestingAgency, ReceivingAgency = order.ReceivingAgency, SendingAgency = order.SendingAgency, + CostCode = order.CostCode, AgreementReference = order.AgreementReference, CurrencyCode = order.CurrencyCode, MeasurementSystem = order.MeasurementSystem, + TimeZoneId = order.TimeZoneId, SourceCapturedOn = order.CapturedOn?.UtcDateTime, SourceVersion = version, + ArtifactData = snapshot, ArtifactFileName = FileNameFor(scheme, order.OrderNumber, version), ArtifactContentType = "application/json", + ArtifactSafeUrl = order.Artifact?.Url, + ConnectorId = connector.RmsExternalOrderConnectorId, OwnershipMarker = RmsExternalOrderOwnership.Connector, + Fills = order.Requests.Where(r => !string.Equals(r.Status, ExternalOrderFeedContract.RequestStatuses.Cancelled, StringComparison.OrdinalIgnoreCase)).Select(ToFillInput).ToList() + }; + } + + private static RecordDeploymentFillInput ToFillInput(ExternalOrderFeedRequest request) => new RecordDeploymentFillInput + { + RequestNumber = request.RequestNumber?.Trim(), ParentRequestNumber = request.ParentRequestNumber, RequestCategory = request.Category, FillNumber = request.FillNumber, + ResourceKind = request.ResourceKind, ResourceType = request.ResourceType, ResourceTypeScheme = request.ResourceTypeScheme, Position = request.Position, PositionScheme = request.PositionScheme, + IsTrainee = request.IsTrainee, HomeUnit = request.HomeUnit, HostAgency = request.HostAgency, AgencyUnitId = request.AgencyUnitId, PointOfHire = request.PointOfHire, + CostCode = request.CostCode, AgreementReference = request.AgreementReference, RequestedOn = request.RequestedOn?.UtcDateTime, NeededOn = request.NeededOn?.UtcDateTime + }; + + private static string FileNameFor(string scheme, string orderNumber, string version) + { + var safe = new string((scheme + "-" + orderNumber).Select(c => char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '-').ToArray()); + return safe + (string.IsNullOrWhiteSpace(version) ? string.Empty : "-v" + new string(version.Where(char.IsLetterOrDigit).ToArray())) + ".json"; + } + + #endregion + + #region Reconciliation + + public async Task> GetRunsAsync(int departmentId, string userId, string connectorId, int take) + { + await RequireAdminAsync(departmentId, userId); + return (await _runs.GetForConnectorAsync(departmentId, connectorId, Math.Max(1, Math.Min(500, take))))?.ToList() ?? new List(); + } + + public async Task> GetReconciliationAsync(int departmentId, string userId, string connectorId = null) + { + await RequireAdminAsync(departmentId, userId); + var orders = (await _orders.GetForDepartmentAsync(departmentId, false))?.Where(o => !o.DeletedOn.HasValue && string.Equals(o.OwnershipMarker, RmsExternalOrderOwnership.Connector, StringComparison.OrdinalIgnoreCase)).ToList() ?? new List(); + if (!string.IsNullOrWhiteSpace(connectorId)) + orders = orders.Where(o => string.Equals(o.ConnectorId, connectorId, StringComparison.OrdinalIgnoreCase)).ToList(); + + var items = new List(); + foreach (var order in orders) + { + var artifact = await _orders.GetArtifactAsync(departmentId, order.RmsExternalOrderId); + if (artifact == null || artifact.Length == 0) continue; + ExternalOrderFeedOrder source; + try { source = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(artifact)); } + catch (JsonException) { continue; } + if (source == null) continue; + var fills = (await _fills.GetForOrderAsync(departmentId, order.RmsExternalOrderId))?.ToList() ?? new List(); + var connector = new RmsExternalOrderConnector { RmsExternalOrderConnectorId = order.ConnectorId, DepartmentId = departmentId }; + items.AddRange(Reconcile(connector, order, source, fills)); + } + return items; + } + + /// Pure comparison of one source order against the department's own record; shown, never applied. + public static List Reconcile(RmsExternalOrderConnector connector, RmsExternalOrder order, ExternalOrderFeedOrder source, List fills) + { + var items = new List(); + if (order == null || source == null) return items; + fills ??= new List(); + + RecordDeploymentReconciliationItem Item(string kind, string requestNumber, string sourceStatus, string localStatus) => new RecordDeploymentReconciliationItem + { + ConnectorId = connector?.RmsExternalOrderConnectorId, OrderId = order.RmsExternalOrderId, RecordId = order.RecordId, OrderNumber = order.OrderNumber, RequestNumber = requestNumber, + Kind = kind, SourceStatus = sourceStatus, LocalStatus = localStatus, SourceVersion = source.SourceVersion, SourceCapturedOn = source.CapturedOn?.UtcDateTime + }; + + var sourceStatus = (source.Status ?? string.Empty).Trim().ToLowerInvariant(); + var outstanding = fills.Where(f => f.Status != (int)RmsDeploymentFillStatus.Declined && f.Status != (int)RmsDeploymentFillStatus.Returned).ToList(); + if ((sourceStatus == ExternalOrderFeedContract.OrderStatuses.Released || sourceStatus == ExternalOrderFeedContract.OrderStatuses.Closed) && outstanding.Count > 0) + { + // The plan's own warning: an external release flag never returns a resource. It is a note for a person. + items.Add(Item(sourceStatus == ExternalOrderFeedContract.OrderStatuses.Closed ? RecordDeploymentReconciliationItem.SourceClosedLocalOpen : RecordDeploymentReconciliationItem.SourceReleasedLocalOut, null, sourceStatus, ((RmsExternalOrderStatus)order.Status).ToString().ToLowerInvariant())); + } + + foreach (var request in source.Requests ?? new List()) + { + var fill = fills.FirstOrDefault(f => string.Equals(f.RequestNumber, request.RequestNumber?.Trim(), StringComparison.OrdinalIgnoreCase)); + var requestStatus = (request.Status ?? ExternalOrderFeedContract.RequestStatuses.Requested).Trim().ToLowerInvariant(); + if (fill == null) + { + if (requestStatus != ExternalOrderFeedContract.RequestStatuses.Cancelled) + items.Add(Item(RecordDeploymentReconciliationItem.SourceRequestMissingLocally, request.RequestNumber, requestStatus, null)); + continue; + } + var local = ExternalOrderFeedContract.LocalStatusOf((RmsDeploymentFillStatus)fill.Status); + if (requestStatus == ExternalOrderFeedContract.RequestStatuses.Cancelled && fill.Status != (int)RmsDeploymentFillStatus.Declined) + { + items.Add(Item(RecordDeploymentReconciliationItem.SourceStatusAhead, request.RequestNumber, requestStatus, local)); + continue; + } + var sourceRank = ExternalOrderFeedContract.RequestStatuses.Rank(requestStatus); + var localRank = ExternalOrderFeedContract.RequestStatuses.Rank(local); + if (fill.Status == (int)RmsDeploymentFillStatus.Returned || sourceRank == 0 || localRank == 0) continue; + if (sourceRank > localRank) items.Add(Item(RecordDeploymentReconciliationItem.SourceStatusAhead, request.RequestNumber, requestStatus, local)); + else if (sourceRank < localRank) items.Add(Item(RecordDeploymentReconciliationItem.SourceStatusBehind, request.RequestNumber, requestStatus, local)); + } + + foreach (var fill in fills) + { + if (fill.Status == (int)RmsDeploymentFillStatus.Declined) continue; + if (!(source.Requests ?? new List()).Any(r => string.Equals(r.RequestNumber?.Trim(), fill.RequestNumber, StringComparison.OrdinalIgnoreCase))) + items.Add(Item(RecordDeploymentReconciliationItem.LocalFillMissingInSource, fill.RequestNumber, null, ExternalOrderFeedContract.LocalStatusOf((RmsDeploymentFillStatus)fill.Status))); + } + + return items; + } + + #endregion + + #region Helpers + + private async Task RequireAdminAsync(int departmentId, string userId) + { + if (!await _authorization.IsDepartmentAdminAsync(userId, departmentId)) + throw new UnauthorizedAccessException("External order connectors are department administration only."); + } + + private static void RequireConfigured() + { + if (!RecordsConnectorConfig.Enabled) throw new InvalidOperationException("External order connectors are switched off for this installation."); + if (string.IsNullOrWhiteSpace(RecordsConnectorConfig.CredentialPassphrase)) throw new InvalidOperationException("No credential passphrase is configured; connector credentials cannot be stored."); + } + + private IExternalOrderFeedProvider RequireProvider(string key) + { + if (!RmsExternalOrderConnectorProviders.IsKnown(key) || !_providers.TryGetValue(key.Trim(), out var provider)) + throw new ArgumentException($"'{key}' is not a connector provider ({string.Join(", ", RmsExternalOrderConnectorProviders.All)})."); + return provider; + } + + private async Task LoadAsync(int departmentId, string connectorId) + { + var connector = string.IsNullOrWhiteSpace(connectorId) ? null : await _connectors.GetByIdForDepartmentAsync(departmentId, connectorId); + if (connector == null || connector.DeletedOn.HasValue) throw new ArgumentException("Unknown connector.", nameof(connectorId)); + return connector; + } + + private static void Apply(RmsExternalOrderConnector connector, RecordDeploymentConnectorInput input, IExternalOrderFeedProvider provider, string userId) + { + if (input.WriteEnabled) throw new InvalidOperationException("Write authority to an external ordering system is not granted in this release; the plan permits no external writes."); + if (string.IsNullOrWhiteSpace(input.Name)) throw new ArgumentException("A connector needs a name.", nameof(input)); + var baseUrl = (input.BaseUrl ?? string.Empty).Trim(); + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)) throw new ArgumentException("The feed root must be an absolute URL.", nameof(input)); + if (!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) && !(RecordsConnectorConfig.AllowHttp && string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException("The feed root must be https.", nameof(input)); + if (!RmsConnectorCredentialKinds.IsKnown(input.CredentialKind)) throw new ArgumentException("Choose a credential kind: none, bearer or header.", nameof(input)); + if (string.Equals(input.CredentialKind, RmsConnectorCredentialKinds.Header, StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(input.CredentialHeaderName)) + throw new ArgumentException("A header credential needs the header name.", nameof(input)); + var profile = string.IsNullOrWhiteSpace(input.ProfileKey) ? provider.DefaultProfileKey : input.ProfileKey.Trim().ToLowerInvariant(); + if (!RmsDeploymentProfiles.IsKnown(profile)) throw new ArgumentException($"'{input.ProfileKey}' is not a deployment profile.", nameof(input)); + + var previousBase = connector.BaseUrl; + connector.ProviderKey = provider.Key; + connector.Name = input.Name.Trim(); + connector.SourceSystem = Trim(input.SourceSystem, 200) ?? provider.Key; + connector.SourceScheme = string.IsNullOrWhiteSpace(input.SourceScheme) ? provider.DefaultScheme : input.SourceScheme.Trim().ToLowerInvariant(); + connector.ProfileKey = profile; + connector.BaseUrl = baseUrl; + connector.CredentialKind = input.CredentialKind.Trim().ToLowerInvariant(); + connector.CredentialHeaderName = Trim(input.CredentialHeaderName, 100); + connector.ReadEnabled = input.ReadEnabled; + connector.WriteEnabled = false; + connector.PollIntervalMinutes = Math.Max(RecordsConnectorConfig.MinPollIntervalMinutes, input.PollIntervalMinutes); + connector.MaxRequestsPerHour = input.MaxRequestsPerHour > 0 ? Math.Min(600, input.MaxRequestsPerHour) : RecordsConnectorConfig.DefaultMaxRequestsPerHour; + connector.TermsReference = Trim(input.TermsReference, 500); + // Terms are acknowledged for a source; pointing the connector somewhere else needs a fresh acknowledgement. + if (previousBase != null && !string.Equals(previousBase, baseUrl, StringComparison.OrdinalIgnoreCase)) + { + connector.TermsAcknowledgedOn = null; + connector.TermsAcknowledgedByUserId = null; + } + connector.ModifiedByUserId = userId; + } + + private static bool RequiresCredential(RmsExternalOrderConnector connector) => !string.Equals(connector.CredentialKind, RmsConnectorCredentialKinds.None, StringComparison.OrdinalIgnoreCase); + + private async Task SaveAsync(RmsExternalOrderConnector connector, string userId, CancellationToken cancellationToken) + { + connector.ModifiedOn = DateTime.UtcNow; + connector.ModifiedByUserId = userId; + connector.RowVersion += 1; + await _connectors.UpdateAsync(connector, cancellationToken, true); + } + + /// The stored ciphertext and token hash never leave the service. A copy is scrubbed, never the row itself. + private static RmsExternalOrderConnector Scrub(RmsExternalOrderConnector connector) + { + if (connector == null) return null; + var copy = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(connector)); + copy.CredentialCiphertext = string.IsNullOrEmpty(connector.CredentialCiphertext) ? null : "stored"; + copy.InboundTokenHash = string.IsNullOrEmpty(connector.InboundTokenHash) ? null : "set"; + return copy; + } + + private Task AuditAsync(int departmentId, string userId, RmsExternalOrderConnector connector, string purpose, CancellationToken cancellationToken, object detail = null) + => _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, Action = (int)RmsAccessAuditAction.Admin, ActorUserId = userId, Purpose = purpose, OriginClient = (int)RmsOriginClient.System, Successful = true, + OccurredOn = DateTime.UtcNow, CorrelationId = connector.RmsExternalOrderConnectorId, DetailJson = detail == null ? null : JsonConvert.SerializeObject(detail) + }, cancellationToken, true); + + private static string NewInboundToken() + { + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + public static string Hash(string token) + { + using var sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant(); + } + + private static bool FixedTimeEquals(string left, string right) + => left != null && right != null && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(left), Encoding.UTF8.GetBytes(right)); + + private static string Trim(string value, int max) => string.IsNullOrWhiteSpace(value) ? null : (value.Trim().Length > max ? value.Trim().Substring(0, max) : value.Trim()); + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/Evidence/PackProjectionEvidenceAdapter.cs b/Core/Resgrid.Services/Records/Evidence/PackProjectionEvidenceAdapter.cs index 8ed6ffce..8be6bd6b 100644 --- a/Core/Resgrid.Services/Records/Evidence/PackProjectionEvidenceAdapter.cs +++ b/Core/Resgrid.Services/Records/Evidence/PackProjectionEvidenceAdapter.cs @@ -197,6 +197,9 @@ private async Task CommandSummaryAsync(RecordEvidenceCapt var board = await _command.GetCommandBoardAsync(request.DepartmentId, callId.Value); if (board?.Command == null) return RecordEvidenceCapture.Unavailable("No incident command was established for this Call."); var command = board.Command; + // Both identifiers go into an Unrestricted manifest, so the capturing user has to be cleared for them the + // same way the participant projections require. + await RequirePeopleAsync(request, new[] { command.EstablishedByUserId, command.CurrentCommanderUserId }.Where(id => !string.IsNullOrWhiteSpace(id))); var now = DateTime.UtcNow; return new RecordEvidenceCapture { diff --git a/Core/Resgrid.Services/Records/FieldRecordsService.cs b/Core/Resgrid.Services/Records/FieldRecordsService.cs index 48038b83..16755e9c 100644 --- a/Core/Resgrid.Services/Records/FieldRecordsService.cs +++ b/Core/Resgrid.Services/Records/FieldRecordsService.cs @@ -30,11 +30,13 @@ public class FieldRecordsService : IFieldRecordsService private readonly IDepartmentGroupsService _groups; private readonly ICallsService _calls; private readonly IIncidentCommandService _command; + private readonly IRecordsFieldRolloutService _rollout; public FieldRecordsService(IRecordsCutoverService cutover, IRecordsAuthorizationService authorization, IFeatureToggleService flags, IRecordDefinitionsService definitions, IDepartmentDataProtectionService protection, IRecordsService records, IRecordWorkAssignmentsService assignments, IUnitsService units, IDepartmentGroupsService groups, - ICallsService calls, IIncidentCommandService command) + ICallsService calls, IIncidentCommandService command, IRecordsFieldRolloutService rollout) { + _rollout = rollout; _cutover = cutover; _authorization = authorization; _flags = flags; @@ -172,7 +174,7 @@ public async Task GetCatalogAsync(int departmentId, string u if (!preflight.Ok) { catalog.Reasons.AddRange(preflight.Reasons); - return catalog; + return await RecordCatalogOutcomeAsync(departmentId, userId, request, capability, catalog); } var verification = await VerifyContextAsync(departmentId, userId, request.Origin, context); @@ -180,14 +182,14 @@ public async Task GetCatalogAsync(int departmentId, string u if (!verification.Ok) { catalog.Reasons.AddRange(verification.Reasons); - return catalog; + return await RecordCatalogOutcomeAsync(departmentId, userId, request, capability, catalog); } catalog.ScopeStamp = await _authorization.GetReadScopeStampAsync(userId, departmentId); if (catalog.ScopeStamp == null) { catalog.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.NotMember); - return catalog; + return await RecordCatalogOutcomeAsync(departmentId, userId, request, capability, catalog); } if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord)) @@ -201,10 +203,35 @@ public async Task GetCatalogAsync(int departmentId, string u || string.Equals(preflight.ProtectionState, DepartmentDataProtectionState.Rotating.ToString(), StringComparison.Ordinal); AddLockedStarters(catalog, request.Origin, context, capability); - await AddDepartmentDefinitionsAsync(departmentId, catalog, request.Origin, request.AppVersion, capability, context, enforced); + // A transient listing failure and a department with nothing published look identical to the app, and the + // app caches what it is told, so say the catalog is unusable rather than handing back the starters alone. + if (!await AddDepartmentDefinitionsAsync(departmentId, catalog, request.Origin, request.AppVersion, capability, context, enforced)) + { + catalog.Reasons.Add(FieldRecordCatalogV1.ExclusionReasons.RecordsNotUsable); + return await RecordCatalogOutcomeAsync(departmentId, userId, request, capability, catalog); + } catalog.Definitions = catalog.Definitions.OrderBy(d => d.Locked ? 1 : 0).ThenBy(d => d.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); catalog.Ok = true; + return await RecordCatalogOutcomeAsync(departmentId, userId, request, capability, catalog); + } + + /// + /// A catalog refusal is the one rollout outcome a client cannot report faithfully — it may not have been + /// given a reason it understands, and a refused client is exactly the one whose telemetry is unreliable. + /// So the server records it (RMS plan RMS-1D rollout dashboards) and never lets that recording fail a read. + /// + private async Task RecordCatalogOutcomeAsync(int departmentId, string userId, FieldRecordCatalogRequest request, string capability, FieldRecordCatalog catalog) + { + try + { + var outcome = catalog.Ok ? "ok" : (catalog.Reasons.FirstOrDefault() ?? "denied"); + await _rollout.RecordAsync(departmentId, userId, request.Origin, request.AppVersion, capability, RmsFieldRolloutEventTypes.Catalog, outcome); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex, "Field Records catalog outcome could not be recorded."); + } return catalog; } @@ -245,7 +272,8 @@ private static void AddLockedStarters(FieldRecordCatalog catalog, RmsOriginClien } } - private async Task AddDepartmentDefinitionsAsync(int departmentId, FieldRecordCatalog catalog, RmsOriginClient origin, string appVersion, string capability, FieldRecordContext context, bool protectionEnforced) + /// False when the definitions could not be listed; the caller must not report that as an empty catalog. + private async Task AddDepartmentDefinitionsAsync(int departmentId, FieldRecordCatalog catalog, RmsOriginClient origin, string appVersion, string capability, FieldRecordContext context, bool protectionEnforced) { List published; List summaries; @@ -257,7 +285,7 @@ private async Task AddDepartmentDefinitionsAsync(int departmentId, FieldRecordCa catch (Exception ex) { Framework.Logging.LogException(ex, "Field Records catalog could not list department definitions."); - return; + return false; } var byKey = summaries.ToDictionary(s => s.Key, StringComparer.OrdinalIgnoreCase); @@ -328,6 +356,8 @@ private async Task AddDepartmentDefinitionsAsync(int departmentId, FieldRecordCa PrefillVersion = version.Version }); } + + return true; } private static bool SurfaceAllows(RecordDefinitionClientSurface surface, RmsOriginClient origin) diff --git a/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs b/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs index 3ae38adf..e4e4c1ec 100644 --- a/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs +++ b/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs @@ -8,6 +8,7 @@ using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Formats.Tiff; using SixLabors.ImageSharp.Formats.Webp; @@ -26,6 +27,9 @@ public sealed class AttachmentHygieneResult public string ContentType { get; set; } public bool IsImage { get; set; } public bool MetadataStripped { get; set; } + + /// True when the definition's policy kept the EXIF GPS block; recorded on the attachment. + public bool LocationRetained { get; set; } } /// @@ -55,7 +59,14 @@ public static class RecordAttachmentHygiene ".png", ".jpg", ".jpeg", ".jpe", ".gif", ".webp", ".bmp", ".tif", ".tiff" }; - public static AttachmentHygieneResult Sanitize(string fileName, string contentType, byte[] data) + public static AttachmentHygieneResult Sanitize(string fileName, string contentType, byte[] data) => Sanitize(fileName, contentType, data, false); + + /// + /// keeps the EXIF GPS block and nothing else — device identity, serial + /// numbers, author, software and timestamps still go. It is set only by a definition whose profile needs the + /// coordinates (RMS plan RMS-1D), and the caller records which way the decision went on the attachment. + /// + public static AttachmentHygieneResult Sanitize(string fileName, string contentType, byte[] data, bool retainLocation) { if (data == null || data.Length == 0) throw new RecordAttachmentRejectedException("Attachment content is required."); @@ -85,7 +96,10 @@ public static AttachmentHygieneResult Sanitize(string fileName, string contentTy throw new RecordAttachmentRejectedException($"Attachment '{safeName}' is larger than the {MaxPixels / 1_000_000} megapixel limit."); var format = image.Metadata.DecodedImageFormat ?? PngFormat.Instance; + var location = retainLocation ? ExtractLocation(image) : null; StripMetadata(image); + if (location != null) + image.Metadata.ExifProfile = location; using var output = new MemoryStream(); image.Save(output, EncoderFor(format)); @@ -96,7 +110,8 @@ public static AttachmentHygieneResult Sanitize(string fileName, string contentTy FileName = safeName, ContentType = format.DefaultMimeType, IsImage = true, - MetadataStripped = true + MetadataStripped = true, + LocationRetained = location != null }; } catch (RecordAttachmentRejectedException) @@ -110,6 +125,49 @@ public static AttachmentHygieneResult Sanitize(string fileName, string contentTy } } + /// + /// The source image's GPS tags on a profile of their own, or null when it carries none. Copying tag by tag + /// rather than filtering a clone is deliberate: only what is named here can survive, so a future EXIF tag + /// carrying device or person identity cannot slip through by default. + /// + private static ExifProfile ExtractLocation(Image image) + { + var source = image.Metadata.ExifProfile; + if (source == null) + return null; + + var kept = new ExifProfile(); + Copy(source, kept, ExifTag.GPSVersionID); + Copy(source, kept, ExifTag.GPSLatitudeRef); + Copy(source, kept, ExifTag.GPSLatitude); + Copy(source, kept, ExifTag.GPSLongitudeRef); + Copy(source, kept, ExifTag.GPSLongitude); + Copy(source, kept, ExifTag.GPSAltitudeRef); + Copy(source, kept, ExifTag.GPSAltitude); + Copy(source, kept, ExifTag.GPSTimestamp); + Copy(source, kept, ExifTag.GPSSatellites); + Copy(source, kept, ExifTag.GPSStatus); + Copy(source, kept, ExifTag.GPSMeasureMode); + Copy(source, kept, ExifTag.GPSDOP); + Copy(source, kept, ExifTag.GPSSpeedRef); + Copy(source, kept, ExifTag.GPSSpeed); + Copy(source, kept, ExifTag.GPSTrackRef); + Copy(source, kept, ExifTag.GPSTrack); + Copy(source, kept, ExifTag.GPSImgDirectionRef); + Copy(source, kept, ExifTag.GPSImgDirection); + Copy(source, kept, ExifTag.GPSMapDatum); + Copy(source, kept, ExifTag.GPSDateStamp); + Copy(source, kept, ExifTag.GPSDifferential); + + return kept.Values.Count == 0 ? null : kept; + } + + private static void Copy(ExifProfile source, ExifProfile destination, ExifTag tag) + { + if (source.TryGetValue(tag, out var value) && value != null && value.Value != null) + destination.SetValue(tag, value.Value); + } + private static void StripMetadata(Image image) { image.Metadata.ExifProfile = null; diff --git a/Core/Resgrid.Services/Records/RecordDefinitionsService.cs b/Core/Resgrid.Services/Records/RecordDefinitionsService.cs index 3ad2a961..e0e1b5cc 100644 --- a/Core/Resgrid.Services/Records/RecordDefinitionsService.cs +++ b/Core/Resgrid.Services/Records/RecordDefinitionsService.cs @@ -21,7 +21,17 @@ namespace Resgrid.Services.Records public class RecordDefinitionsService : IRecordDefinitionsService { public const string DefinitionAggregate = "RmsRecordDefinition"; - private static readonly string[] KnownSubjectTypes = { "call", "unit", "group", "contact", "person", "checklist", "workorder", "inventory", "none" }; + /// + /// Subject/reference types a definition may permit. The incident.* group and "vendor" are the incident-support + /// subjects (Back Office plan E1): a Record scoped to a command instance, an operational period, a checked-in + /// participant, a supplied resource, a facility, an external resource request, or a vendor. RMS-1C's EOC, SAR + /// and Mutual Aid packs need period- and participant-scoped records whether or not the Back Office ships. + /// + private static readonly string[] KnownSubjectTypes = + { + "call", "unit", "group", "contact", "person", "checklist", "workorder", "inventory", "none", + "incidentcommand", "incidentoperationalperiod", "incidentparticipant", "incidentresource", "incidentfacility", "incidentresourcerequest", "vendor" + }; private readonly IRmsRecordDefinitionsRepository _definitions; private readonly IRmsRecordDefinitionVersionsRepository _versions; @@ -149,7 +159,7 @@ public async Task CreateAsync(int departmentId, strin profileKey = rendering.ProfileKey; draft.Schema = rendering.Schema; draft.LifecyclePreset = rendering.Template.LifecyclePreset; - draft.Numbering = new RecordDefinitionNumbering { Prefix = rendering.Template.NumberPrefix }; + draft.Numbering = new RecordDefinitionNumbering { Prefix = rendering.Template.NumberPrefix, PerIncidentSequence = rendering.Template.PerIncidentSequence }; draft.PermittedSubjectTypes = rendering.Template.PermittedSubjectTypes; draft.Classification = rendering.Template.Classification; draft.RetentionYears = rendering.Template.RetentionYears; @@ -312,6 +322,10 @@ public Task ValidateAsync(int departmentId, RecordDe if (string.IsNullOrWhiteSpace(numbering.Prefix) || numbering.Prefix.Length < 2 || numbering.Prefix.Length > 6 || !numbering.Prefix.All(c => char.IsLetterOrDigit(c) && !char.IsLower(c))) issues.Add(RecordDefinitionIssue.Error("numbering.prefix", "bad_prefix", "The number prefix is 2 to 6 upper-case letters or digits.")); if (numbering.SequenceWidth < 3 || numbering.SequenceWidth > 8) issues.Add(RecordDefinitionIssue.Error("numbering.sequenceWidth", "out_of_range", "The sequence width is 3 to 8 digits.")); + // Incident-scoped numbering only reaches its scope through a Call; without the subject every Record falls + // back to the department sequence, which is a surprise rather than an error worth blocking a publish on. + if (numbering.PerIncidentSequence && !(input.PermittedSubjectTypes ?? string.Empty).Split(',').Select(s => s.Trim()).Contains("call", StringComparer.OrdinalIgnoreCase)) + issues.Add(RecordDefinitionIssue.Warning("numbering.perIncidentSequence", "no_call_subject", "Incident-scoped numbering needs the 'call' subject; Records without a Call use the department sequence.")); if (!Enum.IsDefined(typeof(RmsNumberAssignment), numbering.Assignment)) issues.Add(RecordDefinitionIssue.Error("numbering.assignment", "unknown", "Numbers are assigned OnFinalize or OnCreate.")); if (schema.Sections.Count == 0) issues.Add(RecordDefinitionIssue.Error("schema", "no_sections", "A definition needs at least one section.")); diff --git a/Core/Resgrid.Services/Records/RecordDeploymentsService.cs b/Core/Resgrid.Services/Records/RecordDeploymentsService.cs index acb7a231..5c5237e7 100644 --- a/Core/Resgrid.Services/Records/RecordDeploymentsService.cs +++ b/Core/Resgrid.Services/Records/RecordDeploymentsService.cs @@ -30,6 +30,7 @@ public class RecordDeploymentsService : IRecordDeploymentsService private readonly IRmsExternalOrderFillsRepository _fills; private readonly IRmsExternalReferencesRepository _references; private readonly IRecordsService _records; + private readonly IRmsOperationalRecordsRepository _recordRows; private readonly IRecordDefinitionsService _definitions; private readonly IRecordTemplatePacksService _packs; private readonly IRecordsAuthorizationService _authorization; @@ -37,12 +38,14 @@ public class RecordDeploymentsService : IRecordDeploymentsService private readonly IUnitOfWork _unitOfWork; public RecordDeploymentsService(IRmsExternalOrdersRepository orders, IRmsExternalOrderFillsRepository fills, IRmsExternalReferencesRepository references, IRecordsService records, - IRecordDefinitionsService definitions, IRecordTemplatePacksService packs, IRecordsAuthorizationService authorization, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork) + IRmsOperationalRecordsRepository recordRows, IRecordDefinitionsService definitions, IRecordTemplatePacksService packs, IRecordsAuthorizationService authorization, + IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork) { _orders = orders; _fills = fills; _references = references; _records = records; + _recordRows = recordRows; _definitions = definitions; _packs = packs; _authorization = authorization; @@ -102,6 +105,12 @@ public async Task CreateFromExternalOrderAsync(int de }; var record = await _records.CreateDraftAsync(departmentId, userId, draft, cancellationToken); + // A retried create replays the same draft through the idempotency key, and a Record carries exactly one + // order snapshot (UX_RmsExternalOrders_Department_Record). Hand back what is already there rather than + // letting the second insert hit that index. + var already = await _orders.GetForRecordAsync(departmentId, record.Record.RmsOperationalRecordId); + if (already != null) return await BuildAsync(departmentId, already); + var now = DateTime.UtcNow; var order = new RmsExternalOrder { @@ -115,16 +124,29 @@ public async Task CreateFromExternalOrderAsync(int de TimeZoneId = input.TimeZoneId, CapturedOffsetMinutes = input.CapturedOffsetMinutes, SourceCapturedOn = input.SourceCapturedOn ?? now, SourceVersion = input.SourceVersion ?? "1", ArtifactFileName = input.ArtifactData == null ? null : input.ArtifactFileName, ArtifactContentType = input.ArtifactData == null ? null : input.ArtifactContentType, ArtifactChecksum = input.ArtifactData == null ? null : RecordSnapshotSerializer.Checksum(input.ArtifactData), ArtifactData = input.ArtifactData, ArtifactSafeUrl = SafeUrl(input.ArtifactSafeUrl), + ConnectorId = string.IsNullOrWhiteSpace(input.ConnectorId) ? null : input.ConnectorId.Trim(), + OwnershipMarker = !string.IsNullOrWhiteSpace(input.ConnectorId) && string.Equals(input.OwnershipMarker, RmsExternalOrderOwnership.Connector, StringComparison.OrdinalIgnoreCase) ? RmsExternalOrderOwnership.Connector : RmsExternalOrderOwnership.Manual, Status = (int)RmsExternalOrderStatus.Open, CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, ModifiedByUserId = userId, RowVersion = 1 }; var fills = (input.Fills ?? new List()).Select(f => ToFill(order, f, userId, now)).ToList(); - await InTransactionAsync(async () => + try { - await _orders.InsertAsync(order, cancellationToken, true); - foreach (var fill in fills) await _fills.InsertAsync(fill, cancellationToken, true); - await AuditAsync(departmentId, userId, order, $"Create deployment from external order {order.OrderNumber} ({profileKey})", cancellationToken); - }); + await InTransactionAsync(async () => + { + await _orders.InsertAsync(order, cancellationToken, true); + foreach (var fill in fills) await _fills.InsertAsync(fill, cancellationToken, true); + await AuditAsync(departmentId, userId, order, $"Create deployment from external order {order.OrderNumber} ({profileKey})", cancellationToken); + }); + } + catch + { + // The draft Record was created before this transaction, so a rollback here would otherwise leave a + // deployment Record with no order behind it. Cancel it so the reserved number is recorded as voided. + try { await _records.CancelAsync(departmentId, userId, order.RecordId, cancellationToken); } + catch (Exception cleanup) { Framework.Logging.LogException(cleanup, "The orphaned deployment draft could not be cancelled after the order insert failed."); } + throw; + } return await GetAsync(departmentId, userId, order.RmsExternalOrderId); } @@ -244,6 +266,24 @@ public async Task> ListAsync(int departmentId, string use return visible; } + public async Task> ListAggregatesAsync(int departmentId, string userId, bool includeClosed, int take) + { + var orders = (await ListAsync(departmentId, userId, includeClosed)).Take(Math.Clamp(take <= 0 ? 50 : take, 1, 200)).ToList(); + if (orders.Count == 0) return new List(); + + var fills = (await _fills.GetForOrdersAsync(departmentId, orders.Select(o => o.RmsExternalOrderId)))?.ToList() ?? new List(); + var fillsByOrder = fills.ToLookup(f => f.RmsExternalOrderId, StringComparer.Ordinal); + var records = (await _recordRows.GetByIdsAsync(departmentId, orders.Select(o => o.RecordId)))?.ToList() ?? new List(); + var recordsById = records.GroupBy(r => r.RmsOperationalRecordId, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + return orders.Select(order => new RecordDeploymentAggregate + { + Order = order, + Fills = fillsByOrder[order.RmsExternalOrderId].OrderBy(f => f.RequestNumber, StringComparer.Ordinal).ToList(), + Record = recordsById.TryGetValue(order.RecordId, out var row) ? new RecordAggregate { Record = row } : null + }).ToList(); + } + public async Task AddFillAsync(int departmentId, string userId, string orderId, RecordDeploymentFillInput input, CancellationToken cancellationToken = default) { var order = await RequireEditableAsync(departmentId, userId, orderId); @@ -275,6 +315,10 @@ public async Task TransitionFillAsync(int departmentId, st if (input == null) throw new ArgumentNullException(nameof(input)); var fill = await _fills.GetByIdForDepartmentAsync(departmentId, fillId) ?? throw new ArgumentException("Unknown fill.", nameof(fillId)); var order = await RequireEditableAsync(departmentId, userId, fill.RmsExternalOrderId); + // Two callers can both read Requested and both pass the state-machine check, and the second write would + // silently drop the first transition along with its audit line. + if (input.ExpectedRowVersion.HasValue && fill.RowVersion != input.ExpectedRowVersion.Value) + throw new RecordConcurrencyException(fill.RmsExternalOrderFillId, input.ExpectedRowVersion.Value, fill.RowVersion); var from = (RmsDeploymentFillStatus)fill.Status; if (!Allowed.TryGetValue(from, out var next) || !next.Contains(input.Status)) throw new InvalidOperationException($"A fill cannot move from {from} to {input.Status}."); diff --git a/Core/Resgrid.Services/Records/RecordSavedReportsService.cs b/Core/Resgrid.Services/Records/RecordSavedReportsService.cs index d20591a8..41be1fb4 100644 --- a/Core/Resgrid.Services/Records/RecordSavedReportsService.cs +++ b/Core/Resgrid.Services/Records/RecordSavedReportsService.cs @@ -20,6 +20,12 @@ namespace Resgrid.Services.Records /// public class RecordSavedReportsService : IRecordSavedReportsService { + /// Matches the projection repository's own Take ceiling, so a page is never silently shortened. + private const int ProjectionPageSize = 500; + + /// Upper bound on Records examined in one run; a very selective filter must not scan without limit. + private const int MaxProjectionsScanned = 50000; + public static readonly IReadOnlyDictionary BuiltInColumns = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["record.number"] = "Record number", ["record.draft_reference"] = "Draft reference", ["record.state"] = "State", ["record.definition_version"] = "Definition version", @@ -71,6 +77,9 @@ public async Task ValidateAsync(int departmentId, RmsSav if (report.MaxRowsPerRun < 1 || report.MaxRowsPerRun > RmsSavedReportDefinition.MaxRows) issues.Add(RecordDefinitionIssue.Error("maxRowsPerRun", "out_of_range", $"Rows per run is 1 to {RmsSavedReportDefinition.MaxRows}.")); foreach (var column in spec.Columns) { + // Spec keys come from client JSON, so a null entry has to become a validation issue rather than the + // ArgumentNullException that Dictionary.ContainsKey(null) would raise. + if (string.IsNullOrWhiteSpace(column)) { issues.Add(RecordDefinitionIssue.Error("columns", "unknown_field", "A column key is required.")); continue; } if (BuiltInColumns.ContainsKey(column)) continue; var field = schema.FindField(column); if (field == null) { issues.Add(RecordDefinitionIssue.Error("columns", "unknown_field", $"'{column}' is not a field of version {version.Version}.")); continue; } @@ -125,21 +134,28 @@ public async Task SaveAsync(int departmentId, string u await AuditAsync(departmentId, userId, report, "Create saved report", cancellationToken); return report; } - if (existing.RowVersion != report.RowVersion) throw new RecordConcurrencyException(existing.RmsSavedReportDefinitionId, report.RowVersion, existing.RowVersion); + // Comparing the loaded version in memory lets two writers both pass and the second overwrite the first, so + // the claim is staked with the repository's conditional bump and the write follows the version it won. + if (!await _reports.TryBumpRowVersionAsync(departmentId, existing.RmsSavedReportDefinitionId, report.RowVersion, cancellationToken)) + throw new RecordConcurrencyException(existing.RmsSavedReportDefinitionId, report.RowVersion, existing.RowVersion); existing.Name = report.Name; existing.Description = report.Description; existing.DefinitionKey = report.DefinitionKey; existing.DefinitionVersion = report.DefinitionVersion; existing.SpecJson = report.SpecJson; existing.MaxRowsPerRun = report.MaxRowsPerRun; existing.IncludeRestricted = report.IncludeRestricted; - existing.ModifiedOn = now; existing.ModifiedByUserId = userId; existing.RowVersion += 1; + existing.ModifiedOn = now; existing.ModifiedByUserId = userId; existing.RowVersion = report.RowVersion + 1; await _reports.UpdateAsync(existing, cancellationToken, true); await AuditAsync(departmentId, userId, existing, "Update saved report", cancellationToken); return existing; } - public async Task DeleteAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default) + public async Task DeleteAsync(int departmentId, string userId, string reportId, long? expectedRowVersion = null, CancellationToken cancellationToken = default) { await RequireManageAsync(userId, departmentId); var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId); if (report == null) return false; - report.DeletedOn = DateTime.UtcNow; report.ModifiedOn = report.DeletedOn.Value; report.ModifiedByUserId = userId; report.RowVersion += 1; + // A caller that supplies the version it saw must not delete a report somebody else just edited. + var expected = expectedRowVersion ?? report.RowVersion; + if (!await _reports.TryBumpRowVersionAsync(departmentId, report.RmsSavedReportDefinitionId, expected, cancellationToken)) + throw new RecordConcurrencyException(report.RmsSavedReportDefinitionId, expected, report.RowVersion); + report.DeletedOn = DateTime.UtcNow; report.ModifiedOn = report.DeletedOn.Value; report.ModifiedByUserId = userId; report.RowVersion = expected + 1; await _reports.UpdateAsync(report, cancellationToken, true); await AuditAsync(departmentId, userId, report, "Delete saved report", cancellationToken); return true; @@ -166,47 +182,71 @@ public async Task RunAsync(int departmentId, string userId, : new[] { RmsRecordState.Finalized, RmsRecordState.Amended, RmsRecordState.Submitted, RmsRecordState.Accepted, RmsRecordState.Rejected, RmsRecordState.Corrected }; var query = new RmsRecordQuery { - DefinitionKey = aggregate.Definition.DefinitionKey, States = states.Select(s => (int)s).ToList(), ViewerUserId = userId, Skip = 0, Take = take + 1, + DefinitionKey = aggregate.Definition.DefinitionKey, States = states.Select(s => (int)s).ToList(), ViewerUserId = userId, Skip = 0, Take = ProjectionPageSize, + OccurredSince = spec.WindowDays.HasValue ? DateTime.UtcNow.AddDays(-spec.WindowDays.Value) : (DateTime?)null, VisibleGroupIds = await _authorization.IsGroupScopedAsync(departmentId) ? await _authorization.GetVisibleGroupIdsAsync(userId, departmentId) : null }; - var projections = await _records.QueryAsync(departmentId, query); - var since = spec.WindowDays.HasValue ? DateTime.UtcNow.AddDays(-spec.WindowDays.Value) : (DateTime?)null; - if (since.HasValue) projections = projections.Where(p => (p.FinalizedOn ?? p.OccurredOn ?? p.RecordCreatedOn) >= since.Value).ToList(); - var truncated = projections.Count > take; - projections = projections.Take(take).ToList(); - - var rows = (await _recordRows.GetByIdsAsync(departmentId, projections.Select(p => p.SourceId)))?.ToList() ?? new List(); - var revisionIds = rows.Where(r => r.CurrentRevisionId != null && !RmsLifecycle.IsEditable((RmsRecordState)r.State)).Select(r => r.CurrentRevisionId).ToList(); - var draftIds = rows.Where(r => r.CurrentRevisionId == null || RmsLifecycle.IsEditable((RmsRecordState)r.State)).Select(r => r.RmsOperationalRecordId).ToList(); - var valueRows = new List(); var groupRows = new List(); - if (revisionIds.Count > 0) { valueRows.AddRange(await _values.GetForRevisionsAsync(departmentId, revisionIds) ?? Enumerable.Empty()); groupRows.AddRange(await _groups.GetForRevisionsAsync(departmentId, revisionIds) ?? Enumerable.Empty()); } - if (draftIds.Count > 0) { valueRows.AddRange(await _values.GetForRecordsAsync(departmentId, draftIds, true) ?? Enumerable.Empty()); groupRows.AddRange(await _groups.GetForRecordsAsync(departmentId, draftIds, true) ?? Enumerable.Empty()); } - var result = new RecordReportResult { ReportId = report.RmsSavedReportDefinitionId, Name = report.Name, DefinitionKey = report.DefinitionKey, DefinitionVersion = reportVersion?.Version, RanOn = DateTime.UtcNow, Columns = spec.Columns.ToList(), Truncated = truncated }; - result.ColumnLabels = spec.Columns.Select(c => BuiltInColumns.TryGetValue(c, out var l) ? l : reportVersion?.Schema.FindField(c)?.Label ?? c).ToList(); + var result = new RecordReportResult { ReportId = report.RmsSavedReportDefinitionId, Name = report.Name, DefinitionKey = report.DefinitionKey, DefinitionVersion = reportVersion?.Version, RanOn = DateTime.UtcNow, Columns = spec.Columns.ToList() }; + result.ColumnLabels = spec.Columns.Select(c => c != null && BuiltInColumns.TryGetValue(c, out var l) ? l : reportVersion?.Schema.FindField(c)?.Label ?? c).ToList(); - var shaped = new List<(RmsOperationalRecord Record, RecordValueSet Values, RecordDefinitionSchema Schema, Dictionary Map)>(); - foreach (var record in rows) + // The window rides the query now, but spec.Filters run against typed values and cannot, so the page has to + // be filled from as many projection pages as it takes. Cutting one page and filtering afterwards silently + // drops matching Records whenever non-matching ones happen to sort first. + var matched = new List<(RmsOperationalRecord Record, RecordValueSet Values, RecordDefinitionSchema Schema, Dictionary Map)>(); + var scanned = 0; + var scanCapReached = false; + while (matched.Count <= take) { - if (!versions.TryGetValue(record.DefinitionVersion, out var version)) { if (!result.UnmappedVersions.Contains(record.DefinitionVersion)) result.UnmappedVersions.Add(record.DefinitionVersion); continue; } - var isDraft = draftIds.Contains(record.RmsOperationalRecordId); - var set = RecordTypedValuesService.Shape(version.Schema, - groupRows.Where(g => g.RecordId == record.RmsOperationalRecordId && (isDraft ? g.RevisionId == null : g.RevisionId == record.CurrentRevisionId)), - valueRows.Where(v => v.RecordId == record.RmsOperationalRecordId && (isDraft ? v.RevisionId == null : v.RevisionId == record.CurrentRevisionId)), canViewRestricted && report.IncludeRestricted); - Dictionary map = null; - if (reportVersion != null && record.DefinitionVersion != reportVersion.Version) + cancellationToken.ThrowIfCancellationRequested(); + // A filter that matches almost nothing would otherwise walk the whole definition on a request thread, + // so the scan is bounded too and the run reports itself as truncated when it stops early. + if (scanned >= MaxProjectionsScanned) { scanCapReached = true; break; } + var projections = await _records.QueryAsync(departmentId, query); + if (projections.Count == 0) break; + query.Skip += projections.Count; + scanned += projections.Count; + + var rows = (await _recordRows.GetByIdsAsync(departmentId, projections.Select(p => p.SourceId)))?.ToList() ?? new List(); + var revisionIds = rows.Where(r => r.CurrentRevisionId != null && !RmsLifecycle.IsEditable((RmsRecordState)r.State)).Select(r => r.CurrentRevisionId).ToList(); + var draftIds = rows.Where(r => r.CurrentRevisionId == null || RmsLifecycle.IsEditable((RmsRecordState)r.State)).Select(r => r.RmsOperationalRecordId).ToList(); + var valueRows = new List(); var groupRows = new List(); + if (revisionIds.Count > 0) { valueRows.AddRange(await _values.GetForRevisionsAsync(departmentId, revisionIds) ?? Enumerable.Empty()); groupRows.AddRange(await _groups.GetForRevisionsAsync(departmentId, revisionIds) ?? Enumerable.Empty()); } + if (draftIds.Count > 0) { valueRows.AddRange(await _values.GetForRecordsAsync(departmentId, draftIds, true) ?? Enumerable.Empty()); groupRows.AddRange(await _groups.GetForRecordsAsync(departmentId, draftIds, true) ?? Enumerable.Empty()); } + + // Indexed once per page; the shaping loop below is otherwise a linear scan of every value row per Record. + var draftIdSet = new HashSet(draftIds, StringComparer.Ordinal); + var valuesByRecord = valueRows.ToLookup(v => v.RecordId, StringComparer.Ordinal); + var groupsByRecord = groupRows.ToLookup(g => g.RecordId, StringComparer.Ordinal); + + foreach (var record in rows) { - if (!spec.VersionMappings.TryGetValue(record.DefinitionVersion, out map)) + if (!versions.TryGetValue(record.DefinitionVersion, out var version)) { if (!result.UnmappedVersions.Contains(record.DefinitionVersion)) result.UnmappedVersions.Add(record.DefinitionVersion); continue; } + var isDraft = draftIdSet.Contains(record.RmsOperationalRecordId); + var set = RecordTypedValuesService.Shape(version.Schema, + groupsByRecord[record.RmsOperationalRecordId].Where(g => isDraft ? g.RevisionId == null : g.RevisionId == record.CurrentRevisionId), + valuesByRecord[record.RmsOperationalRecordId].Where(v => isDraft ? v.RevisionId == null : v.RevisionId == record.CurrentRevisionId), canViewRestricted && report.IncludeRestricted); + Dictionary map = null; + if (reportVersion != null && record.DefinitionVersion != reportVersion.Version) { - // Same keys carry over; anything else is unmapped and the version is reported. - map = spec.Columns.Concat(spec.Filters.Select(f => f.FieldKey)).Concat(new[] { spec.GroupByFieldKey }).Concat(spec.Aggregates.Select(a => a.FieldKey)).Where(k => k != null && version.Schema.FindField(k) != null).Distinct(StringComparer.OrdinalIgnoreCase).ToDictionary(k => k, k => k, StringComparer.OrdinalIgnoreCase); - if (spec.Columns.Any(c => !BuiltInColumns.ContainsKey(c) && !map.ContainsKey(c)) && !result.UnmappedVersions.Contains(record.DefinitionVersion)) result.UnmappedVersions.Add(record.DefinitionVersion); + if (!spec.VersionMappings.TryGetValue(record.DefinitionVersion, out map)) + { + // Same keys carry over; anything else is unmapped and the version is reported. + map = spec.Columns.Concat(spec.Filters.Select(f => f.FieldKey)).Concat(new[] { spec.GroupByFieldKey }).Concat(spec.Aggregates.Select(a => a.FieldKey)).Where(k => k != null && version.Schema.FindField(k) != null).Distinct(StringComparer.OrdinalIgnoreCase).ToDictionary(k => k, k => k, StringComparer.OrdinalIgnoreCase); + if (spec.Columns.Any(c => c != null && !BuiltInColumns.ContainsKey(c) && !map.ContainsKey(c)) && !result.UnmappedVersions.Contains(record.DefinitionVersion)) result.UnmappedVersions.Add(record.DefinitionVersion); + } } + + var candidate = (Record: record, Values: set, Schema: version.Schema, Map: map); + if (spec.Filters.All(f => Matches(f, Cell(candidate, f.FieldKey), record))) matched.Add(candidate); } - shaped.Add((record, set, version.Schema, map)); + + if (projections.Count < ProjectionPageSize) break; } - var matched = shaped.Where(s => spec.Filters.All(f => Matches(f, Cell(s, f.FieldKey), s.Record))).ToList(); + var truncated = matched.Count > take || scanCapReached; + if (matched.Count > take) matched = matched.Take(take).ToList(); + result.Truncated = truncated; result.TotalMatched = matched.Count; IEnumerable<(RmsOperationalRecord Record, RecordValueSet Values, RecordDefinitionSchema Schema, Dictionary Map)> ordered = matched; if (!string.IsNullOrWhiteSpace(spec.SortFieldKey)) @@ -244,11 +284,15 @@ public async Task RunAsync(int departmentId, string userId, } } if (result.UnmappedVersions.Count > 0) result.Warnings.Add("Definition versions without a column mapping: " + string.Join(", ", result.UnmappedVersions.OrderBy(v => v)) + ". Declare a mapping to include them."); - if (truncated) result.Warnings.Add($"The run stopped at {take} records; narrow the window or filters."); + if (scanCapReached) result.Warnings.Add($"The run stopped after examining {MaxProjectionsScanned} Records; narrow the window or filters."); + else if (truncated) result.Warnings.Add($"The run stopped at {take} records; narrow the window or filters."); - report.LastRunOn = result.RanOn; report.LastRunByUserId = userId; - await _reports.UpdateAsync(report, cancellationToken, true); - await AuditAsync(departmentId, userId, report, $"Run saved report ({result.TotalMatched} records)", cancellationToken); + // Only the run-tracking columns: the row was loaded before a potentially long run, so writing it whole + // would revert any Name, SpecJson or MaxRowsPerRun edit made while the run was in flight. + var current = await _reports.GetByIdForDepartmentAsync(departmentId, reportId) ?? report; + current.LastRunOn = result.RanOn; current.LastRunByUserId = userId; + await _reports.UpdateAsync(current, cancellationToken, true); + await AuditAsync(departmentId, userId, current, $"Run saved report ({result.TotalMatched} records)", cancellationToken); return result; } @@ -284,8 +328,10 @@ private static bool Matches(RecordReportFilter filter, RecordValueCell cell, Rms var values = cell?.Values ?? (value == null ? new List() : new List { value }); switch (filter.Operator) { - case RmsRuleOperator.IsEmpty: return values.Count == 0 && string.IsNullOrWhiteSpace(value); - case RmsRuleOperator.IsNotEmpty: return values.Count > 0 && !string.IsNullOrWhiteSpace(value); + // A scalar cell has no Values collection, so values falls back to a one-element list holding value: + // counting entries called a blank cell non-empty, and called a multi-select cell neither. + case RmsRuleOperator.IsEmpty: return string.IsNullOrWhiteSpace(value) && values.All(string.IsNullOrWhiteSpace); + case RmsRuleOperator.IsNotEmpty: return !string.IsNullOrWhiteSpace(value) || values.Any(v => !string.IsNullOrWhiteSpace(v)); case RmsRuleOperator.Equals: return values.Any(v => string.Equals(v, filter.Value, StringComparison.OrdinalIgnoreCase)) || cell != null && string.Equals(cell.Display, filter.Value, StringComparison.OrdinalIgnoreCase); case RmsRuleOperator.NotEquals: return !(values.Any(v => string.Equals(v, filter.Value, StringComparison.OrdinalIgnoreCase)) || cell != null && string.Equals(cell.Display, filter.Value, StringComparison.OrdinalIgnoreCase)); case RmsRuleOperator.InSet: return values.Any(v => (filter.Values ?? new List()).Contains(v, StringComparer.OrdinalIgnoreCase)); diff --git a/Core/Resgrid.Services/Records/RecordTemplateCatalog.IncidentSupport.cs b/Core/Resgrid.Services/Records/RecordTemplateCatalog.IncidentSupport.cs new file mode 100644 index 00000000..40e0a6e4 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordTemplateCatalog.IncidentSupport.cs @@ -0,0 +1,1036 @@ +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model; + +namespace Resgrid.Services.Records +{ + /// + /// The Incident Support pack (Back Office plan section 10A.2, extension E5): the ICS/NWCG planning, resource, + /// communications, safety, incident-business, support-operations and business-administration records of a large + /// incident, published through the ordinary RMS pack pipeline as product content rather than as schema. + /// + /// Three rules shape everything below. + /// + /// 1. Web only. Every definition ships (extension E7). These + /// are desk products authored in a planning section, not phone forms; the four field apps exclude them with + /// SurfaceNotEnabled and nothing here is ever written to a device. + /// 2. Incident-scoped numbering (extension E2). The third ICS 214 on an incident is 003 on that incident, not + /// 003 for the department this year. + /// 3. The ledger is authoritative (section 10A.0). Time, equipment use, expense, cost and lodging arithmetic + /// belongs to the Incident Back Office. Where a form below carries those numbers it carries the entry made on + /// the incident plus an external reference to the authoritative row; when that ledger ships, the reference is + /// what a rendered total is computed from. Nothing here becomes a second place a bed-night is counted. + /// + /// Nothing in this pack is an exact named form. It ships Preview and carries no claim that NWCG, FEMA, Cal OES, + /// CIFFC or any member agency accepts its output, until a real artifact has been produced and reconciled. + /// + public static partial class RecordTemplateCatalog + { + public const string IncidentSupportPackKey = "pack.incident-support"; + + /// A pack definition: Web-only surface and incident-scoped numbering, per the pack rules above. + private static RecordTemplateDefinition Ics(string key, string name, string category, string description, RmsLifecyclePreset preset, string prefix, string subjects, params RecordSectionSchema[] sections) + { + var template = Template(IncidentSupportPackKey + "." + key, IncidentSupportPackKey, name, category, description, preset, prefix, subjects, sections); + template.ClientSurface = RecordDefinitionClientSurface.WebOnly(); + template.PerIncidentSequence = true; + template.RetentionYears = 7; + return template; + } + + /// The header every incident product carries: which incident, which operational period, who prepared it. + private static RecordSectionSchema Header(bool withPeriod = true) + { + var fields = new List + { + F("incident_name", "Incident name", RmsFieldType.ShortText, true, true, true), + F("incident_number", "Incident number", RmsFieldType.ShortText, false, true, true) + }; + if (withPeriod) + { + fields.Add(F("operational_period", "Operational period", RmsFieldType.ShortText, true, true, true)); + fields.Add(F("period_from", "Period from", RmsFieldType.DateTime, true)); + fields.Add(F("period_to", "Period to", RmsFieldType.DateTime, true)); + } + fields.Add(F("prepared_by", "Prepared by", RmsFieldType.Person, true, true, true)); + fields.Add(F("prepared_position", "Position/title", RmsFieldType.ShortText, false, true)); + fields.Add(F("prepared_on", "Date/time prepared", RmsFieldType.DateTime, true)); + return new RecordSectionSchema { Key = "header", Label = "Incident", Fields = fields }; + } + + private static RecordSectionSchema Approval(string label) + => Section("approval", label, + F("approved_by", "Approved by", RmsFieldType.Person, false, true, true), + F("approved_position", "Position/title", RmsFieldType.ShortText), + F("approved_on", "Approved on", RmsFieldType.DateTime), + F("approval_signature", "Signature", RmsFieldType.Signature, true)); + + private const string SubjectsPeriod = "call,incidentcommand,incidentoperationalperiod"; + private const string SubjectsResource = "call,incidentcommand,incidentresource,incidentresourcerequest,unit"; + private const string SubjectsParticipant = "call,incidentcommand,incidentparticipant,person,unit"; + private const string SubjectsBusiness = "call,incidentcommand,incidentparticipant,person,unit,vendor"; + private const string SubjectsFacility = "call,incidentcommand,incidentfacility,vendor"; + private const string SubjectsCommand = "call,incidentcommand,vendor"; + + private static RecordTemplatePack IncidentSupportPack() + { + var pack = new RecordTemplatePack + { + Key = IncidentSupportPackKey, + Name = "Incident Support (ICS Logistics, Finance and Administration)", + Category = RecordDefinitionCategories.IncidentSupport, + Description = "ICS planning, resource, communications, safety, incident-business, support-operations and business-administration records for a large incident. Web-authored with incident-scoped numbering; the owning ledger stays authoritative for time, equipment, expense and cost arithmetic.", + IsPreview = true, + ArtifactStatus = RmsArtifactStatus.Compatible, + SupportedProfiles = new List { "generic", "us-nwcg", "us-nims", "us-calif", "ca" }, + SupportedLocales = new List { "en-US", "en-CA", "fr-CA" }, + ReleaseNotes = "Initial release (Back Office plan E5). Preview: compatible with the published ICS/NWCG artifact set, not an exact named form, and no claim of agency acceptance.", + Sources = new List + { + Source("NIMS/ICS forms", "FEMA National Incident Management System ICS forms", "FEMA", "2023", "https://www.fema.gov/emergency-managers/nims/components", "named-form"), + Source("NWCG PMS 902", "Interagency Incident Business Management Handbook", "NWCG", "2025", "https://www.nwcg.gov/publications/902", "named-form"), + Source("NWCG PMS 310-1", "NWCG Standards for Wildland Fire Position Qualifications", "NWCG", "2025", "https://www.nwcg.gov/publications/pms310-1"), + Source("FIRESCOPE FOG", "FIRESCOPE Field Operations Guide ICS 420-1", "FIRESCOPE", "2024", "https://firescope.caloes.ca.gov/ics-documents"), + Source("ICS Canada forms", "ICS Canada incident forms", "ICS Canada", "2024", "https://www.icscanada.ca/") + } + }; + + foreach (var definition in IapCore().Concat(StatusReporting()).Concat(Communications()).Concat(MedicalAndSafety()) + .Concat(ResourceForms()).Concat(ActivityAndPlanning()).Concat(IncidentBusinessForms()).Concat(SupportOperations()).Concat(BusinessAdministration())) + pack.Definitions.Add(definition); + + return pack; + } + + // ---- IAP core ------------------------------------------------------------------------------------------- + + private static IEnumerable IapCore() + { + yield return Ics("ics-202-objectives", "ICS 202 Incident Objectives", RecordDefinitionCategories.IncidentSupport, + "Objectives, command emphasis and the plan attachments for one operational period.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ICS202", SubjectsPeriod, + Header(), + Rows("objectives", "Objectives", 1, 50, + Count("priority", "Priority"), F("objective", "Objective", RmsFieldType.LongText, true), + Select("status", "Status", false, true, "Not started", "In progress", "Met", "Carried forward")), + Section("emphasis", "Command emphasis", + F("command_emphasis", "Command emphasis", RmsFieldType.LongText), F("situational_awareness", "General situational awareness", RmsFieldType.LongText), + F("site_safety_plan_required", "Site safety plan required", RmsFieldType.Boolean, false, false, true), F("site_safety_plan_location", "Site safety plan location", RmsFieldType.ShortText)), + Section("attachments", "Plan attachments", + Multi("included_documents", "Documents included", "ICS 203", "ICS 204", "ICS 205", "ICS 205A", "ICS 206", "ICS 207", "ICS 208", "ICS 215A", "ICS 220", "Incident map", "Weather forecast", "Traffic plan"), + F("attachment", "Attachment", RmsFieldType.Attachment)), + Approval("Approved by Incident Commander")); + + yield return Ics("ics-203-organization", "ICS 203 Organization Assignment List", RecordDefinitionCategories.IncidentSupport, + "Command and general staff assignments by position for one operational period.", + RmsLifecyclePreset.ReviewRequired, "ICS203", SubjectsPeriod, + Header(), + Rows("assignments", "Position assignments", 1, 300, + Select("section", "Section", true, true, "Incident command", "Command staff", "Operations", "Planning", "Logistics", "Finance and administration", "Agency representative"), + F("position", "Position", RmsFieldType.ShortText, true, true, true), F("member", "Name", RmsFieldType.Person, false, true), + F("name_text", "Name (not a member)", RmsFieldType.ShortText), F("agency", "Home agency or unit", RmsFieldType.ShortText, false, true), + F("contact", "Contact", RmsFieldType.ShortText)), + Section("notes", "Notes", F("notes", "Notes", RmsFieldType.LongText))); + + yield return Ics("ics-204-assignment", "ICS 204 Assignment List", RecordDefinitionCategories.IncidentSupport, + "Division or group assignment, resources assigned, work assignment, special instructions and communications for one operational period.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ICS204", SubjectsPeriod, + Header(), + Section("branch", "Branch and division", + F("branch", "Branch", RmsFieldType.ShortText, false, true, true), F("division_group", "Division or group", RmsFieldType.ShortText, true, true, true), + F("staging_area", "Staging area", RmsFieldType.ShortText), F("operations_chief", "Operations section chief", RmsFieldType.Person, false, true), + F("branch_director", "Branch director", RmsFieldType.ShortText), F("division_supervisor", "Division or group supervisor", RmsFieldType.ShortText)), + Rows("resources", "Resources assigned", null, 200, + F("resource_identifier", "Resource identifier", RmsFieldType.ShortText, true, true), F("unit", "Unit", RmsFieldType.Unit, false, true), + F("leader", "Leader", RmsFieldType.ShortText), Count("persons", "Persons"), F("contact", "Contact", RmsFieldType.ShortText), + F("reporting_location", "Reporting location", RmsFieldType.ShortText), F("reporting_time", "Reporting time", RmsFieldType.DateTime), + Multi("ppe_required", "PPE required", "Structural", "Wildland", "Chemical protective", "Respiratory", "High visibility", "Water rescue", "Fall protection")), + Section("work", "Work assignment", + F("work_assignment", "Work assignment", RmsFieldType.LongText, true), F("special_instructions", "Special instructions", RmsFieldType.LongText)), + Section("communications", "Communications", + F("command_frequency", "Command", RmsFieldType.ShortText), F("tactical_frequency", "Tactical", RmsFieldType.ShortText), + F("support_frequency", "Support", RmsFieldType.ShortText), F("emergency_communications", "Emergency communications", RmsFieldType.LongText)), + Approval("Approved by Planning Section Chief")); + + yield return Ics("ics-207-org-chart", "ICS 207 Incident Organization Chart", RecordDefinitionCategories.IncidentSupport, + "The wall-chart view of the command organization for one operational period.", + RmsLifecyclePreset.QuickEntry, "ICS207", SubjectsPeriod, + Header(), + Rows("nodes", "Organization", 1, 300, + F("position", "Position", RmsFieldType.ShortText, true, true), F("reports_to", "Reports to (position)", RmsFieldType.ShortText), + F("name", "Name", RmsFieldType.ShortText), Count("chart_level", "Chart level")), + Section("chart", "Chart", F("chart_image", "Chart image", RmsFieldType.Attachment), F("notes", "Notes", RmsFieldType.LongText))); + + yield return Ics("iap-package", "Incident Action Plan package", RecordDefinitionCategories.IncidentSupport, + "The assembly manifest for one operational period: which records and revisions are in the plan, in what order, and who received it. The pages stay their own Records; this is the cover, the order and the distribution.", + RmsLifecyclePreset.ApprovalAcknowledgement, "IAP", SubjectsPeriod, + Header(), + Rows("contents", "Plan contents", 1, 100, + Count("page_order", "Order"), F("document", "Document", RmsFieldType.ShortText, true, true), + Ext("record_reference", "Record", "record"), F("revision_reference", "Revision", RmsFieldType.ShortText), + Select("inclusion", "Inclusion", false, true, "Included", "Attached separately", "Not applicable")), + Rows("distribution", "Distribution", null, 200, + F("recipient", "Recipient", RmsFieldType.ShortText, true), F("position", "Position", RmsFieldType.ShortText), + Select("method", "Method", false, false, "Printed", "Electronic", "Briefing", "Posted"), + F("distributed_on", "Distributed on", RmsFieldType.DateTime), Count("copies", "Copies")), + Section("assembly", "Assembly", + F("assembled_on", "Assembled on", RmsFieldType.DateTime, true), F("assembly_note", "Assembly note", RmsFieldType.LongText), + F("packet", "Compiled packet", RmsFieldType.Attachment)), + Approval("Approved by Incident Commander")); + } + + // ---- Status reporting ----------------------------------------------------------------------------------- + + private static IEnumerable StatusReporting() + { + var summary = Ics("ics-209-status-summary", "ICS 209 Incident Status Summary", RecordDefinitionCategories.IncidentSupport, + "The reporting-period status summary: size, containment, threats, damage, resources committed, cost to date and the outlook.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ICS209", SubjectsPeriod, + Header(), + Section("report", "Report", + Select("report_type", "Report type", true, true, "Initial", "Update", "Final"), F("report_number", "Report number", RmsFieldType.ShortText), + F("reporting_period_from", "Reporting period from", RmsFieldType.DateTime, true), F("reporting_period_to", "Reporting period to", RmsFieldType.DateTime, true), + Select("incident_kind", "Incident kind", true, true, "Wildfire", "Structure fire", "Flood", "Severe weather", "Earthquake", "HAZMAT", "Search and rescue", "Public health", "Planned event", "Other"), + F("incident_start", "Incident start", RmsFieldType.DateTime), F("location", "Location", RmsFieldType.Address, false, true), + F("jurisdiction", "Jurisdiction", RmsFieldType.ShortText, false, true), F("subdivision", "State or province", RmsFieldType.CountrySubdivision)), + Section("size", "Size and status", + Quantity("area", "Area affected", "area", "ha"), Count("percent_contained", "Percent contained"), + Select("containment_trend", "Trend", false, true, "Increasing", "Holding", "Decreasing", "Contained", "Controlled"), + F("estimated_containment", "Estimated containment", RmsFieldType.DateTime), F("current_situation", "Current situation", RmsFieldType.LongText, true)), + Section("threats", "Threats and damage", + F("significant_events", "Significant events", RmsFieldType.LongText), F("primary_threats", "Primary threats over the next period", RmsFieldType.LongText), + Count("structures_threatened", "Structures threatened"), Count("structures_damaged", "Structures damaged"), Count("structures_destroyed", "Structures destroyed"), + Count("evacuations", "People evacuated"), Count("injuries", "Injuries this period"), Count("fatalities", "Fatalities this period"), + F("critical_infrastructure", "Critical infrastructure affected", RmsFieldType.LongText)), + Rows("committed_resources", "Resources committed", null, 300, + F("resource_kind", "Kind", RmsFieldType.ShortText, true, true), F("resource_type", "Type", RmsFieldType.ShortText), + Count("quantity", "Quantity"), Count("personnel", "Personnel"), F("agency", "Agency", RmsFieldType.ShortText, false, true)), + Section("cost", "Cost and outlook", + Money("cost_to_date", "Cost to date"), Ext("finance_reference", "Finance posting", RmsExternalReferenceSchemes.FinancePosting), + F("planned_actions", "Planned actions for the next period", RmsFieldType.LongText), F("resources_needed", "Resources needed", RmsFieldType.LongText), + F("remarks", "Remarks", RmsFieldType.LongText)), + Approval("Approved by Incident Commander")); + + // Cost to date is the ledger's number, not the record's: the record carries the reported figure and the + // posting reference it came from, and the reference is what a rendered total is computed from later. + yield return Policy(summary, "regulatory", RmsFieldClassification.Restricted, + "Reported incident cost is an unreleased financial figure until the agency publishes it.", "cost_to_date", "finance_reference"); + } + + // ---- Communications ------------------------------------------------------------------------------------- + + private static IEnumerable Communications() + { + yield return Ics("ics-205-comms-plan", "ICS 205 Incident Radio Communications Plan", RecordDefinitionCategories.IncidentSupport, + "Radio channel assignments for one operational period: function, channel, frequency, mode and assignment.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ICS205", SubjectsPeriod, + Header(), + Rows("channels", "Channel assignments", 1, 200, + Count("zone_group", "Zone or group"), F("channel_number", "Channel number", RmsFieldType.ShortText), + Select("function", "Function", true, true, "Command", "Tactical", "Ground to air", "Air to air", "Support", "Logistics", "Medical", "Repeat", "Other"), + F("channel_name", "Channel name or talkgroup", RmsFieldType.ShortText, true, true), F("assignment", "Assignment", RmsFieldType.ShortText), + F("rx_frequency", "RX frequency", RmsFieldType.ShortText), F("rx_tone", "RX tone or NAC", RmsFieldType.ShortText), + F("tx_frequency", "TX frequency", RmsFieldType.ShortText), F("tx_tone", "TX tone or NAC", RmsFieldType.ShortText), + Select("mode", "Mode", false, false, "Analog", "Digital", "Mixed"), F("remarks", "Remarks", RmsFieldType.LongText)), + Section("special", "Special instructions", + F("special_instructions", "Special instructions", RmsFieldType.LongText), F("comms_unit_leader", "Communications unit leader", RmsFieldType.ShortText)), + Approval("Approved by Communications Unit Leader")); + + yield return Ics("ics-205a-comms-list", "ICS 205A Communications List", RecordDefinitionCategories.IncidentSupport, + "Who is reachable how: assignment, name and the method that actually reaches them this period.", + RmsLifecyclePreset.QuickEntry, "I205A", SubjectsPeriod, + Header(), + Rows("contacts", "Contacts", 1, 300, + F("assignment", "Assignment", RmsFieldType.ShortText, true, true), F("name", "Name", RmsFieldType.ShortText, true), + F("method", "Method of contact", RmsFieldType.ShortText, true), F("notes", "Notes", RmsFieldType.LongText))); + + yield return Ics("ics-217a-frequency-inventory", "ICS 217A Communications Resource Availability Worksheet", RecordDefinitionCategories.IncidentSupport, + "The frequencies and talkgroups actually available to the incident, and who owns each one.", + RmsLifecyclePreset.ReviewRequired, "I217A", SubjectsPeriod, + Header(false), + Rows("frequencies", "Available frequencies", 1, 300, + F("channel_name", "Channel name or talkgroup", RmsFieldType.ShortText, true, true), F("owner", "Owner or authority", RmsFieldType.ShortText, false, true), + F("rx_frequency", "RX frequency", RmsFieldType.ShortText), F("tx_frequency", "TX frequency", RmsFieldType.ShortText), + Select("mode", "Mode", false, false, "Analog", "Digital", "Mixed"), + Select("availability", "Availability", true, true, "Available", "Restricted", "On request", "Not available"), + F("conditions", "Conditions of use", RmsFieldType.LongText))); + } + + // ---- Medical and safety --------------------------------------------------------------------------------- + + private static IEnumerable MedicalAndSafety() + { + var medical = Ics("ics-206-medical-plan", "ICS 206 Medical Plan", RecordDefinitionCategories.IncidentSupport, + "Incident medical aid stations, transport, hospitals and the emergency procedure for one operational period.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ICS206", SubjectsPeriod, + Header(), + Rows("aid_stations", "Medical aid stations", null, 50, + F("station_name", "Name", RmsFieldType.ShortText, true, true), F("station_location", "Location", RmsFieldType.Address), + F("station_contact", "Contact", RmsFieldType.ShortText), Select("paramedics", "Paramedics on site", false, false, "Yes", "No")), + Rows("transport", "Transportation", null, 50, + F("ambulance_service", "Ambulance service", RmsFieldType.ShortText, true), F("transport_location", "Location", RmsFieldType.ShortText), + F("transport_contact", "Contact", RmsFieldType.ShortText), Select("level_of_service", "Level of service", false, false, "ALS", "BLS")), + Rows("hospitals", "Hospitals", null, 50, + F("hospital_name", "Hospital", RmsFieldType.ShortText, true, true), F("hospital_address", "Address", RmsFieldType.Address), F("hospital_contact", "Contact", RmsFieldType.ShortText), + Quantity("air_travel_time", "Air travel time", "time", "min"), Quantity("ground_travel_time", "Ground travel time", "time", "min"), + Select("trauma_center", "Trauma centre", false, true, "Yes", "No"), F("trauma_level", "Level", RmsFieldType.ShortText), + Select("burn_center", "Burn centre", false, false, "Yes", "No"), Select("helipad", "Helipad", false, false, "Yes", "No")), + Section("procedure", "Emergency procedure", + F("emergency_procedure", "Emergency medical procedure", RmsFieldType.LongText, true), F("evacuation_signal", "Evacuation signal", RmsFieldType.ShortText), + F("medical_unit_leader", "Medical unit leader", RmsFieldType.ShortText)), + Approval("Approved by Safety Officer")); + + yield return Policy(medical, "treatment-casualty", RmsFieldClassification.Restricted, + "The emergency medical procedure is read alongside casualty handling and is not a page the whole department browses.", "emergency_procedure"); + + yield return Ics("ics-208-safety-message", "ICS 208 Safety Message and Plan", RecordDefinitionCategories.IncidentSupport, + "The safety message, hazards, mitigations and site safety plan reference for one operational period.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ICS208", SubjectsPeriod, + Header(), + Section("message", "Safety message", + F("safety_message", "Safety message", RmsFieldType.LongText, true), + F("site_safety_plan_required", "Site safety plan required", RmsFieldType.Boolean, false, false, true), + F("site_safety_plan_location", "Site safety plan location", RmsFieldType.ShortText)), + Rows("hazards", "Hazards and mitigations", null, 100, + F("hazard", "Hazard", RmsFieldType.ShortText, true, true), Select("severity", "Severity", true, true, "Low", "Medium", "High", "Extreme"), + F("mitigation", "Mitigation", RmsFieldType.LongText, true), F("owner", "Owner", RmsFieldType.ShortText)), + Section("weather", "Weather and environment", + F("weather_summary", "Weather summary", RmsFieldType.LongText), Quantity("temperature", "Temperature", "temperature", "C"), + F("wind", "Wind", RmsFieldType.ShortText), F("humidity", "Relative humidity", RmsFieldType.ShortText), + F("watch_outs", "Watch-outs in effect", RmsFieldType.LongText)), + Approval("Approved by Safety Officer")); + + yield return Ics("ics-215a-hazard-analysis", "ICS 215A Incident Safety Analysis", RecordDefinitionCategories.IncidentSupport, + "Hazard and risk analysis by division or group, with the mitigations the operations plan depends on.", + RmsLifecyclePreset.ReviewRequired, "I215A", SubjectsPeriod, + Header(), + Rows("analysis", "Analysis by division or group", 1, 200, + F("division_group", "Division or group", RmsFieldType.ShortText, true, true), + Multi("hazards", "Hazards", "Aviation", "Burnover or entrapment", "Communications failure", "Confined space", "Contamination", "Driving", "Dropped objects", "Electrical", "Environmental", "Fatigue", "Falling material", "Heat or cold", "Hazardous materials", "Heavy equipment", "Slips trips and falls", "Structural collapse", "Traffic", "Violence", "Water", "Wildlife"), + F("mitigations", "Mitigations", RmsFieldType.LongText, true), Select("residual_risk", "Residual risk", true, true, "Low", "Medium", "High", "Extreme")), + Section("summary", "Summary", + F("safety_officer", "Safety officer", RmsFieldType.Person, false, true), F("overall_notes", "Overall notes", RmsFieldType.LongText))); + } + + // ---- Resources ------------------------------------------------------------------------------------------ + + private static IEnumerable ResourceForms() + { + yield return Ics("ics-210-status-change", "ICS 210 Resource Status Change", RecordDefinitionCategories.IncidentSupport, + "One resource changed state: from what, to what, when, and who was told.", + RmsLifecyclePreset.QuickEntry, "ICS210", SubjectsResource, + Header(false), + Section("change", "Status change", + F("resource_identifier", "Resource identifier", RmsFieldType.ShortText, true, true, true), F("unit", "Unit", RmsFieldType.Unit, false, true), + Select("from_status", "From status", true, true, "Assigned", "Available", "Out of service", "En route", "Staged", "Released"), + Select("to_status", "To status", true, true, "Assigned", "Available", "Out of service", "En route", "Staged", "Released"), + F("changed_on", "Changed on", RmsFieldType.DateTime, true), F("location", "Location", RmsFieldType.ShortText), + F("reason", "Reason", RmsFieldType.LongText), F("reported_by", "Reported by", RmsFieldType.Person, false, true), + F("notified", "Notified", RmsFieldType.ShortText))); + + var checkIn = Ics("ics-211-check-in", "ICS 211 Incident Check-In List", RecordDefinitionCategories.IncidentSupport, + "Who and what checked in: agency, position, order and request reference, arrival, and the support state each resource is in.", + RmsLifecyclePreset.ReviewRequired, "ICS211", SubjectsParticipant, + Header(false), + Section("station", "Check-in location", + Select("check_in_location", "Check-in location", true, true, "Incident command post", "Base", "Camp", "Staging area", "Helibase", "Restat", "Other"), + F("location_detail", "Location detail", RmsFieldType.ShortText), F("recorder", "Recorder", RmsFieldType.Person, false, true)), + Rows("check_ins", "Check-ins", 1, 500, + F("resource_identifier", "Resource identifier", RmsFieldType.ShortText, true, true), F("member", "Member", RmsFieldType.Person, false, true), + F("name_text", "Name (not a member)", RmsFieldType.ShortText), F("agency", "Home agency or unit", RmsFieldType.ShortText, false, true), + F("position", "ICS position", RmsFieldType.ShortText, false, true), F("unit", "Unit", RmsFieldType.Unit), + Ext("order_number", "Resource order number", RmsExternalReferenceSchemes.Iroc), Ext("request_number", "Request number", "request"), + F("arrived_on", "Arrival", RmsFieldType.DateTime, true), F("departure_point", "Departure point", RmsFieldType.ShortText), + F("method_of_travel", "Method of travel", RmsFieldType.ShortText), F("crew_leader_phone", "Contact", RmsFieldType.ShortText), + Count("persons", "Persons"), Select("support_state", "Support state", false, true, "Checked in", "Assigned", "Released", "Demobilized"), + F("home_unit_return", "Actual return to home unit", RmsFieldType.DateTime)), + Section("notes", "Notes", F("notes", "Notes", RmsFieldType.LongText))); + + yield return Policy(checkIn, "manifest-travel", RmsFieldClassification.Restricted, + "Personal travel and contact detail on a check-in manifest is not department-wide reading.", "crew_leader_phone", "departure_point", "method_of_travel"); + + yield return Ics("ics-213-general-message", "ICS 213 General Message", RecordDefinitionCategories.IncidentSupport, + "A message sent through the incident: to, from, subject, message and the reply it drew.", + RmsLifecyclePreset.QuickEntry, "ICS213", SubjectsCommand, + Header(false), + Section("message", "Message", + F("to", "To", RmsFieldType.ShortText, true, true), F("to_position", "Position", RmsFieldType.ShortText), + F("from", "From", RmsFieldType.ShortText, true, true), F("from_position", "Position", RmsFieldType.ShortText), + F("subject", "Subject", RmsFieldType.ShortText, true, true, true), F("sent_on", "Date and time", RmsFieldType.DateTime, true), + F("message", "Message", RmsFieldType.LongText, true)), + Section("reply", "Reply", + F("reply", "Reply", RmsFieldType.LongText), F("replied_by", "Replied by", RmsFieldType.Person, false, true), + F("replied_position", "Position", RmsFieldType.ShortText), F("replied_on", "Replied on", RmsFieldType.DateTime))); + + yield return Ics("ics-213rr-resource-request", "ICS 213RR Resource Request", RecordDefinitionCategories.IncidentSupport, + "The resource request form. The request header and its fills are the ordering system's rows; this record is the request document, and it references them rather than restating them.", + RmsLifecyclePreset.ApprovalAcknowledgement, "I213RR", SubjectsResource, + Header(false), + Section("request", "Request", + Select("priority", "Priority", true, true, "Now", "Next operational period", "Routine"), + F("requested_on", "Date and time", RmsFieldType.DateTime, true), F("requested_by", "Requested by", RmsFieldType.Person, true, true), + F("requested_position", "Position", RmsFieldType.ShortText), F("section", "Section", RmsFieldType.ShortText, false, true), + Ext("request_number", "Request number", "request"), Ext("order_number", "Order number", RmsExternalReferenceSchemes.Iroc), + Ext("webeoc_reference", "WebEOC mission", RmsExternalReferenceSchemes.WebEoc), Ext("emac_reference", "EMAC mission", RmsExternalReferenceSchemes.Emac)), + Rows("items", "Items requested", 1, 100, + Count("quantity", "Quantity"), F("kind_and_type", "Kind and type", RmsFieldType.ShortText, true, true), + F("detailed_description", "Detailed item description", RmsFieldType.LongText, true), F("needed_on", "Needed by", RmsFieldType.DateTime), + F("deliver_to", "Deliver to", RmsFieldType.ShortText), F("suitable_substitutes", "Suitable substitutes", RmsFieldType.LongText)), + Section("justification", "Justification and supply", + F("justification", "Justification", RmsFieldType.LongText, true), F("requested_source", "Requested source of supply", RmsFieldType.ShortText), + Money("estimated_cost", "Estimated cost"), F("logistics_notes", "Logistics notes", RmsFieldType.LongText), + Select("fill_status", "Fill status", false, true, "Submitted", "Filled", "Partially filled", "Unable to fill", "Cancelled")), + Approval("Approved by Section Chief")); + + yield return Ics("ics-218-support-vehicle-inventory", "ICS 218 Support Vehicle and Equipment Inventory", RecordDefinitionCategories.IncidentSupport, + "Support vehicles and equipment on the incident: kind, type, identifier, operator and where it is.", + RmsLifecyclePreset.ReviewRequired, "ICS218", SubjectsResource, + Header(false), + Rows("vehicles", "Support vehicles and equipment", 1, 500, + Select("category", "Category", true, true, "Vehicle", "Heavy equipment", "Support trailer", "Generator", "Pump", "Other"), + F("kind_and_type", "Kind and type", RmsFieldType.ShortText, true, true), F("identifier", "Incident identifier", RmsFieldType.ShortText, true, true), + F("unit", "Unit", RmsFieldType.Unit), F("make_model", "Make and model", RmsFieldType.ShortText), + F("agency", "Owning agency or vendor", RmsFieldType.ShortText, false, true), F("operator", "Operator", RmsFieldType.ShortText), + F("contact", "Contact", RmsFieldType.ShortText), F("location", "Location", RmsFieldType.ShortText), + Ext("rental_agreement", "Rental agreement", "agreement"), F("released_on", "Released on", RmsFieldType.DateTime))); + + yield return Ics("ics-219-tcard", "ICS 219 Resource Status Card (T-Card)", RecordDefinitionCategories.IncidentSupport, + "One resource tracked through the incident: what it is, who is on it, and every status it moved through.", + RmsLifecyclePreset.QuickEntry, "ICS219", SubjectsResource, + Header(false), + Section("resource", "Resource", + Select("card_type", "Card type", true, true, "Header", "Crew or team", "Engine", "Helicopter", "Personnel", "Equipment", "Aircraft", "Dozer", "Miscellaneous"), + F("resource_identifier", "Resource identifier", RmsFieldType.ShortText, true, true, true), F("kind_and_type", "Kind and type", RmsFieldType.ShortText, false, true), + F("unit", "Unit", RmsFieldType.Unit, false, true), F("agency", "Home agency or unit", RmsFieldType.ShortText, false, true), + F("leader", "Leader", RmsFieldType.ShortText), Count("persons", "Persons"), + Ext("order_number", "Order number", RmsExternalReferenceSchemes.Iroc), Ext("request_number", "Request number", "request"), + F("checked_in_on", "Checked in", RmsFieldType.DateTime), F("released_on", "Released", RmsFieldType.DateTime)), + Rows("status_history", "Status history", null, 500, + F("changed_on", "Date and time", RmsFieldType.DateTime, true), + Select("status", "Status", true, true, "Checked in", "Available", "Assigned", "Out of service", "En route", "Staged", "Released", "Demobilized"), + F("assignment", "Assignment", RmsFieldType.ShortText), F("location", "Location", RmsFieldType.ShortText), F("notes", "Notes", RmsFieldType.LongText))); + + var demob = Ics("ics-221-demobilization", "ICS 221 Demobilization Check-Out", RecordDefinitionCategories.IncidentSupport, + "A resource leaving the incident: every unit that must sign it out, travel arrangements and the actual return to home unit.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ICS221", SubjectsResource, + Header(false), + Section("resource", "Resource", + F("resource_identifier", "Resource identifier", RmsFieldType.ShortText, true, true, true), F("unit", "Unit", RmsFieldType.Unit, false, true), + F("agency", "Home agency or unit", RmsFieldType.ShortText, false, true), Count("persons", "Persons"), + Ext("order_number", "Order number", RmsExternalReferenceSchemes.Iroc), Ext("request_number", "Request number", "request"), + F("demob_planned_on", "Planned release", RmsFieldType.DateTime), F("released_on", "Actual release", RmsFieldType.DateTime, true)), + Rows("checkout_units", "Check-out", 1, 30, + Select("unit_name", "Unit", true, true, "Logistics — supply", "Logistics — communications", "Logistics — facilities", "Logistics — ground support", "Planning — documentation", "Planning — demobilization", "Finance — time", "Finance — equipment time", "Safety", "Security", "Other"), + Select("outcome", "Outcome", true, true, "Cleared", "Not required", "Outstanding"), F("cleared_by", "Cleared by", RmsFieldType.ShortText), + F("cleared_on", "Cleared on", RmsFieldType.DateTime), F("remarks", "Remarks", RmsFieldType.LongText)), + Section("travel", "Travel and return", + F("destination", "Destination", RmsFieldType.ShortText), F("method_of_travel", "Method of travel", RmsFieldType.ShortText), + F("estimated_time_of_departure", "Estimated departure", RmsFieldType.DateTime), F("estimated_time_of_arrival", "Estimated arrival", RmsFieldType.DateTime), + Quantity("travel_distance", "Travel distance", "length", "km"), F("overnight_stops", "Overnight stops", RmsFieldType.LongText), + F("actual_home_unit_return", "Actual return to home unit", RmsFieldType.DateTime), + F("rest_overrides", "Rest or work-driving exception", RmsFieldType.LongText)), + Approval("Approved by Demobilization Unit Leader")); + + yield return Policy(demob, "manifest-travel", RmsFieldClassification.Restricted, + "Travel routing and overnight stops for a named crew are personal movement detail.", "method_of_travel", "overnight_stops", "destination"); + } + + // ---- Activity and planning ------------------------------------------------------------------------------ + + private static IEnumerable ActivityAndPlanning() + { + yield return Ics("ics-214-activity-log", "ICS 214 Activity Log", RecordDefinitionCategories.IncidentSupport, + "The unit or individual activity log for one operational period: who was assigned, and what happened when.", + RmsLifecyclePreset.ReviewRequired, "ICS214", SubjectsParticipant, + Header(), + Section("unit", "Unit or individual", + F("unit_name", "Unit name", RmsFieldType.ShortText, true, true, true), F("unit", "Unit", RmsFieldType.Unit, false, true), + F("leader", "Unit leader", RmsFieldType.Person, false, true), F("leader_position", "Position", RmsFieldType.ShortText)), + Rows("assigned", "Resources assigned", null, 200, + F("name", "Name", RmsFieldType.ShortText, true), F("member", "Member", RmsFieldType.Person), F("position", "ICS position", RmsFieldType.ShortText), + F("home_agency", "Home agency or unit", RmsFieldType.ShortText)), + Rows("activities", "Activity log", 1, 500, + F("occurred_on", "Date and time", RmsFieldType.DateTime, true), F("activity", "Notable activity", RmsFieldType.LongText, true), + Select("kind", "Kind", false, true, "Assignment", "Briefing", "Communication", "Safety", "Significant event", "Resource change", "Administrative", "Other")), + Section("close", "Close", F("summary", "Summary", RmsFieldType.LongText), F("signature", "Preparer signature", RmsFieldType.Signature))); + + yield return Ics("ics-215-planning-worksheet", "ICS 215 Operational Planning Worksheet", RecordDefinitionCategories.IncidentSupport, + "The worksheet the tactics meeting produces: work assignment by division or group, resources required against resources available, and the reporting location for each.", + RmsLifecyclePreset.ReviewRequired, "ICS215", SubjectsPeriod, + Header(), + Rows("assignments", "Planned assignments", 1, 200, + F("division_group", "Division or group", RmsFieldType.ShortText, true, true), F("work_assignment", "Work assignment", RmsFieldType.LongText, true), + F("resource_kind", "Resource kind and type", RmsFieldType.ShortText, true, true), + Count("required", "Required"), Count("have", "Have"), Count("need", "Need"), + F("reporting_location", "Reporting location", RmsFieldType.ShortText), F("reporting_time", "Reporting time", RmsFieldType.DateTime), + F("special_equipment", "Special equipment and supplies", RmsFieldType.LongText)), + Section("summary", "Summary", + F("total_resources_note", "Resource summary", RmsFieldType.LongText), F("operations_chief", "Operations section chief", RmsFieldType.Person, false, true), + F("planning_chief", "Planning section chief", RmsFieldType.Person, false, true))); + + yield return Ics("ics-220-air-operations", "ICS 220 Air Operations Summary", RecordDefinitionCategories.IncidentSupport, + "Aircraft assigned, air traffic control, frequencies and the air operations plan for one operational period.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ICS220", SubjectsPeriod, + Header(), + Section("control", "Air traffic control", + F("air_operations_director", "Air operations branch director", RmsFieldType.ShortText), F("air_tactical_supervisor", "Air tactical group supervisor", RmsFieldType.ShortText), + F("air_support_supervisor", "Air support group supervisor", RmsFieldType.ShortText), F("helibase", "Helibase", RmsFieldType.ShortText), + F("helispots", "Helispots", RmsFieldType.LongText), F("air_to_air_frequency", "Air to air frequency", RmsFieldType.ShortText), + F("air_to_ground_frequency", "Air to ground frequency", RmsFieldType.ShortText), F("command_frequency", "Command frequency", RmsFieldType.ShortText), + F("deck_coordinator_frequency", "Deck coordinator frequency", RmsFieldType.ShortText), F("temporary_flight_restriction", "Temporary flight restriction", RmsFieldType.LongText)), + Rows("aircraft", "Aircraft assigned", null, 100, + F("aircraft_identifier", "Aircraft identifier", RmsFieldType.ShortText, true, true), + Select("aircraft_category", "Category", true, true, "Rotor wing", "Fixed wing", "Air tanker", "Lead plane", "Air attack", "Unmanned"), + F("make_model", "Make and model", RmsFieldType.ShortText), F("base", "Base", RmsFieldType.ShortText), + F("available_from", "Available from", RmsFieldType.DateTime), F("available_to", "Available to", RmsFieldType.DateTime), + F("assignment", "Assignment", RmsFieldType.LongText), F("vendor", "Vendor or agency", RmsFieldType.ShortText)), + Section("plan", "Air operations plan", + F("remarks", "Remarks", RmsFieldType.LongText), F("hazards", "Known aviation hazards", RmsFieldType.LongText)), + Approval("Approved by Air Operations Branch Director")); + + var rating = Ics("ics-225-performance-rating", "ICS 225 Incident Personnel Performance Rating", RecordDefinitionCategories.IncidentSupport, + "An individual performance rating for the incident assignment: rating factors, narrative, and the discussion with the individual.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ICS225", SubjectsParticipant, + Header(false), + Section("individual", "Individual rated", + F("member", "Member", RmsFieldType.Person, false, true), F("name_text", "Name (not a member)", RmsFieldType.ShortText), + F("home_agency", "Home agency or unit", RmsFieldType.ShortText, false, true), F("position", "Incident position", RmsFieldType.ShortText, true, true), + F("assignment_from", "Assignment from", RmsFieldType.Date), F("assignment_to", "Assignment to", RmsFieldType.Date), + Select("incident_complexity", "Incident complexity", false, true, "Type 5", "Type 4", "Type 3", "Type 2", "Type 1")), + Rows("factors", "Rating factors", 1, 30, + F("factor", "Factor", RmsFieldType.ShortText, true), Select("rating", "Rating", true, false, "Unacceptable", "Needs improvement", "Met standard", "Exceeded standard", "Not observed"), + F("comment", "Comment", RmsFieldType.LongText)), + Section("narrative", "Narrative and discussion", + F("narrative", "Narrative", RmsFieldType.LongText, true), F("recommendation", "Recommendation", RmsFieldType.LongText), + F("discussed_with_individual", "Discussed with the individual", RmsFieldType.Boolean), F("individual_comment", "Individual comment", RmsFieldType.LongText), + F("individual_signature", "Individual signature", RmsFieldType.Signature)), + Approval("Rated by")); + + yield return Policy(rating, "regulatory", RmsFieldClassification.Restricted, + "A performance rating is a personnel record about a named individual, not incident documentation the department browses.", + "narrative", "recommendation", "individual_comment", "factor", "rating", "comment"); + + yield return Ics("ics-260-resource-order", "ICS 260 Resource Order", RecordDefinitionCategories.IncidentSupport, + "The resource order as placed and filled. The order and its fills live in the ordering system; this record carries the document, the identifiers and the artifact it was built from.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ICS260", SubjectsResource, + Header(false), + Section("order", "Order", + Ext("order_number", "Order number", RmsExternalReferenceSchemes.Iroc, true), F("ordering_point", "Ordering point", RmsFieldType.ShortText, false, true), + F("ordered_on", "Ordered on", RmsFieldType.DateTime, true), F("ordered_by", "Ordered by", RmsFieldType.Person, false, true), + Select("order_kind", "Order kind", true, true, "Overhead", "Crew", "Equipment", "Aircraft", "Supply"), + F("deliver_to", "Deliver to", RmsFieldType.ShortText), F("needed_on", "Needed by", RmsFieldType.DateTime)), + Rows("lines", "Order lines", 1, 200, + Ext("request_number", "Request number", "request"), Count("quantity", "Quantity"), + F("kind_and_type", "Kind and type", RmsFieldType.ShortText, true, true), F("description", "Description", RmsFieldType.LongText), + Select("line_status", "Status", false, true, "Requested", "Filled", "Partially filled", "Unable to fill", "Cancelled"), + F("filled_with", "Filled with", RmsFieldType.ShortText), F("filled_on", "Filled on", RmsFieldType.DateTime), + F("provider", "Providing unit or agency", RmsFieldType.ShortText)), + Section("source", "Source artifact", + F("source_artifact", "Source artifact", RmsFieldType.Attachment), F("source_checksum_note", "Provenance note", RmsFieldType.LongText), + Ext("eisuite_reference", "e-ISuite reference", RmsExternalReferenceSchemes.EIsuite)), + Approval("Approved by Ordering Point")); + } + + // ---- Incident business ---------------------------------------------------------------------------------- + + private static IEnumerable IncidentBusinessForms() + { + // Every form in this group is a document over a ledger. The rows below carry what the crew wrote down + // and the reference to the authoritative row; when the Back Office ledger ships, the reference is what a + // rendered total is computed from, and nothing here is re-keyed into it. + var crewTime = Ics("sf-261-crew-time", "SF-261 Crew Time Report", RecordDefinitionCategories.IncidentBusiness, + "Daily crew time for one crew: hours by person and day, with the certifying signatures. The approved time span itself is the ledger's row; this is the certified document over it.", + RmsLifecyclePreset.ApprovalAcknowledgement, "SF261", SubjectsBusiness, + Header(false), + Section("crew", "Crew", + F("crew_name", "Crew or team name", RmsFieldType.ShortText, true, true, true), F("unit", "Unit", RmsFieldType.Unit, false, true), + F("home_agency", "Home agency or unit", RmsFieldType.ShortText, false, true), F("crew_leader", "Crew leader", RmsFieldType.Person, false, true), + F("work_date", "Work date", RmsFieldType.Date, true), Ext("order_number", "Order number", RmsExternalReferenceSchemes.Iroc), + Ext("request_number", "Request number", "request")), + Rows("entries", "Time entries", 1, 200, + F("member", "Member", RmsFieldType.Person), F("name_text", "Name (not a member)", RmsFieldType.ShortText), + F("position", "ICS position", RmsFieldType.ShortText), F("started_on", "Start", RmsFieldType.DateTime, true), + F("ended_on", "Stop", RmsFieldType.DateTime, true), Quantity("hours_worked", "Hours worked", "time", "h"), + Select("hours_kind", "Kind", false, true, "Regular", "Overtime", "Travel", "Standby", "Hazard"), + F("remarks", "Remarks", RmsFieldType.LongText), Ext("ledger_reference", "Approved time reference", "dtr")), + Section("certification", "Certification", + F("crew_certification", "Crew representative certification", RmsFieldType.Signature, true), F("certified_by", "Certified by", RmsFieldType.Person, false, true), + F("time_officer", "Incident time officer", RmsFieldType.ShortText), F("posted_on", "Posted on", RmsFieldType.DateTime), + Ext("finance_reference", "Finance posting", RmsExternalReferenceSchemes.FinancePosting)), + Approval("Approved by Finance Section")); + + yield return Policy(crewTime, "regulatory", RmsFieldClassification.Restricted, + "Individual hours and pay-relevant classifications are personnel compensation data.", "hours_worked", "hours_kind", "remarks"); + + var equipmentInvoice = Ics("of-286-equipment-use-invoice", "OF-286 Emergency Equipment Use Invoice", RecordDefinitionCategories.IncidentBusiness, + "The use invoice for hired equipment: agreement, rate basis, use by day, deductions and the total claimed. The rate and the arithmetic belong to the ledger and the agreement; this is the invoice document.", + RmsLifecyclePreset.ApprovalAcknowledgement, "OF286", SubjectsBusiness, + Header(false), + Section("agreement", "Agreement and equipment", + F("contractor", "Contractor or vendor", RmsFieldType.Contact, false, true, true), F("contractor_text", "Contractor (not a contact)", RmsFieldType.ShortText), + Ext("rental_agreement", "Rental agreement number", "agreement", true), F("equipment_identifier", "Equipment identifier", RmsFieldType.ShortText, true, true), + F("equipment_description", "Equipment description", RmsFieldType.LongText), F("operator", "Operator furnished by", RmsFieldType.ShortText), + Select("rate_basis", "Rate basis", true, true, "Daily", "Hourly", "Mileage", "Work rate", "Guarantee"), + F("period_from", "Use period from", RmsFieldType.Date, true), F("period_to", "Use period to", RmsFieldType.Date, true)), + Rows("use", "Use by day", 1, 200, + F("use_date", "Date", RmsFieldType.Date, true), Quantity("hours_used", "Hours used", "time", "h"), + Quantity("distance_used", "Distance", "length", "km"), F("work_location", "Work location", RmsFieldType.ShortText), + Select("special_rate", "Special rate", false, false, "None", "Standby", "Guarantee", "Double shift"), + F("remarks", "Remarks", RmsFieldType.LongText), Ext("ledger_reference", "Equipment use reference", "equipment-time")), + Rows("deductions", "Deductions", null, 50, + Select("deduction_kind", "Kind", true, true, "Fuel", "Oil and lubricant", "Repair", "Operator supplied", "Damage", "Other"), + F("description", "Description", RmsFieldType.ShortText), Money("amount", "Amount"), F("authority", "Authority", RmsFieldType.ShortText)), + Section("totals", "Claimed total", + Money("claimed_total", "Total claimed"), Ext("vendor_invoice", "Vendor invoice number", RmsExternalReferenceSchemes.VendorInvoice), + Ext("finance_reference", "Finance posting", RmsExternalReferenceSchemes.FinancePosting), F("invoice_document", "Invoice document", RmsFieldType.Attachment), + F("totals_note", "The rate schedule and the arithmetic are the agreement's and the ledger's; this figure is what was claimed.", RmsFieldType.LongText)), + Approval("Approved by Finance Section")); + + yield return Policy(equipmentInvoice, "regulatory", RmsFieldClassification.Restricted, + "Vendor rates, claimed amounts and deductions are commercial terms under an agreement.", "claimed_total", "amount", "vendor_invoice", "finance_reference"); + + var firefighterTime = Ics("of-288-firefighter-time", "OF-288 Emergency Firefighter Time Report", RecordDefinitionCategories.IncidentBusiness, + "Individual emergency firefighter time for the assignment: hours by day, travel and the certifying signatures.", + RmsLifecyclePreset.ApprovalAcknowledgement, "OF288", SubjectsBusiness, + Header(false), + Section("individual", "Individual", + F("member", "Member", RmsFieldType.Person, false, true), F("name_text", "Name (not a member)", RmsFieldType.ShortText), + F("home_agency", "Home agency or unit", RmsFieldType.ShortText, false, true), F("position", "Incident position", RmsFieldType.ShortText, false, true), + F("employment_kind", "Employment kind", RmsFieldType.ShortText), Ext("order_number", "Order number", RmsExternalReferenceSchemes.Iroc), + Ext("request_number", "Request number", "request"), F("assignment_from", "Assignment from", RmsFieldType.Date, true), + F("assignment_to", "Assignment to", RmsFieldType.Date)), + Rows("days", "Time by day", 1, 200, + F("work_date", "Date", RmsFieldType.Date, true), F("started_on", "Start", RmsFieldType.DateTime), F("ended_on", "Stop", RmsFieldType.DateTime), + Quantity("hours_worked", "Hours worked", "time", "h"), Quantity("travel_hours", "Travel hours", "time", "h"), + Select("hours_kind", "Kind", false, true, "Regular", "Overtime", "Travel", "Standby", "Hazard"), + F("remarks", "Remarks", RmsFieldType.LongText), Ext("ledger_reference", "Approved time reference", "dtr")), + Section("certification", "Certification", + F("employee_signature", "Employee signature", RmsFieldType.Signature), F("supervisor", "Supervisor", RmsFieldType.Person, false, true), + F("supervisor_signature", "Supervisor signature", RmsFieldType.Signature, true), F("time_officer", "Incident time officer", RmsFieldType.ShortText), + Ext("finance_reference", "Finance posting", RmsExternalReferenceSchemes.FinancePosting)), + Approval("Approved by Finance Section")); + + yield return Policy(firefighterTime, "regulatory", RmsFieldClassification.Restricted, + "Individual hours, employment kind and pay classification are personnel compensation data.", + "hours_worked", "travel_hours", "hours_kind", "employment_kind", "remarks"); + + var shiftTicket = Ics("of-294-equipment-shift-ticket", "OF-294 Emergency Equipment Shift Ticket", RecordDefinitionCategories.IncidentBusiness, + "One shift of hired equipment use, signed at the end of the shift. The shift ticket is the source document the use invoice is built from.", + RmsLifecyclePreset.ReviewRequired, "OF294", SubjectsBusiness, + Header(false), + Section("shift", "Shift", + Ext("rental_agreement", "Rental agreement number", "agreement", true), F("equipment_identifier", "Equipment identifier", RmsFieldType.ShortText, true, true), + F("operator", "Operator", RmsFieldType.ShortText), F("shift_date", "Shift date", RmsFieldType.Date, true), + F("started_on", "Start", RmsFieldType.DateTime, true), F("ended_on", "Stop", RmsFieldType.DateTime, true), + Quantity("hours_used", "Hours used", "time", "h"), Quantity("distance_used", "Distance", "length", "km"), + F("work_performed", "Work performed", RmsFieldType.LongText), F("work_location", "Work location", RmsFieldType.ShortText)), + Rows("consumables", "Fuel and consumables furnished by the government", null, 50, + Select("kind", "Kind", true, true, "Fuel", "Oil", "Lubricant", "Parts", "Other"), Quantity("quantity", "Quantity", "volume", "L"), + F("furnished_by", "Furnished by", RmsFieldType.ShortText), F("remarks", "Remarks", RmsFieldType.LongText)), + Section("certification", "Certification", + F("operator_signature", "Operator signature", RmsFieldType.Signature), F("government_representative", "Government representative", RmsFieldType.ShortText), + F("representative_signature", "Representative signature", RmsFieldType.Signature, true), + Ext("ledger_reference", "Equipment use reference", "equipment-time"))); + + yield return Policy(shiftTicket, "regulatory", RmsFieldClassification.Restricted, + "Shift hours and furnished consumables feed a commercial invoice under an agreement.", "hours_used", "distance_used", "quantity"); + + yield return Ics("of-296-equipment-inspection", "OF-296 Vehicle and Heavy Equipment Safety Inspection", RecordDefinitionCategories.IncidentBusiness, + "The pre-use safety inspection a hired vehicle or piece of heavy equipment must pass before it works on the incident.", + RmsLifecyclePreset.ApprovalAcknowledgement, "OF296", SubjectsBusiness, + Header(false), + Section("equipment", "Equipment", + F("contractor", "Contractor or vendor", RmsFieldType.Contact, false, true), F("contractor_text", "Contractor (not a contact)", RmsFieldType.ShortText), + Ext("rental_agreement", "Rental agreement number", "agreement"), F("equipment_identifier", "Equipment identifier", RmsFieldType.ShortText, true, true), + F("make_model", "Make and model", RmsFieldType.ShortText), F("serial_or_vin", "Serial or VIN", RmsFieldType.ShortText), + F("odometer", "Odometer or hour meter", RmsFieldType.ShortText), F("inspected_on", "Inspected on", RmsFieldType.DateTime, true)), + Rows("items", "Inspection items", 1, 200, + Select("system", "System", true, true, "Brakes", "Steering", "Lights", "Tyres and wheels", "Glass and mirrors", "Seat belts", "Fire extinguisher", "Exhaust", "Fuel system", "Hydraulics", "Guards and shields", "Backup alarm", "Rollover protection", "Other"), + F("item", "Item", RmsFieldType.ShortText, true), Select("result", "Result", true, true, "Pass", "Fail", "Not applicable"), + F("defect", "Defect", RmsFieldType.LongText), F("corrected_on", "Corrected on", RmsFieldType.DateTime)), + Section("outcome", "Outcome", + Select("disposition", "Disposition", true, true, "Accepted", "Accepted with corrections", "Rejected"), F("comments", "Comments", RmsFieldType.LongText), + F("photo", "Photo", RmsFieldType.Attachment), F("inspector", "Inspector", RmsFieldType.Person, false, true), + F("inspector_qualification", "Inspector qualification", RmsFieldType.ShortText)), + Approval("Approved by Ground Support Unit Leader")); + + var rentalEnvelope = Ics("of-297-rental-use-envelope", "OF-297 Emergency Equipment Rental-Use Envelope", RecordDefinitionCategories.IncidentBusiness, + "The envelope that keeps one piece of hired equipment together: agreement, shift tickets, inspections, invoice and the release.", + RmsLifecyclePreset.ReviewRequired, "OF297", SubjectsBusiness, + Header(false), + Section("equipment", "Equipment and agreement", + F("contractor", "Contractor or vendor", RmsFieldType.Contact, false, true), F("contractor_text", "Contractor (not a contact)", RmsFieldType.ShortText), + Ext("rental_agreement", "Rental agreement number", "agreement", true), F("equipment_identifier", "Equipment identifier", RmsFieldType.ShortText, true, true), + F("hired_on", "Hired on", RmsFieldType.Date), F("released_on", "Released on", RmsFieldType.Date)), + Rows("contents", "Envelope contents", 1, 200, + Select("document_kind", "Document", true, true, "Rental agreement", "Shift ticket", "Safety inspection", "Use invoice", "Deduction authority", "Damage report", "Release", "Other"), + F("reference", "Reference", RmsFieldType.ShortText), F("document_date", "Date", RmsFieldType.Date), + Select("present", "Present", true, true, "Yes", "No", "Not applicable"), F("document", "Document", RmsFieldType.Attachment)), + Section("close", "Close-out", + Money("total_claimed", "Total claimed"), Ext("finance_reference", "Finance posting", RmsExternalReferenceSchemes.FinancePosting), + F("outstanding_items", "Outstanding items", RmsFieldType.LongText), F("closed_on", "Closed on", RmsFieldType.DateTime), + F("closed_by", "Closed by", RmsFieldType.Person, false, true))); + + yield return Policy(rentalEnvelope, "regulatory", RmsFieldClassification.Restricted, + "The claimed total and its finance posting are commercial terms under an agreement.", "total_claimed", "finance_reference"); + + var rentalAgreement = Ics("of-315-rental-agreement", "OF-315 Emergency Equipment Rental Agreement", RecordDefinitionCategories.IncidentBusiness, + "The rental agreement itself: parties, equipment, rate basis, terms and the signatures that bind it.", + RmsLifecyclePreset.ApprovalAcknowledgement, "OF315", SubjectsBusiness, + Header(false), + Section("parties", "Parties", + F("contractor", "Contractor or vendor", RmsFieldType.Contact, false, true, true), F("contractor_text", "Contractor (not a contact)", RmsFieldType.ShortText), + F("contractor_address", "Contractor address", RmsFieldType.Address), F("contractor_identifier", "Contractor identifier", RmsFieldType.ShortText), + F("government_agency", "Government agency", RmsFieldType.ShortText, true, true), F("agency_representative", "Agency representative", RmsFieldType.Person, false, true), + Ext("agreement_number", "Agreement number", "agreement", true)), + Rows("equipment", "Equipment covered", 1, 100, + F("equipment_identifier", "Equipment identifier", RmsFieldType.ShortText, true, true), F("description", "Description", RmsFieldType.LongText), + F("serial_or_vin", "Serial or VIN", RmsFieldType.ShortText), Select("rate_basis", "Rate basis", true, true, "Daily", "Hourly", "Mileage", "Work rate", "Guarantee"), + Money("rate", "Rate"), Select("operator_furnished", "Operator furnished by", false, false, "Contractor", "Government", "Not applicable"), + Select("fuel_furnished", "Fuel furnished by", false, false, "Contractor", "Government")), + Section("terms", "Terms", + F("effective_from", "Effective from", RmsFieldType.Date, true), F("effective_to", "Effective to", RmsFieldType.Date), + F("point_of_hire", "Point of hire", RmsFieldType.ShortText), F("special_terms", "Special terms", RmsFieldType.LongText), + F("insurance_note", "Insurance and liability", RmsFieldType.LongText), F("agreement_document", "Signed agreement", RmsFieldType.Attachment)), + Section("signatures", "Signatures", + F("contractor_signature", "Contractor signature", RmsFieldType.Signature, true), F("agency_signature", "Agency signature", RmsFieldType.Signature, true), + F("signed_on", "Signed on", RmsFieldType.DateTime, true)), + Approval("Approved by Procurement Unit Leader")); + + yield return Policy(rentalAgreement, "regulatory", RmsFieldClassification.Restricted, + "Vendor identifiers, rates and negotiated terms are commercial and, in the identifier case, tax-adjacent.", + "contractor_identifier", "rate", "special_terms", "insurance_note", "contractor_address"); + } + + // ---- Support operations --------------------------------------------------------------------------------- + + private static IEnumerable SupportOperations() + { + var facility = Ics("facility-inspection", "Incident facility inspection", RecordDefinitionCategories.IncidentSupport, + "The condition of an incident facility on arrival and on release: what was there, what was damaged, and what the incident is responsible for.", + RmsLifecyclePreset.ApprovalAcknowledgement, "FACINS", SubjectsFacility, + Header(false), + Section("facility", "Facility", + F("facility_name", "Facility", RmsFieldType.ShortText, true, true, true), + Select("facility_kind", "Kind", true, true, "Incident command post", "Base", "Camp", "Staging area", "Helibase", "Helispot", "Warehouse", "Office", "Other"), + F("location", "Location", RmsFieldType.Address), F("owner", "Owner or land manager", RmsFieldType.ShortText, false, true), + Ext("use_agreement", "Use agreement", "agreement"), Select("inspection_kind", "Inspection", true, true, "On arrival", "Interim", "On release"), + F("inspected_on", "Inspected on", RmsFieldType.DateTime, true)), + Rows("areas", "Areas inspected", 1, 200, + F("area", "Area", RmsFieldType.ShortText, true), Select("condition", "Condition", true, true, "Good", "Fair", "Poor", "Damaged", "Not inspected"), + F("findings", "Findings", RmsFieldType.LongText), F("photo", "Photo", RmsFieldType.Attachment), F("responsible_party", "Responsible party", RmsFieldType.ShortText)), + Section("outcome", "Outcome", + F("damage_summary", "Damage summary", RmsFieldType.LongText), Money("estimated_restoration_cost", "Estimated restoration cost"), + F("owner_present", "Owner representative present", RmsFieldType.ShortText), F("owner_signature", "Owner signature", RmsFieldType.Signature), + F("access_controls", "Access and security controls", RmsFieldType.LongText)), + Approval("Approved by Facilities Unit Leader")); + + yield return Policy(facility, "facility-security", RmsFieldClassification.Restricted, + "Access and security controls for a facility are not department-wide reading.", "access_controls"); + + yield return Ics("facility-use-agreement", "Land or facility use agreement", RecordDefinitionCategories.IncidentBusiness, + "The agreement under which the incident occupies land or a facility: parties, term, permitted use, restoration obligation and signatures.", + RmsLifecyclePreset.ApprovalAcknowledgement, "LUA", SubjectsFacility, + Header(false), + Section("parties", "Parties and property", + F("owner", "Owner or land manager", RmsFieldType.Contact, false, true, true), F("owner_text", "Owner (not a contact)", RmsFieldType.ShortText), + F("property_description", "Property description", RmsFieldType.LongText, true), F("location", "Location", RmsFieldType.Address), + Quantity("area_used", "Area used", "area", "ha"), Ext("agreement_number", "Agreement number", "agreement", true)), + Section("terms", "Terms", + F("effective_from", "Effective from", RmsFieldType.Date, true), F("effective_to", "Effective to", RmsFieldType.Date), + F("permitted_use", "Permitted use", RmsFieldType.LongText, true), Money("compensation", "Compensation"), + Select("compensation_basis", "Compensation basis", false, true, "No cost", "Daily", "Flat", "Restoration only", "Other"), + F("restoration_obligation", "Restoration obligation", RmsFieldType.LongText), F("restrictions", "Restrictions", RmsFieldType.LongText)), + Section("signatures", "Signatures", + F("owner_signature", "Owner signature", RmsFieldType.Signature, true), F("agency_signature", "Agency signature", RmsFieldType.Signature, true), + F("signed_on", "Signed on", RmsFieldType.DateTime, true), F("agreement_document", "Signed agreement", RmsFieldType.Attachment)), + Approval("Approved by Procurement Unit Leader")); + + yield return Ics("camp-sanitation-inspection", "Camp sanitation inspection", RecordDefinitionCategories.IncidentSupport, + "Sanitation at an incident camp: water, waste, washing, sleeping and vector control, with the corrective actions raised.", + RmsLifecyclePreset.ReviewRequired, "SANINS", SubjectsFacility, + Header(false), + Section("camp", "Camp", + F("facility_name", "Camp or facility", RmsFieldType.ShortText, true, true, true), F("location", "Location", RmsFieldType.Address), + Count("population", "Population served"), F("inspected_on", "Inspected on", RmsFieldType.DateTime, true), + F("inspector", "Inspector", RmsFieldType.Person, false, true), F("inspector_qualification", "Qualification", RmsFieldType.ShortText)), + Rows("items", "Inspection items", 1, 200, + Select("area", "Area", true, true, "Potable water", "Hand washing", "Showers", "Toilets", "Grey water", "Solid waste", "Sleeping area", "Food service", "Vector control", "Lighting", "Other"), + F("item", "Item", RmsFieldType.ShortText, true), Select("result", "Result", true, true, "Satisfactory", "Marginal", "Unsatisfactory", "Not applicable"), + F("finding", "Finding", RmsFieldType.LongText), F("corrective_action", "Corrective action", RmsFieldType.LongText), + F("corrected_on", "Corrected on", RmsFieldType.DateTime), F("photo", "Photo", RmsFieldType.Attachment)), + Section("outcome", "Outcome", + Select("overall", "Overall", true, true, "Satisfactory", "Marginal", "Unsatisfactory"), F("summary", "Summary", RmsFieldType.LongText), + F("reinspection_due", "Reinspection due", RmsFieldType.Date))); + + var foodService = Ics("food-service-inspection", "Food service inspection", RecordDefinitionCategories.IncidentSupport, + "Food service on the incident: the caterer, temperatures, handling, storage and the findings that must be corrected before the next meal.", + RmsLifecyclePreset.ReviewRequired, "FDINSP", SubjectsFacility, + Header(false), + Section("service", "Food service", + F("caterer", "Caterer or vendor", RmsFieldType.Contact, false, true), F("caterer_text", "Caterer (not a contact)", RmsFieldType.ShortText), + Ext("agreement_number", "Agreement number", "agreement"), F("facility_name", "Facility", RmsFieldType.ShortText, true, true), + Count("meals_served", "Meals served"), F("inspected_on", "Inspected on", RmsFieldType.DateTime, true), + F("inspector", "Inspector", RmsFieldType.Person, false, true), F("permit_reference", "Health permit reference", RmsFieldType.ShortText)), + Rows("temperatures", "Temperature checks", null, 100, + F("food_item", "Item", RmsFieldType.ShortText, true), Select("holding", "Holding", true, true, "Hot", "Cold", "Frozen", "Ambient"), + Quantity("temperature", "Temperature", "temperature", "C"), F("checked_on", "Checked on", RmsFieldType.DateTime), + Select("within_range", "Within range", true, true, "Yes", "No")), + Rows("items", "Inspection items", 1, 200, + Select("area", "Area", true, true, "Hand washing", "Food handling", "Cold storage", "Hot holding", "Dish washing", "Waste", "Personal hygiene", "Pest control", "Water supply", "Other"), + F("item", "Item", RmsFieldType.ShortText, true), Select("result", "Result", true, true, "Satisfactory", "Marginal", "Unsatisfactory", "Not applicable"), + F("finding", "Finding", RmsFieldType.LongText), F("corrective_action", "Corrective action", RmsFieldType.LongText), F("corrected_on", "Corrected on", RmsFieldType.DateTime)), + Section("outcome", "Outcome", + Select("overall", "Overall", true, true, "Satisfactory", "Marginal", "Unsatisfactory"), F("summary", "Summary", RmsFieldType.LongText), + F("embargo_issued", "Embargo or stop-service issued", RmsFieldType.Boolean, false, false, true), F("reinspection_due", "Reinspection due", RmsFieldType.Date))); + + yield return Policy(foodService, "regulatory", RmsFieldClassification.Restricted, + "A health permit reference and an unsatisfactory finding against a named vendor is regulatory information about that vendor.", + "permit_reference", "embargo_issued"); + + yield return Ics("potable-water-test", "Potable water test", RecordDefinitionCategories.IncidentSupport, + "A potable-water sample from an incident facility: where it came from, what was measured, and what was done about the result.", + RmsLifecyclePreset.ReviewRequired, "H2OTST", SubjectsFacility, + Header(false), + Section("sample", "Sample", + F("facility_name", "Facility", RmsFieldType.ShortText, true, true), F("source_description", "Source", RmsFieldType.ShortText, true), + Select("source_kind", "Source kind", true, true, "Municipal", "Well", "Tanker", "Bottled", "Surface treated", "Other"), + F("sampled_on", "Sampled on", RmsFieldType.DateTime, true), F("sampled_by", "Sampled by", RmsFieldType.Person, false, true), + F("sample_identifier", "Sample identifier", RmsFieldType.ShortText, false, true), F("laboratory", "Laboratory", RmsFieldType.ShortText)), + Rows("results", "Results", 1, 50, + Select("analyte", "Analyte", true, true, "Free chlorine residual", "Total chlorine", "pH", "Turbidity", "Total coliform", "E. coli", "Temperature", "Other"), + F("value", "Value", RmsFieldType.ShortText, true), F("unit_label", "Unit", RmsFieldType.ShortText), + Select("result", "Result", true, true, "Pass", "Fail", "Inconclusive"), F("reported_on", "Reported on", RmsFieldType.DateTime)), + Section("action", "Action", + Select("disposition", "Disposition", true, true, "Approved for use", "Approved with treatment", "Not approved", "Retest required"), + F("action_taken", "Action taken", RmsFieldType.LongText), F("boil_notice_issued", "Boil or do-not-drink notice issued", RmsFieldType.Boolean, false, false, true), + F("retest_due", "Retest due", RmsFieldType.Date), F("report_document", "Laboratory report", RmsFieldType.Attachment))); + + yield return Ics("shift-ticket", "Support shift ticket", RecordDefinitionCategories.IncidentSupport, + "A shift of support work that is not covered by an equipment agreement: who, what, where and how long.", + RmsLifecyclePreset.QuickEntry, "SHFTKT", SubjectsBusiness, + Header(false), + Section("shift", "Shift", + F("unit_name", "Unit or crew", RmsFieldType.ShortText, true, true, true), F("unit", "Unit", RmsFieldType.Unit, false, true), + F("supervisor", "Supervisor", RmsFieldType.Person, false, true), F("shift_date", "Shift date", RmsFieldType.Date, true), + F("started_on", "Start", RmsFieldType.DateTime, true), F("ended_on", "Stop", RmsFieldType.DateTime, true), + Quantity("hours_worked", "Hours worked", "time", "h"), Count("persons", "Persons"), + F("work_location", "Work location", RmsFieldType.ShortText)), + Section("work", "Work", + F("work_performed", "Work performed", RmsFieldType.LongText, true), F("materials_used", "Materials used", RmsFieldType.LongText), + F("remarks", "Remarks", RmsFieldType.LongText), Ext("ledger_reference", "Approved time reference", "dtr"), + F("supervisor_signature", "Supervisor signature", RmsFieldType.Signature))); + + yield return Ics("delivery-receiving-ticket", "Delivery and receiving ticket", RecordDefinitionCategories.IncidentSupport, + "Something arrived at the incident: what, from whom, against which request, in what condition, and who signed for it.", + RmsLifecyclePreset.QuickEntry, "RCVTKT", SubjectsFacility, + Header(false), + Section("delivery", "Delivery", + F("received_on", "Received on", RmsFieldType.DateTime, true), F("received_at", "Received at", RmsFieldType.ShortText, true, true), + F("received_by", "Received by", RmsFieldType.Person, true, true), F("carrier", "Carrier or vendor", RmsFieldType.ShortText, false, true), + F("waybill", "Waybill or tracking", RmsFieldType.ShortText, false, true), Ext("request_number", "Request number", "request"), + Ext("supply_reference", "Cache or supply request", RmsExternalReferenceSchemes.NfesIclip), + Ext("lscms_reference", "LSCMS reference", RmsExternalReferenceSchemes.Lscms)), + Rows("items", "Items received", 1, 200, + F("item", "Item", RmsFieldType.ShortText, true, true), F("item_identifier", "Item identifier", RmsFieldType.ShortText), + Count("quantity_expected", "Expected"), Count("quantity_received", "Received"), F("unit_of_issue", "Unit of issue", RmsFieldType.ShortText), + Select("condition", "Condition", true, true, "Good", "Damaged", "Short", "Over", "Wrong item", "Refused"), + F("discrepancy_note", "Discrepancy", RmsFieldType.LongText), F("inventory_item", "Inventory item", RmsFieldType.InventoryReference)), + Section("signature", "Signature", + F("driver_name", "Driver or courier", RmsFieldType.ShortText), F("receiver_signature", "Receiver signature", RmsFieldType.Signature, true), + F("photo", "Photo", RmsFieldType.Attachment), Ext("vendor_invoice", "Vendor invoice number", RmsExternalReferenceSchemes.VendorInvoice))); + + yield return Ics("corrective-action", "Corrective action", RecordDefinitionCategories.IncidentSupport, + "Something needs fixing: what was found, who owns it, when it is due, and the evidence it closed.", + RmsLifecyclePreset.ApprovalAcknowledgement, "CORACT", SubjectsCommand, + Header(false), + Section("finding", "Finding", + F("title", "Title", RmsFieldType.ShortText, true, true, true), Select("source", "Source", true, true, "Safety inspection", "Sanitation inspection", "Food service inspection", "Equipment inspection", "Accident", "Near miss", "Hotwash", "After-action review", "Audit", "Other"), + Ext("source_reference", "Source record", "record"), F("identified_on", "Identified on", RmsFieldType.DateTime, true), + F("identified_by", "Identified by", RmsFieldType.Person, false, true), F("description", "Description", RmsFieldType.LongText, true), + Select("severity", "Severity", true, true, "Low", "Medium", "High", "Critical")), + Section("action", "Action", + F("action_required", "Action required", RmsFieldType.LongText, true), F("owner", "Owner", RmsFieldType.Person, false, true), + F("owner_position", "Owner position", RmsFieldType.ShortText), F("due_on", "Due on", RmsFieldType.Date, true), + Select("status", "Status", true, true, "Open", "In progress", "Blocked", "Closed", "Cancelled"), + F("work_order", "Work order", RmsFieldType.ChecklistWorkOrderReference)), + Section("closure", "Closure", + F("closure_note", "Closure note", RmsFieldType.LongText), F("closed_on", "Closed on", RmsFieldType.DateTime), + F("closed_by", "Closed by", RmsFieldType.Person, false, true), F("evidence", "Closure evidence", RmsFieldType.Attachment), + F("verified_by", "Verified by", RmsFieldType.Person, false, true))); + + var accident = Ics("incident-accident-report", "Incident accident report", RecordDefinitionCategories.IncidentSupport, + "An accident, injury or near miss that happened on the incident: what occurred, who was involved, what was damaged and what was reported onward. Retained permanently, like every casualty and exposure record.", + RmsLifecyclePreset.ApprovalAcknowledgement, "ACCRPT", SubjectsParticipant, + Header(false), + Section("event", "Event", + Select("event_kind", "Kind", true, true, "Injury", "Illness", "Near miss", "Vehicle accident", "Equipment damage", "Property damage", "Exposure", "Other"), + F("occurred_on", "Occurred on", RmsFieldType.DateTime, true), F("location", "Location", RmsFieldType.Address, true), + F("division_group", "Division or group", RmsFieldType.ShortText, false, true), F("activity_at_time", "Activity at the time", RmsFieldType.LongText), + F("description", "Description", RmsFieldType.LongText, true), Select("severity", "Severity", true, true, "No injury", "First aid", "Medical treatment", "Lost time", "Hospitalization", "Fatality")), + Rows("involved", "People involved", null, 50, + F("member", "Member", RmsFieldType.Person), F("name_text", "Name (not a member)", RmsFieldType.ShortText), + Select("involvement", "Involvement", true, false, "Injured", "Exposed", "Operator", "Witness", "Supervisor", "Other"), + F("home_agency", "Home agency or unit", RmsFieldType.ShortText), F("injury_description", "Injury or illness", RmsFieldType.LongText), + F("treatment", "Treatment", RmsFieldType.LongText), F("transported_to", "Transported to", RmsFieldType.ShortText), + F("returned_to_duty", "Returned to duty", RmsFieldType.Boolean)), + Rows("property", "Property and equipment", null, 50, + F("item", "Item", RmsFieldType.ShortText, true), F("owner", "Owner", RmsFieldType.ShortText), F("damage", "Damage", RmsFieldType.LongText), + Money("estimated_cost", "Estimated cost"), F("out_of_service", "Out of service", RmsFieldType.Boolean)), + Section("cause", "Cause and prevention", + F("contributing_factors", "Contributing factors", RmsFieldType.LongText), F("immediate_actions", "Immediate actions taken", RmsFieldType.LongText), + F("prevention", "Prevention recommendations", RmsFieldType.LongText), Ext("corrective_action_reference", "Corrective action", "record")), + Section("reporting", "Onward reporting", + Multi("reported_to", "Reported to", "Safety officer", "Incident commander", "Home agency", "Compensation and claims", "Regulator", "Law enforcement", "Vendor"), + F("reported_on", "Reported on", RmsFieldType.DateTime), F("regulator_reference", "Regulator reference", RmsFieldType.ShortText), + F("claim_reference", "Compensation or claim reference", RmsFieldType.ShortText), F("photo", "Photo", RmsFieldType.Attachment)), + Approval("Approved by Safety Officer")); + + accident.RetentionYears = 0; + yield return Policy( + Policy(accident, "treatment-casualty", RmsFieldClassification.Restricted, + "Injury, illness and treatment detail about a named person is restricted whoever recorded it.", + "injury_description", "treatment", "transported_to", "returned_to_duty", "involvement", "name_text"), + "regulatory", RmsFieldClassification.Restricted, + "Regulator and compensation references identify an open matter about a named person or agency.", + "regulator_reference", "claim_reference"); + } + + // ---- Business administration ---------------------------------------------------------------------------- + + private static IEnumerable BusinessAdministration() + { + yield return Ics("delegation-of-authority", "Delegation of authority", RecordDefinitionCategories.IncidentBusiness, + "The signed delegation that puts an incident commander in charge: from whom, to whom, for what, with which constraints and until when.", + RmsLifecyclePreset.ApprovalAcknowledgement, "DELEG", SubjectsCommand, + Header(false), + Section("delegation", "Delegation", + F("delegating_official", "Delegating official", RmsFieldType.ShortText, true, true), F("delegating_position", "Position", RmsFieldType.ShortText), + F("delegating_agency", "Agency", RmsFieldType.ShortText, true, true), F("incident_commander", "Incident commander", RmsFieldType.Person, false, true), + F("incident_commander_text", "Incident commander (not a member)", RmsFieldType.ShortText), F("effective_from", "Effective from", RmsFieldType.DateTime, true), + F("effective_to", "Effective to", RmsFieldType.DateTime), Select("incident_complexity", "Incident complexity", false, true, "Type 5", "Type 4", "Type 3", "Type 2", "Type 1")), + Rows("objectives", "Agency objectives and priorities", 1, 50, + Count("priority", "Priority"), F("objective", "Objective", RmsFieldType.LongText, true), F("measure", "How success is measured", RmsFieldType.LongText)), + Section("constraints", "Authorities and constraints", + F("authorities_granted", "Authorities granted", RmsFieldType.LongText, true), F("financial_limit_note", "Financial limitations", RmsFieldType.LongText), + F("constraints", "Constraints and limitations", RmsFieldType.LongText), F("agency_administrator_contact", "Agency administrator contact", RmsFieldType.ShortText), + F("reporting_expectations", "Reporting expectations", RmsFieldType.LongText), F("delegation_document", "Signed delegation", RmsFieldType.Attachment)), + Section("signatures", "Signatures", + F("delegating_signature", "Delegating official signature", RmsFieldType.Signature, true), F("commander_signature", "Incident commander signature", RmsFieldType.Signature, true), + F("signed_on", "Signed on", RmsFieldType.DateTime, true)), + Approval("Accepted by Incident Commander")); + + var funding = Ics("funding-authorization", "Funding authorization", RecordDefinitionCategories.IncidentBusiness, + "The authority to spend on this incident: which account, up to what ceiling, for what, and who authorized it.", + RmsLifecyclePreset.ApprovalAcknowledgement, "FUNDAU", SubjectsCommand, + Header(false), + Section("authorization", "Authorization", + F("authorizing_official", "Authorizing official", RmsFieldType.ShortText, true, true), F("authorizing_agency", "Agency", RmsFieldType.ShortText, true, true), + F("authorized_on", "Authorized on", RmsFieldType.DateTime, true), F("effective_from", "Effective from", RmsFieldType.Date), + F("effective_to", "Effective to", RmsFieldType.Date), Select("authorization_kind", "Kind", true, true, "Initial", "Increase", "Decrease", "Extension", "Closure")), + Rows("accounts", "Accounts and ceilings", 1, 50, + F("account_code", "Account or job code", RmsFieldType.ShortText, true), F("description", "Description", RmsFieldType.ShortText), + Money("ceiling", "Ceiling"), F("purpose", "Purpose", RmsFieldType.LongText), Ext("finance_reference", "Finance posting", RmsExternalReferenceSchemes.FinancePosting)), + Section("terms", "Terms", + F("permitted_expenditures", "Permitted expenditures", RmsFieldType.LongText), F("restrictions", "Restrictions", RmsFieldType.LongText), + F("reporting_requirements", "Reporting requirements", RmsFieldType.LongText), F("authorization_document", "Signed authorization", RmsFieldType.Attachment)), + Approval("Approved by Agency Administrator")); + + yield return Policy(funding, "regulatory", RmsFieldClassification.Restricted, + "Account codes and spending ceilings are financial control data.", "account_code", "ceiling", "finance_reference"); + + var costShare = Ics("cost-share-agreement", "Cost-share agreement", RecordDefinitionCategories.IncidentBusiness, + "How the incident's cost is divided between jurisdictions: the parties, the basis, the split and the signatures.", + RmsLifecyclePreset.ApprovalAcknowledgement, "COSTSH", SubjectsCommand, + Header(false), + Section("agreement", "Agreement", + Ext("agreement_number", "Agreement number", "agreement", true), F("effective_from", "Effective from", RmsFieldType.Date, true), + F("effective_to", "Effective to", RmsFieldType.Date), Select("basis", "Cost-share basis", true, true, "Acreage", "Resource use", "Fixed percentage", "Actual cost by jurisdiction", "Daily negotiated", "Other"), + F("basis_description", "Basis description", RmsFieldType.LongText, true)), + Rows("parties", "Parties and shares", 2, 20, + F("party", "Party", RmsFieldType.ShortText, true, true), F("jurisdiction", "Jurisdiction", RmsFieldType.ShortText), + Count("share_percent", "Share percent"), Money("estimated_share", "Estimated share"), + F("account_code", "Account or job code", RmsFieldType.ShortText), F("representative", "Representative", RmsFieldType.ShortText), + F("signature", "Signature", RmsFieldType.Signature)), + Section("terms", "Terms", + F("included_costs", "Costs included", RmsFieldType.LongText), F("excluded_costs", "Costs excluded", RmsFieldType.LongText), + F("reconciliation", "Reconciliation and billing", RmsFieldType.LongText), F("dispute_resolution", "Dispute resolution", RmsFieldType.LongText), + F("agreement_document", "Signed agreement", RmsFieldType.Attachment)), + Approval("Approved by Agency Administrators")); + + yield return Policy(costShare, "regulatory", RmsFieldClassification.Restricted, + "Negotiated shares and account codes between jurisdictions are financial terms.", "share_percent", "estimated_share", "account_code"); + + var businessSummary = Ics("incident-business-summary", "Incident business summary", RecordDefinitionCategories.IncidentBusiness, + "The finance section's close-out summary: cost by category as of a stated cursor, the packages produced, and what is still open. Every figure names the ledger cursor it was rendered from.", + RmsLifecyclePreset.ApprovalAcknowledgement, "IBSUM", SubjectsCommand, + Header(false), + Section("summary", "Summary", + F("as_of", "As of", RmsFieldType.DateTime, true), F("cursor_note", "Ledger cursor", RmsFieldType.ShortText), + F("finance_chief", "Finance section chief", RmsFieldType.Person, false, true), Money("total_cost", "Total cost"), + Ext("finance_reference", "Finance posting", RmsExternalReferenceSchemes.FinancePosting), Ext("eisuite_reference", "e-ISuite reference", RmsExternalReferenceSchemes.EIsuite)), + Rows("by_category", "Cost by category", 1, 100, + Select("category", "Category", true, true, "Personnel", "Overtime", "Travel", "Equipment", "Aircraft", "Supplies", "Facilities", "Food service", "Contracts", "Claims", "Other"), + Money("estimated", "Estimated"), Money("committed", "Committed"), Money("accrued", "Accrued"), Money("actual", "Actual"), Money("invoiced", "Invoiced"), + F("notes", "Notes", RmsFieldType.LongText)), + Rows("packages", "Packages produced", null, 50, + Select("package_kind", "Package", true, true, "Time package", "Equipment package", "Claims package", "Cost-share package", "Reimbursement package", "Property package", "Other"), + F("reference", "Reference", RmsFieldType.ShortText), F("produced_on", "Produced on", RmsFieldType.Date), + Select("status", "Status", false, true, "In progress", "Complete", "Transmitted", "Reconciled"), F("document", "Document", RmsFieldType.Attachment)), + Section("open", "Open items", + F("open_items", "Open items", RmsFieldType.LongText), F("claims_note", "Claims outstanding", RmsFieldType.LongText), + Ext("emac_reference", "EMAC reimbursement", RmsExternalReferenceSchemes.Emac)), + Approval("Approved by Finance Section Chief")); + + yield return Policy(businessSummary, "regulatory", RmsFieldClassification.Restricted, + "Incident cost by category is unreleased financial data until the agency publishes it.", + "total_cost", "estimated", "committed", "accrued", "actual", "invoiced", "finance_reference", "eisuite_reference", "emac_reference"); + + var purchase = Ics("purchase-justification", "Purchase justification", RecordDefinitionCategories.IncidentBusiness, + "Why the incident bought something outside the normal supply channel, and who approved it.", + RmsLifecyclePreset.ApprovalAcknowledgement, "PURJUS", SubjectsBusiness, + Header(false), + Section("purchase", "Purchase", + F("requested_by", "Requested by", RmsFieldType.Person, true, true), F("requested_position", "Position", RmsFieldType.ShortText), + F("requested_on", "Requested on", RmsFieldType.DateTime, true), F("vendor", "Vendor", RmsFieldType.Contact, false, true), + F("vendor_text", "Vendor (not a contact)", RmsFieldType.ShortText), Money("estimated_amount", "Estimated amount"), + Select("method", "Procurement method", true, true, "Purchase card", "Purchase order", "Blanket agreement", "Emergency purchase", "Cash", "Other"), + Ext("request_number", "Request number", "request")), + Rows("items", "Items", 1, 100, + F("item", "Item", RmsFieldType.ShortText, true), Count("quantity", "Quantity"), F("unit_of_issue", "Unit of issue", RmsFieldType.ShortText), + Money("unit_price", "Unit price"), F("specification", "Specification", RmsFieldType.LongText)), + Section("justification", "Justification", + F("justification", "Justification", RmsFieldType.LongText, true), F("why_not_cache", "Why the cache or normal channel was not used", RmsFieldType.LongText, true), + F("alternatives_considered", "Alternatives considered", RmsFieldType.LongText), F("urgency", "Urgency", RmsFieldType.LongText)), + Section("approval_detail", "Approval detail", + F("account_code", "Account or job code", RmsFieldType.ShortText), Ext("finance_reference", "Finance posting", RmsExternalReferenceSchemes.FinancePosting), + Ext("vendor_invoice", "Vendor invoice number", RmsExternalReferenceSchemes.VendorInvoice), F("receipt", "Receipt", RmsFieldType.Attachment)), + Approval("Approved by Procurement Unit Leader")); + + yield return Policy(purchase, "regulatory", RmsFieldClassification.Restricted, + "Prices, account codes and invoice references are commercial and financial control data.", + "estimated_amount", "unit_price", "account_code", "finance_reference", "vendor_invoice"); + + var conflict = Ics("conflict-of-interest-attestation", "Conflict-of-interest attestation", RecordDefinitionCategories.IncidentBusiness, + "An attestation from someone in a procurement or contracting role on the incident: what interests they hold, and what they recused themselves from.", + RmsLifecyclePreset.ApprovalAcknowledgement, "COIATT", SubjectsBusiness, + Header(false), + Section("attestor", "Attestor", + F("member", "Member", RmsFieldType.Person, false, true), F("name_text", "Name (not a member)", RmsFieldType.ShortText), + F("position", "Incident position", RmsFieldType.ShortText, true, true), F("home_agency", "Home agency or unit", RmsFieldType.ShortText, false, true), + F("attested_on", "Attested on", RmsFieldType.DateTime, true), Select("scope", "Scope", true, true, "Procurement", "Contracting", "Equipment inspection", "Payment approval", "Cost share", "Other")), + Rows("disclosures", "Disclosures", null, 50, + Select("interest_kind", "Interest", true, false, "Financial", "Employment", "Family or household", "Ownership", "Gift or hospitality", "Prior relationship", "Other"), + F("party", "Party", RmsFieldType.ShortText, true), F("description", "Description", RmsFieldType.LongText, true), + F("recusal", "Recusal or mitigation", RmsFieldType.LongText)), + Section("statement", "Statement", + F("no_conflicts", "No conflicts to disclose", RmsFieldType.Boolean), F("statement", "Statement", RmsFieldType.LongText), + F("attestation_signature", "Attestation signature", RmsFieldType.Signature, true)), + Approval("Reviewed by Finance Section Chief")); + + yield return Policy(conflict, "regulatory", RmsFieldClassification.Restricted, + "A disclosed personal or financial interest is about a named individual and their relationships.", + "interest_kind", "party", "description", "recusal", "statement"); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordTemplateCatalog.cs b/Core/Resgrid.Services/Records/RecordTemplateCatalog.cs index 8efa3189..96d5c7a7 100644 --- a/Core/Resgrid.Services/Records/RecordTemplateCatalog.cs +++ b/Core/Resgrid.Services/Records/RecordTemplateCatalog.cs @@ -28,7 +28,7 @@ public sealed class RecordTemplatePack /// packs) and the locked jurisdiction profiles. Content lives here, in code, so a pack update ships as a product /// release with a diff; it never mutates a department clone. Nothing here is labeled an exact named form. /// - public static class RecordTemplateCatalog + public static partial class RecordTemplateCatalog { public const int CatalogVersion = 1; @@ -56,7 +56,51 @@ public static class RecordTemplateCatalog Source("CIFFC MARS", "Mutual Aid and Resource Sharing agreement and guidelines", "CIFFC", "2025", "https://dev.ciffc.ca/download/mutual-aid-and-resource-sharing/")), Profile("us-ca", "U.S.–Canada cross-border", "US-CA", null, "en-US", "en-US,en-CA,fr-CA", "metric", null, RmsArtifactStatus.Compatible, new Dictionary>(), - Source("International Mobilization Guide", "NIFC International Mobilization Guide", "NIFC", "2026", "https://www.nifc.gov/sites/default/files/NICC/3-Logistics/Reference%20Documents/2026_International_Mobilization_Guide_FINAL.pdf")) + Source("International Mobilization Guide", "NIFC International Mobilization Guide", "NIFC", "2026", "https://www.nifc.gov/sites/default/files/NICC/3-Logistics/Reference%20Documents/2026_International_Mobilization_Guide_FINAL.pdf")), + + // Incident Support profiles (Back Office plan E5). "us-calif" is California, spelled out rather than + // "us-ca" — that key already means the U.S.–Canada cross-border pair above, and two profiles that read + // the same in a URL is exactly the confusion an opaque identifier is supposed to prevent. + Profile("us-nwcg", "United States — wildland (NWCG)", "US", null, "en-US", "en-US", "customary", "USD", RmsArtifactStatus.Compatible, + new Dictionary> + { + ["en-US"] = new Dictionary + { + ["subdivision"] = "State", ["area"] = "Area (acres)", ["incident_number"] = "Incident number", ["incident_name"] = "Incident name", + ["order_number"] = "Resource order number", ["request_number"] = "Request number", ["agency"] = "Home agency/unit", + ["position"] = "ICS position (PMS 310-1)", ["operational_period"] = "Operational period" + } + }, + Source("NWCG PMS 902", "NWCG Incident Business Committee — Interagency Incident Business Management Handbook", "NWCG", "2025", "https://www.nwcg.gov/publications/902"), + Source("NWCG PMS 310-1", "NWCG Standards for Wildland Fire Position Qualifications", "NWCG", "2025", "https://www.nwcg.gov/publications/pms310-1"), + Source("NIFC mobilization guide", "NIFC mobilization and resource-order guidance", "NIFC", "2026", "https://www.nifc.gov/nicc/logistics/reference-documents")), + + Profile("us-nims", "United States — all-hazards (FEMA NIMS)", "US", null, "en-US", "en-US", "customary", "USD", RmsArtifactStatus.Compatible, + new Dictionary> + { + ["en-US"] = new Dictionary + { + ["subdivision"] = "State", ["area"] = "Area (acres)", ["incident_number"] = "Incident number", ["incident_name"] = "Incident name", + ["order_number"] = "Mission/task number", ["request_number"] = "Resource request number", ["agency"] = "Home agency/jurisdiction", + ["position"] = "ICS position (NIMS)", ["operational_period"] = "Operational period" + } + }, + Source("NIMS/ICS forms", "FEMA National Incident Management System ICS forms", "FEMA", "2023", "https://www.fema.gov/emergency-managers/nims/components"), + Source("NIMS resource management", "NIMS Resource Management supplemental guidance", "FEMA", "2023", "https://www.fema.gov/emergency-managers/nims/components")), + + Profile("us-calif", "United States — California (Cal OES / CAL FIRE / FIRESCOPE)", "US", "CA", "en-US", "en-US", "customary", "USD", RmsArtifactStatus.Compatible, + new Dictionary> + { + ["en-US"] = new Dictionary + { + ["subdivision"] = "State", ["area"] = "Area (acres)", ["incident_number"] = "Incident number", ["incident_name"] = "Incident name", + ["order_number"] = "Resource order number", ["request_number"] = "Request number", ["agency"] = "Home agency/unit", + ["position"] = "ICS position (CICCS)", ["operational_period"] = "Operational period" + } + }, + Source("FIRESCOPE FOG", "FIRESCOPE Field Operations Guide ICS 420-1", "FIRESCOPE", "2024", "https://firescope.caloes.ca.gov/ics-documents"), + Source("CICCS", "California Incident Command Certification System qualification guide", "Cal OES / FIRESCOPE", "2024", "https://firescope.caloes.ca.gov/ics-documents"), + Source("Cal OES mutual aid plan", "California Fire Service and Rescue Emergency Mutual Aid Plan", "Cal OES", "2024", "https://www.caloes.ca.gov/office-of-the-director/operations/response-operations/fire-rescue/")) }; // ---- packs ------------------------------------------------------------------------------------ @@ -71,7 +115,8 @@ public static class RecordTemplateCatalog HazmatPack(), IndustrialPack(), ExercisePack(), - MutualAidPack() + MutualAidPack(), + IncidentSupportPack() }; public static IEnumerable AllTemplates => Packs.SelectMany(p => p.Definitions); diff --git a/Core/Resgrid.Services/Records/RecordTypedValuesService.cs b/Core/Resgrid.Services/Records/RecordTypedValuesService.cs index ada6b517..20a81718 100644 --- a/Core/Resgrid.Services/Records/RecordTypedValuesService.cs +++ b/Core/Resgrid.Services/Records/RecordTypedValuesService.cs @@ -308,6 +308,13 @@ private async Task> ParseFieldAsync(ParseContext context, R { if (IsBlank(input)) return null; var value = input.Value?.Trim(); + // A client that posts only Values or ReferenceId leaves Value null; the text cases below index it directly, + // so record a validation issue rather than letting the NRE surface as a 500. + if (value == null && (field.Type == RmsFieldType.ShortText || field.Type == RmsFieldType.LongText || field.Type == RmsFieldType.CountrySubdivision)) + { + context.Error(input, "not_text", $"'{field.Label ?? field.Key}' must be posted as a text value."); + return null; + } var row = new RmsRecordValue(); switch (field.Type) { @@ -587,11 +594,18 @@ private void ApplyFinalizationRules(ParseContext context, ParsedValues parsed) // Storage // ------------------------------------------------------------------------------------------------ + /// + /// A posted list replaces the stored rows wholesale, so the two "nothing here" shapes have to stay apart: + /// null means the caller sent no values at all and the stored draft is left alone, an empty list is an + /// explicit clear. Without that split a client that has never heard of typed values erases them on any save. + /// public async Task SaveDraftValuesAsync(int departmentId, string userId, string recordId, RmsRecordDefinitionVersion version, List inputs, CancellationToken cancellationToken = default) { if (version == null) throw new ArgumentNullException(nameof(version)); + if (inputs == null) + return await HydrateAsync(departmentId, recordId, null, version, true); var context = new ParseContext(departmentId, recordId, version.Schema, this) { UserId = userId }; - var parsed = await ParseAllAsync(context, inputs ?? new List(), version); + var parsed = await ParseAllAsync(context, inputs, version); if (context.Issues.Any(i => i.Severity == "error")) throw new ArgumentException(string.Join(" ", context.Issues.Where(i => i.Severity == "error").Select(i => i.Message))); @@ -669,19 +683,21 @@ private async Task RevealSealedAsync(int departmentId, List /// /// A copied row gets a new identity, and the envelope's AAD is bound to the row key, so a sealed source must be - /// revealed first and the copy re-sealed under its own key (the caller's grant carries finalize under enforcement). + /// revealed first and the copy carried as plaintext (the caller's grant carries finalize under enforcement). + /// The reveal unseals the sources in place, so the copy — cloned before the reveal — is what still holds the + /// envelope and what has to be tested. Re-sealing is deliberately left to , + /// which runs after the copies have been given their new row ids, so the new envelope binds to the new key. /// - private async Task ResealCopiesAsync(int departmentId, string userId, List sources, List copies, string operation, CancellationToken cancellationToken) + private async Task UnsealCopiesAsync(int departmentId, List sources, List copies, string operation) { (await RevealSealedAsync(departmentId, sources)).RequireRevealed(operation); foreach (var copy in copies) { var source = sources.FirstOrDefault(s => s.RmsRecordValueId == copy.RmsRecordValueId); - if (source == null || !source.IsSealed) continue; + if (source == null || !copy.IsSealed) continue; RmsRecordValuePack.Unpack(copy, RmsRecordValuePack.Pack(source)); copy.ProtectedEnvelope = null; copy.IsProtected = false; copy.ProtectedCatalogVersion = 0; } - await _protection.ProtectValuesAsync(departmentId, copies, userId, cancellationToken); } public async Task CopyDraftToRevisionAsync(int departmentId, string recordId, string revisionId, CancellationToken cancellationToken = default) @@ -690,7 +706,7 @@ public async Task CopyDraftToRevisionAsync(int departmentId, string recordId, st var rows = (await _values.GetForRecordAsync(departmentId, recordId, null))?.ToList() ?? new List(); var now = DateTime.UtcNow; var copies = rows.Select(Clone).ToList(); - await ResealCopiesAsync(departmentId, null, rows, copies, "finalize", cancellationToken); + await UnsealCopiesAsync(departmentId, rows, copies, "finalize"); var groupMap = new Dictionary(StringComparer.Ordinal); foreach (var group in groups) { @@ -726,7 +742,7 @@ public async Task RestoreDraftFromRevisionAsync(int departmentId, string userId, var groups = (await _groups.GetForRecordAsync(departmentId, recordId, revisionId))?.ToList() ?? new List(); var rows = (await _values.GetForRecordAsync(departmentId, recordId, revisionId))?.ToList() ?? new List(); var copies = rows.Select(Clone).ToList(); - await ResealCopiesAsync(departmentId, userId, rows, copies, "restore draft", cancellationToken); + await UnsealCopiesAsync(departmentId, rows, copies, "restore draft"); await _values.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); await _groups.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); var now = DateTime.UtcNow; diff --git a/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs b/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs index c442d9e3..9b316f29 100644 --- a/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs +++ b/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs @@ -192,6 +192,10 @@ public async Task GetPacketAsync(int departmentId, string userId, var run = await _runs.GetWithDataAsync(departmentId, runId); if (run == null || run.DeletedOn.HasValue || run.ExpiresOn <= DateTime.UtcNow || !string.Equals(run.TemplateKey, PacketTemplateKey, StringComparison.Ordinal)) return null; + // A packet is the requester's own assembled copy of records they were cleared for at build time, so the + // download belongs to whoever built it — ExportRecords alone must not open somebody else's packet. + if (!string.Equals(run.GeneratedByUserId, userId, StringComparison.Ordinal)) + return null; (await _protection.RevealExportRunsAsync(departmentId, new[] { run }, true, cancellationToken)).RequireRevealed("packet download"); await _audits.InsertAsync(new RmsAccessAudit { diff --git a/Core/Resgrid.Services/Records/RecordsFieldRolloutService.cs b/Core/Resgrid.Services/Records/RecordsFieldRolloutService.cs new file mode 100644 index 00000000..54e4bc50 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsFieldRolloutService.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Per-app Field Records rollout telemetry and its dashboard (RMS plan RMS-1D). A client reports coded + /// outcomes; the server stamps them with the authenticated department and member and stores nothing else, so + /// a rollout number can never carry record content. The dashboard is department-admin only and combines those + /// events with what the Records themselves already say about which app created and finalized them. + /// + public class RecordsFieldRolloutService : IRecordsFieldRolloutService + { + /// How far back a dashboard may look. Rollout telemetry is operational, not an archive. + public const int MaxWindowDays = 90; + + /// Most events one dashboard pass reads; a busier department reports on what fits rather than stalling. + public const int MaxWindowEvents = 100000; + + private static readonly RmsOriginClient[] FieldApps = { RmsOriginClient.Responder, RmsOriginClient.Unit, RmsOriginClient.IncidentCommand, RmsOriginClient.Dispatch }; + + private readonly IRmsFieldRolloutEventsRepository _events; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IFeatureToggleService _flags; + + public RecordsFieldRolloutService(IRmsFieldRolloutEventsRepository events, IRecordsAuthorizationService authorization, IRmsOperationalRecordsRepository records, IFeatureToggleService flags) + { + _events = events; + _authorization = authorization; + _records = records; + _flags = flags; + } + + #region Recording + + public async Task RecordBatchAsync(int departmentId, string userId, RecordFieldRolloutBatch batch, CancellationToken cancellationToken = default) + { + if (batch == null || batch.Events == null || batch.Events.Count == 0) + return 0; + // A client that is not one of the four apps has no rollout to report; its events are dropped rather + // than stored under an origin it does not have. + if (!FieldRecordCatalogV1.IsFieldOrigin(batch.OriginClient)) + return 0; + if (!await _authorization.IsActiveMemberAsync(userId, departmentId)) + return 0; + + var now = DateTime.UtcNow; + var rows = new List(); + foreach (var input in batch.Events.Take(RecordFieldRolloutBatch.MaxEvents)) + { + if (input == null || !RmsFieldRolloutEventTypes.IsKnown(input.EventType)) + continue; + var occurredOn = input.OccurredOn ?? now; + // A clock that is wrong or hostile cannot move a row outside the window it is reported in. + if (occurredOn > now.AddMinutes(5)) occurredOn = now; + if (occurredOn < now.AddDays(-MaxWindowDays)) occurredOn = now.AddDays(-MaxWindowDays); + + rows.Add(new RmsFieldRolloutEvent + { + RmsFieldRolloutEventId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + OriginClient = (int)batch.OriginClient, + AppVersion = Trim(batch.AppVersion, 32), + ClientCapability = Trim(batch.ClientCapability, 32), + EventType = input.EventType.Trim().ToLowerInvariant(), + Outcome = Trim(input.Outcome, 48) ?? "ok", + DefinitionKey = Trim(input.DefinitionKey, 64), + DefinitionVersion = input.DefinitionVersion, + RecordId = Trim(input.RecordId, 36), + UserId = userId, + DurationMs = input.DurationMs is > 0 and < 86_400_000 ? input.DurationMs : null, + ItemCount = input.ItemCount is >= 0 and <= 1_000_000 ? input.ItemCount : null, + OccurredOn = occurredOn, + RecordedOn = now + }); + } + + return rows.Count == 0 ? 0 : await _events.InsertBatchAsync(rows, cancellationToken); + } + + public async Task RecordAsync(int departmentId, string userId, RmsOriginClient origin, string appVersion, string clientCapability, string eventType, string outcome, CancellationToken cancellationToken = default) + { + if (!FieldRecordCatalogV1.IsFieldOrigin(origin) || !RmsFieldRolloutEventTypes.IsKnown(eventType)) + return; + var now = DateTime.UtcNow; + await _events.InsertBatchAsync(new[] + { + new RmsFieldRolloutEvent + { + RmsFieldRolloutEventId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + OriginClient = (int)origin, + AppVersion = Trim(appVersion, 32), + ClientCapability = Trim(clientCapability, 32), + EventType = eventType.Trim().ToLowerInvariant(), + Outcome = Trim(outcome, 48) ?? "ok", + UserId = userId, + OccurredOn = now, + RecordedOn = now + } + }, cancellationToken); + } + + #endregion + + #region Dashboard + + public async Task GetAsync(int departmentId, string userId, int windowDays = 30, CancellationToken cancellationToken = default) + { + if (!await _authorization.IsDepartmentAdminAsync(userId, departmentId)) + throw new UnauthorizedAccessException("The Field Records rollout dashboard is department administration only."); + + windowDays = Math.Max(1, Math.Min(MaxWindowDays, windowDays)); + var since = DateTime.UtcNow.AddDays(-windowDays); + var rollout = new RecordsFieldRollout { WindowDays = windowDays, WindowStart = since }; + + foreach (var app in FieldApps) + { + var flag = FieldFlagFor(app); + var enabled = false; + try { enabled = await _flags.IsEnabledAsync(flag, departmentId); } + catch (Exception ex) { Framework.Logging.LogException(ex, "Field Records rollout could not read " + flag + "."); } + rollout.AppFlags[app.ToString()] = enabled; + rollout.AnyAppEnabled |= enabled; + rollout.MinimumAppVersions[app.ToString()] = MinimumVersionFor(app) ?? string.Empty; + } + + var events = (await _events.GetForWindowAsync(departmentId, since, MaxWindowEvents))?.ToList() ?? new List(); + // Records are the ground truth for adoption: an app that reports nothing still shows the work it did. + var created = (await _records.GetCreatedSinceAsync(departmentId, since, MaxWindowEvents))?.ToList() ?? new List(); + var finalized = (await _records.GetFinalizedSinceAsync(departmentId, since))?.ToList() ?? new List(); + + foreach (var app in FieldApps) + { + var appEvents = events.Where(e => e.OriginClient == (int)app).ToList(); + var summary = Summarize(app, appEvents, MinimumVersionFor(app)); + summary.RecordsCreated = created.Count(r => r.OriginClient == (int)app); + summary.RecordsFinalized = finalized.Count(r => r.OriginClient == (int)app); + rollout.Apps.Add(summary); + } + + return rollout; + } + + /// Aggregates one app's window. Every number here is a count, a distinct-user count or a median. + public static RecordsFieldRolloutApp Summarize(RmsOriginClient app, List events, string minimumVersion) + { + var summary = new RecordsFieldRolloutApp { OriginClient = app.ToString() }; + if (events == null || events.Count == 0) + return summary; + + summary.ActiveUsers = events.Where(e => !string.IsNullOrWhiteSpace(e.UserId)).Select(e => e.UserId).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + + summary.Versions = events + .Where(e => !string.IsNullOrWhiteSpace(e.AppVersion)) + .GroupBy(e => e.AppVersion, StringComparer.OrdinalIgnoreCase) + .Select(group => new RecordsFieldRolloutVersion + { + AppVersion = group.Key, + Users = group.Where(e => !string.IsNullOrWhiteSpace(e.UserId)).Select(e => e.UserId).Distinct(StringComparer.OrdinalIgnoreCase).Count(), + Events = group.Count() + }) + .OrderByDescending(version => FieldRecordCatalogV1.CompareVersions(version.AppVersion, "0")) + .ThenByDescending(version => version.Users) + .ToList(); + + // Compatible adoption is per person, not per event: a member who reported an old version once and a + // current one since is counted on the version they are actually running now. + var latestByUser = events + .Where(e => !string.IsNullOrWhiteSpace(e.UserId) && !string.IsNullOrWhiteSpace(e.AppVersion)) + .GroupBy(e => e.UserId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.OrderByDescending(e => e.OccurredOn).First().AppVersion, StringComparer.OrdinalIgnoreCase); + summary.CompatibleUsers = latestByUser.Values.Count(version => FieldRecordCatalogV1.MeetsMinimum(version, minimumVersion)); + + foreach (var entry in events) + { + var ok = IsOk(entry.Outcome); + switch (entry.EventType) + { + case RmsFieldRolloutEventTypes.Catalog: + summary.CatalogRequests++; + if (!ok) + { + summary.CatalogFailures++; + Bump(summary.CatalogFailureReasons, entry.Outcome); + } + break; + case RmsFieldRolloutEventTypes.DraftStarted: + summary.DraftsStarted++; + break; + case RmsFieldRolloutEventTypes.DraftSaved: + if (ok) summary.DraftsSaved++; + else summary.DraftSaveFailures++; + break; + case RmsFieldRolloutEventTypes.Sync: + summary.Syncs++; + if (!ok) summary.SyncFailures++; + break; + case RmsFieldRolloutEventTypes.Conflict: + summary.Conflicts++; + Bump(summary.ConflictKinds, entry.Outcome); + break; + case RmsFieldRolloutEventTypes.Attachment: + if (ok) summary.AttachmentsUploaded++; + else summary.AttachmentFailures++; + break; + case RmsFieldRolloutEventTypes.Completed: + summary.Completed++; + break; + case RmsFieldRolloutEventTypes.Abandoned: + summary.Abandoned++; + break; + case RmsFieldRolloutEventTypes.WebHandoff: + summary.WebHandoffs++; + break; + } + } + + var durations = events + .Where(e => e.EventType == RmsFieldRolloutEventTypes.Completed && e.DurationMs.HasValue && e.DurationMs > 0) + .Select(e => e.DurationMs.Value) + .OrderBy(value => value) + .ToList(); + if (durations.Count > 0) + summary.MedianTimeToCompleteMs = durations.Count % 2 == 1 ? durations[durations.Count / 2] : (durations[durations.Count / 2 - 1] + durations[durations.Count / 2]) / 2; + + return summary; + } + + #endregion + + #region Helpers + + private static bool IsOk(string outcome) => string.IsNullOrWhiteSpace(outcome) || outcome.Equals("ok", StringComparison.OrdinalIgnoreCase); + + private static void Bump(IDictionary counts, string key) + { + var name = string.IsNullOrWhiteSpace(key) ? "unknown" : key.Trim(); + counts[name] = counts.TryGetValue(name, out var current) ? current + 1 : 1; + } + + private static string Trim(string value, int max) => string.IsNullOrWhiteSpace(value) ? null : (value.Trim().Length > max ? value.Trim().Substring(0, max) : value.Trim()); + + private static string FieldFlagFor(RmsOriginClient app) + { + switch (app) + { + case RmsOriginClient.Responder: return FeatureFlagKeys.RecordsFieldResponder; + case RmsOriginClient.Unit: return FeatureFlagKeys.RecordsFieldUnit; + case RmsOriginClient.IncidentCommand: return FeatureFlagKeys.RecordsFieldIncidentCommand; + default: return FeatureFlagKeys.RecordsFieldDispatch; + } + } + + private static string MinimumVersionFor(RmsOriginClient app) + { + switch (app) + { + case RmsOriginClient.Responder: return RecordsFieldConfig.MinimumResponderVersion; + case RmsOriginClient.Unit: return RecordsFieldConfig.MinimumUnitVersion; + case RmsOriginClient.IncidentCommand: return RecordsFieldConfig.MinimumIncidentCommandVersion; + default: return RecordsFieldConfig.MinimumDispatchVersion; + } + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs b/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs index 9423ed7f..5b72b94d 100644 --- a/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs +++ b/Core/Resgrid.Services/Records/RecordsPrintLayoutService.cs @@ -157,8 +157,11 @@ public static RecordsDefinitionLayoutConfig NormalizeDefinition(RecordsDefinitio config.HiddenSectionKeys = Keys(config.HiddenSectionKeys); config.HiddenFieldKeys = Keys(config.HiddenFieldKeys); config.PageBreakBeforeSectionKeys = Keys(config.PageBreakBeforeSectionKeys); + // "Order", "order" and "order " all normalize to the same key, and ToDictionary would throw on the second + // one — turning a client-supplied layout into an unhandled failure on save. Keep the first and move on. config.SectionHeadings = (config.SectionHeadings ?? new Dictionary()).Where(p => !string.IsNullOrWhiteSpace(p.Key) && !string.IsNullOrWhiteSpace(p.Value)) - .ToDictionary(p => p.Key.Trim().ToLowerInvariant(), p => Trim(p.Value, 120), StringComparer.OrdinalIgnoreCase); + .GroupBy(p => p.Key.Trim().ToLowerInvariant(), StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => Trim(g.First().Value, 120), StringComparer.OrdinalIgnoreCase); config.SignatureBlockPlacement = RecordsDefinitionLayoutConfig.SignaturePlacements.Contains((config.SignatureBlockPlacement ?? string.Empty).Trim().ToLowerInvariant()) ? config.SignatureBlockPlacement.Trim().ToLowerInvariant() : RecordsDefinitionLayoutConfig.SignatureAtEnd; config.AttachmentListStyle = RecordsDefinitionLayoutConfig.AttachmentStyles.Contains((config.AttachmentListStyle ?? string.Empty).Trim().ToLowerInvariant()) diff --git a/Core/Resgrid.Services/Records/RecordsRevealService.cs b/Core/Resgrid.Services/Records/RecordsRevealService.cs index 957508bd..a85ec8bf 100644 --- a/Core/Resgrid.Services/Records/RecordsRevealService.cs +++ b/Core/Resgrid.Services/Records/RecordsRevealService.cs @@ -43,8 +43,13 @@ public async Task RevealRecordAsync(int departmentId, string fields[$"{accessor.Key}:{details.RmsOperationalRecordDetailId}"] = accessor.Value.Get(details); } } + // RecordsDocumentService.Project drops a protected or classified attachment outright for a caller without + // RecordRestricted_View; the reveal has to agree, or the file name leaks through the other door. foreach (var attachment in aggregate.Attachments ?? new List()) + { + if (!canViewRestricted && attachment.RequiresRestrictedAccess) continue; fields[$"rmsrecordattachments.filename:{attachment.RmsRecordAttachmentId}"] = attachment.FileName; + } // Department-definition values are not cataloged yet (catalog v11); withheld cells stay withheld here too. await _records.RecordAccessAsync(departmentId, userId, aggregate.Record.RmsOperationalRecordId, null, RmsAccessAuditAction.Read, "Protected reveal", ipAddress); @@ -78,7 +83,10 @@ void Add(IEnumerable rows, Func key, IReadOnlyDictionary c.RmsCasualtyRescueId, RmsProtectedFields.Casualties); foreach (var attachment in aggregate.Attachments ?? new List()) + { + if (!canViewRestricted && attachment.RequiresRestrictedAccess) continue; fields[$"rmsrecordattachments.filename:{attachment.RmsRecordAttachmentId}"] = attachment.FileName; + } await _incidents.RecordAccessAsync(departmentId, userId, aggregate.Report.RmsIncidentReportId, null, RmsAccessAuditAction.Read, "Protected reveal", ipAddress); return new RecordRevealResult { Success = true, Fields = fields }; diff --git a/Core/Resgrid.Services/Records/RecordsService.cs b/Core/Resgrid.Services/Records/RecordsService.cs index 23873912..096874e3 100644 --- a/Core/Resgrid.Services/Records/RecordsService.cs +++ b/Core/Resgrid.Services/Records/RecordsService.cs @@ -214,12 +214,19 @@ public async Task CreateDraftAsync(int departmentId, string use record.DisplaySummary = definitionVersion == null ? BuildDisplaySummary(recordType, record, details, units) : definitionVersion.DefinitionKey; var outboxIds = new List(); + // Concurrent creates can read the same maximum before either has inserted, and + // UX_RmsOperationalRecords_Department_RecordNumber then rejects the loser. Its number was never used, so + // the losing create takes a fresh one and tries again rather than failing the caller. + var allocatesOnCreate = definitionVersion != null && definitionVersion.Numbering.Assignment == RmsNumberAssignment.OnCreate; + for (var attempt = 0; ; attempt++) + { + outboxIds.Clear(); try { await InTransactionAsync(async () => { // OnCreate numbering (plan 4.1): the number is reserved now; a cancelled draft records it as voided, never reused. - if (definitionVersion != null && definitionVersion.Numbering.Assignment == RmsNumberAssignment.OnCreate) + if (allocatesOnCreate) record.RecordNumber = await AllocateRecordNumberAsync(record, cancellationToken); await _records.InsertAsync(record, cancellationToken, true); if (definitionVersion != null) @@ -250,6 +257,14 @@ await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Chan if (winner == null) throw; return await ReplayCreateAsync(departmentId, userId, winner, requestChecksum); } + catch (DbException) when (allocatesOnCreate && attempt < NumberAllocationRetries) + { + // The transaction rolled back, so nothing of this attempt survives; reallocate and go round again. + record.RecordNumber = null; + continue; + } + break; + } await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return await GetAsync(departmentId, recordId, false); @@ -810,9 +825,11 @@ public async Task AddAttachmentAsync(int departmentId, stri if (RmsLifecycle.IsTerminal((RmsRecordState)record.State)) throw new RecordTransitionException(recordId, (RmsRecordState)record.State, (RmsRecordState)record.State, "attachments cannot be added to a voided or cancelled Record"); - // Media hygiene (plan section 4.7): images are re-encoded so EXIF/XMP/IPTC never reach storage, active - // content is refused, and the bytes pass the configured scanner before they are stored. - var hygiene = RecordAttachmentHygiene.Sanitize(fileName, contentType, data); + // Media hygiene (plan section 4.7 and RMS-1D): images are re-encoded so EXIF/XMP/IPTC never reach storage, + // active content is refused, and the bytes pass the configured scanner before they are stored. Location + // survives only for a definition whose profile needs the coordinates, and either way the decision is + // recorded on the attachment rather than left to be inferred from the definition later. + var hygiene = RecordAttachmentHygiene.Sanitize(fileName, contentType, data, await RetainsMediaLocationAsync(record)); var scan = await _attachmentScanner.ScanAsync(hygiene.FileName, hygiene.ContentType, hygiene.Data, cancellationToken) ?? new RecordAttachmentScanResult(); if (scan.State == RmsAttachmentScanState.Rejected) throw new RecordAttachmentRejectedException($"Attachment '{hygiene.FileName}' was rejected by the scanner: {scan.Detail}"); @@ -835,6 +852,7 @@ public async Task AddAttachmentAsync(int departmentId, stri UploadedOn = now, ScanState = (int)scan.State, MetadataStripped = hygiene.MetadataStripped, + MediaLocationRetained = hygiene.LocationRetained, CreatedOn = now, ModifiedOn = now, RowVersion = 1 @@ -856,7 +874,7 @@ record = await LoadRecordAsync(departmentId, recordId); var state = (RmsRecordState)record.State; outboxIds.Add((await EnqueueLifecycleEventAsync(record, null, WorkflowTriggerEventType.RecordAttachmentAdded, state, state, null, cancellationToken, null, new Dictionary { ["attachment"] = AttachmentBlock(attachment, (await _attachments.GetMetadataForRecordAsync(departmentId, recordId))?.Count() ?? 1) })).DomainEventOutboxId); - await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Add attachment", RmsOriginClient.Web, cancellationToken, new { attachment.RmsRecordAttachmentId, attachment.ByteSize, attachment.Checksum }); + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Add attachment", RmsOriginClient.Web, cancellationToken, new { attachment.RmsRecordAttachmentId, attachment.ByteSize, attachment.Checksum, attachment.MetadataStripped, attachment.MediaLocationRetained }); }); await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); @@ -876,6 +894,8 @@ public static object AttachmentBlock(RmsRecordAttachment attachment, int count) checksum = attachment.Checksum, classification = ((RmsEvidenceClassification)attachment.Classification).ToString(), scan_state = ((RmsAttachmentScanState)attachment.ScanState).ToString(), + metadata_stripped = attachment.MetadataStripped, + media_location_retained = attachment.MediaLocationRetained, uploaded_by_user_id = attachment.UploadedByUserId, uploaded_on = attachment.UploadedOn, count @@ -1172,6 +1192,7 @@ private async Task AllocateRecordNumberAsync(RmsOperationalRecord record var prefixBase = RmsDefinitionKeys.DefaultNumberPrefix(record.DefinitionKey); var year = (record.StartedOn ?? DateTime.UtcNow).Year; var perGroup = config.PerGroupSequence; + var perIncident = false; var includeYear = config.IncludeYear; var configuredWidth = config.SequenceWidth; if (record.RecordType == null) @@ -1182,14 +1203,20 @@ private async Task AllocateRecordNumberAsync(RmsOperationalRecord record { if (!string.IsNullOrWhiteSpace(numbering.Prefix)) prefixBase = numbering.Prefix; perGroup = numbering.PerGroupSequence; + perIncident = numbering.PerIncidentSequence; includeYear = numbering.ResetYearly; configuredWidth = numbering.SequenceWidth; } } var prefix = prefixBase + "-"; + // Incident scope is the narrowest and comes first: ICS-style forms number per incident, and the sequence + // resets with the Call rather than with the year. A Record with no Call keeps the wider scope. + var incidentScoped = perIncident && record.CallId.HasValue; + if (incidentScoped) + prefix += "C" + record.CallId.Value + "-"; if (perGroup && record.StationGroupId.HasValue) prefix += "G" + record.StationGroupId.Value + "-"; - if (includeYear) + if (includeYear && !incidentScoped) prefix += year + "-"; var width = Math.Max(3, Math.Min(8, configuredWidth <= 0 ? 4 : configuredWidth)); @@ -1379,6 +1406,24 @@ private async Task EnqueueLifecycleEventAsync(RmsOperati /// participants and nothing restricted. Shared so the lifecycle events and the RMS-3 overdue event /// (worker 42) describe a Record identically. /// + /// Whether this Record's definition version asks for photo coordinates to survive upload. + private async Task RetainsMediaLocationAsync(RmsOperationalRecord record) + { + if (record?.RecordType != null || string.IsNullOrWhiteSpace(record?.DefinitionKey)) + return false; + try + { + var version = await _definitions.GetVersionAsync(record.DepartmentId, record.DefinitionKey, record.DefinitionVersion); + return version?.ClientSurface?.RetainMediaLocation == true; + } + catch (Exception ex) + { + // A definition that cannot be read is not a licence to keep coordinates; strip and carry on. + Logging.LogException(ex, "Media location policy could not be read; stripping location."); + return false; + } + } + public static object RecordBlock(RmsOperationalRecord record, RmsRevision revision, RmsRecordState state) { return new diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 2aad8186..1275ff7a 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -295,6 +295,12 @@ protected override void Load(ContainerBuilder builder) // Field Records for the operational apps (RMS-1D) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); // Default attachment scanner: no engine, rows stay Skipped. A real scanner provider replaces this registration. builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0159_AddRmsRecordValues.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0159_AddRmsRecordValues.cs index 6e03b3d1..2bc98582 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0159_AddRmsRecordValues.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0159_AddRmsRecordValues.cs @@ -70,7 +70,7 @@ public override void Up() Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordValues_Department_Version_Field_Number ON RmsRecordValues (DepartmentId, RmsRecordDefinitionVersionId, FieldKey, NumberValue) WHERE IsProtected = 0;"); Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordValues_Department_Version_Field_DateTime ON RmsRecordValues (DepartmentId, RmsRecordDefinitionVersionId, FieldKey, DateTimeValue) WHERE IsProtected = 0;"); Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsRecordValues_Department_Revision ON RmsRecordValues (DepartmentId, RevisionId);"); - Execute.Sql("ALTER TABLE RmsRecordValues ADD CONSTRAINT CK_RmsRecordValues_OneColumnGroup CHECK ((CASE WHEN TextValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN LongTextValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN NumberValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN BoolValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN DateTimeValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN DurationSeconds IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN ReferenceId IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN OptionKey IS NOT NULL THEN 1 ELSE 0 END) = 1 OR ProtectedEnvelope IS NOT NULL);"); + Execute.Sql("ALTER TABLE RmsRecordValues ADD CONSTRAINT CK_RmsRecordValues_OneColumnGroup CHECK ((CASE WHEN TextValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN LongTextValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN NumberValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN BoolValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN DateTimeValue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN DurationSeconds IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN ReferenceId IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN OptionKey IS NOT NULL THEN 1 ELSE 0 END) = CASE WHEN ProtectedEnvelope IS NULL THEN 1 ELSE 0 END);"); } } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0163_AddRmsExternalOrderReferences.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0163_AddRmsExternalOrderReferences.cs index 6630015e..870d2515 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0163_AddRmsExternalOrderReferences.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0163_AddRmsExternalOrderReferences.cs @@ -62,7 +62,7 @@ public override void Up() .WithColumn("ModifiedByUserId").AsString(128).Nullable() .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1L) .WithColumn("DeletedOn").AsDateTime2().Nullable(); - Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsExternalOrders_Department_Record ON RmsExternalOrders (DepartmentId, RecordId);"); + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RmsExternalOrders_Department_Record ON RmsExternalOrders (DepartmentId, RecordId) WHERE DeletedOn IS NULL;"); Execute.Sql("CREATE NONCLUSTERED INDEX IX_RmsExternalOrders_Department_Order ON RmsExternalOrders (DepartmentId, OrderNumber);"); } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0180_AddRmsFieldRollout.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0180_AddRmsFieldRollout.cs new file mode 100644 index 00000000..6ade93ef --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0180_AddRmsFieldRollout.cs @@ -0,0 +1,53 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Field Records media policy and rollout telemetry (RMS plan RMS-1D, registry M0180): the per-attachment + /// record of whether photo coordinates survived upload, and the bounded per-app rollout event stream the + /// adoption dashboards read. Events carry identifiers, counts and outcome codes only — never record content. + /// Guarded for safe retry. + /// + [Migration(180)] + public class M0180_AddRmsFieldRollout : Migration + { + public override void Up() + { + if (Schema.Table("RmsRecordAttachments").Exists() && !Schema.Table("RmsRecordAttachments").Column("MediaLocationRetained").Exists()) + Alter.Table("RmsRecordAttachments").AddColumn("MediaLocationRetained").AsBoolean().NotNullable().WithDefaultValue(false); + + if (!Schema.Table("RmsFieldRolloutEvents").Exists()) + { + Create.Table("RmsFieldRolloutEvents") + .WithColumn("RmsFieldRolloutEventId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("OriginClient").AsInt32().NotNullable() + .WithColumn("AppVersion").AsString(32).Nullable() + .WithColumn("ClientCapability").AsString(32).Nullable() + .WithColumn("EventType").AsString(32).NotNullable() + .WithColumn("Outcome").AsString(48).NotNullable() + .WithColumn("DefinitionKey").AsString(64).Nullable() + .WithColumn("DefinitionVersion").AsInt32().Nullable() + .WithColumn("RecordId").AsString(36).Nullable() + .WithColumn("UserId").AsString(128).Nullable() + .WithColumn("DurationMs").AsInt64().Nullable() + .WithColumn("ItemCount").AsInt32().Nullable() + .WithColumn("OccurredOn").AsDateTime2().NotNullable() + .WithColumn("RecordedOn").AsDateTime2().NotNullable(); + + Create.Index("IX_RmsFieldRolloutEvents_Window").OnTable("RmsFieldRolloutEvents") + .OnColumn("DepartmentId").Ascending().OnColumn("OccurredOn").Ascending(); + Create.Index("IX_RmsFieldRolloutEvents_App").OnTable("RmsFieldRolloutEvents") + .OnColumn("DepartmentId").Ascending().OnColumn("OriginClient").Ascending().OnColumn("EventType").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("RmsFieldRolloutEvents").Exists()) + Delete.Table("RmsFieldRolloutEvents"); + if (Schema.Table("RmsRecordAttachments").Exists() && Schema.Table("RmsRecordAttachments").Column("MediaLocationRetained").Exists()) + Delete.Column("MediaLocationRetained").FromTable("RmsRecordAttachments"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0181_AddRmsExternalOrderConnectors.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0181_AddRmsExternalOrderConnectors.cs new file mode 100644 index 00000000..718b4feb --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0181_AddRmsExternalOrderConnectors.cs @@ -0,0 +1,111 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// External ordering-system connectors for mutual-aid deployments (RMS plan section 4.1, RMS-1C completion, + /// registry M0181): the per-department connector (provider, feed root, encrypted credential, inbound token + /// hash, read/write authority, rate limit, terms acknowledgement) and its bounded run log, plus the ownership + /// marker on RmsExternalOrders that says whether a connector or a person maintains the source view. The + /// credential is stored only as ciphertext and the inbound token only as a hash. Guarded for safe retry. + /// + [Migration(181)] + public class M0181_AddRmsExternalOrderConnectors : Migration + { + public override void Up() + { + if (Schema.Table("RmsExternalOrders").Exists()) + { + if (!Schema.Table("RmsExternalOrders").Column("ConnectorId").Exists()) + Alter.Table("RmsExternalOrders").AddColumn("ConnectorId").AsString(36).Nullable(); + if (!Schema.Table("RmsExternalOrders").Column("OwnershipMarker").Exists()) + Alter.Table("RmsExternalOrders").AddColumn("OwnershipMarker").AsString(16).NotNullable().WithDefaultValue("manual"); + } + + if (!Schema.Table("RmsExternalOrderConnectors").Exists()) + { + Create.Table("RmsExternalOrderConnectors") + .WithColumn("RmsExternalOrderConnectorId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("ProviderKey").AsString(32).NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("SourceSystem").AsString(200).Nullable() + .WithColumn("SourceScheme").AsString(64).NotNullable() + .WithColumn("ProfileKey").AsString(32).NotNullable() + .WithColumn("BaseUrl").AsString(1000).NotNullable() + .WithColumn("CredentialKind").AsString(16).NotNullable() + .WithColumn("CredentialHeaderName").AsString(100).Nullable() + .WithColumn("CredentialCiphertext").AsString(int.MaxValue).Nullable() + .WithColumn("InboundTokenHash").AsString(128).Nullable() + .WithColumn("ReadEnabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("WriteEnabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("PollIntervalMinutes").AsInt32().NotNullable().WithDefaultValue(60) + .WithColumn("MaxRequestsPerHour").AsInt32().NotNullable().WithDefaultValue(12) + .WithColumn("RequestsThisHour").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RateWindowStartedOn").AsDateTime2().Nullable() + .WithColumn("TermsReference").AsString(500).Nullable() + .WithColumn("TermsAcknowledgedOn").AsDateTime2().Nullable() + .WithColumn("TermsAcknowledgedByUserId").AsString(128).Nullable() + .WithColumn("IsEnabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("LastCursor").AsString(1000).Nullable() + .WithColumn("LastPolledOn").AsDateTime2().Nullable() + .WithColumn("LastSuccessOn").AsDateTime2().Nullable() + .WithColumn("LastError").AsString(500).Nullable() + .WithColumn("ConsecutiveFailures").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable() + .WithColumn("ModifiedByUserId").AsString(128).Nullable() + .WithColumn("RowVersion").AsInt64().NotNullable().WithDefaultValue(1) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + + Create.Index("IX_RmsExternalOrderConnectors_Department").OnTable("RmsExternalOrderConnectors") + .OnColumn("DepartmentId").Ascending().OnColumn("DeletedOn").Ascending(); + Create.Index("IX_RmsExternalOrderConnectors_Due").OnTable("RmsExternalOrderConnectors") + .OnColumn("IsEnabled").Ascending().OnColumn("LastPolledOn").Ascending(); + } + + if (!Schema.Table("RmsExternalOrderConnectorRuns").Exists()) + { + Create.Table("RmsExternalOrderConnectorRuns") + .WithColumn("RmsExternalOrderConnectorRunId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("RmsExternalOrderConnectorId").AsString(36).NotNullable() + .WithColumn("Trigger").AsString(16).NotNullable() + .WithColumn("TriggeredByUserId").AsString(128).Nullable() + .WithColumn("StartedOn").AsDateTime2().NotNullable() + .WithColumn("FinishedOn").AsDateTime2().Nullable() + .WithColumn("Outcome").AsString(16).NotNullable() + .WithColumn("Error").AsString(1000).Nullable() + .WithColumn("RequestCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("OrdersSeen").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("OrdersCreated").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SnapshotsRecorded").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RequestsAdded").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Unchanged").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Rejected").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Conflicts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SourceVersion").AsString(64).Nullable(); + + Create.Index("IX_RmsExternalOrderConnectorRuns_Connector").OnTable("RmsExternalOrderConnectorRuns") + .OnColumn("DepartmentId").Ascending().OnColumn("RmsExternalOrderConnectorId").Ascending().OnColumn("StartedOn").Descending(); + } + } + + public override void Down() + { + if (Schema.Table("RmsExternalOrderConnectorRuns").Exists()) + Delete.Table("RmsExternalOrderConnectorRuns"); + if (Schema.Table("RmsExternalOrderConnectors").Exists()) + Delete.Table("RmsExternalOrderConnectors"); + if (Schema.Table("RmsExternalOrders").Exists()) + { + if (Schema.Table("RmsExternalOrders").Column("OwnershipMarker").Exists()) + Delete.Column("OwnershipMarker").FromTable("RmsExternalOrders"); + if (Schema.Table("RmsExternalOrders").Column("ConnectorId").Exists()) + Delete.Column("ConnectorId").FromTable("RmsExternalOrders"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0159_AddRmsRecordValuesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0159_AddRmsRecordValuesPg.cs index 560e8c42..efcc3ae0 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0159_AddRmsRecordValuesPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0159_AddRmsRecordValuesPg.cs @@ -1,4 +1,4 @@ -using FluentMigrator; +using FluentMigrator; namespace Resgrid.Providers.MigrationsPg.Migrations { @@ -66,11 +66,11 @@ public override void Up() .WithColumn("modifiedon").AsDateTime2().NotNullable() .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L); Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_record_revision ON rmsrecordvalues (departmentid, recordid, revisionid);"); - Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_version_field_text ON rmsrecordvalues (departmentid, rmsrecorddefinitionversionid, fieldkey, textvalue) WHERE isprotected = 0;"); - Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_version_field_number ON rmsrecordvalues (departmentid, rmsrecorddefinitionversionid, fieldkey, numbervalue) WHERE isprotected = 0;"); - Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_version_field_datetime ON rmsrecordvalues (departmentid, rmsrecorddefinitionversionid, fieldkey, datetimevalue) WHERE isprotected = 0;"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_version_field_text ON rmsrecordvalues (departmentid, rmsrecorddefinitionversionid, fieldkey, textvalue) WHERE isprotected = false;"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_version_field_number ON rmsrecordvalues (departmentid, rmsrecorddefinitionversionid, fieldkey, numbervalue) WHERE isprotected = false;"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_version_field_datetime ON rmsrecordvalues (departmentid, rmsrecorddefinitionversionid, fieldkey, datetimevalue) WHERE isprotected = false;"); Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsrecordvalues_department_revision ON rmsrecordvalues (departmentid, revisionid);"); - Execute.Sql("ALTER TABLE rmsrecordvalues ADD CONSTRAINT ck_rmsrecordvalues_onecolumngroup CHECK ((CASE WHEN textvalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN longtextvalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN numbervalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN boolvalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN datetimevalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN durationseconds IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN referenceid IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN optionkey IS NOT NULL THEN 1 ELSE 0 END) = 1 OR protectedenvelope IS NOT NULL);"); + Execute.Sql("ALTER TABLE rmsrecordvalues ADD CONSTRAINT ck_rmsrecordvalues_onecolumngroup CHECK ((CASE WHEN textvalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN longtextvalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN numbervalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN boolvalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN datetimevalue IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN durationseconds IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN referenceid IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN optionkey IS NOT NULL THEN 1 ELSE 0 END) = CASE WHEN protectedenvelope IS NULL THEN 1 ELSE 0 END);"); } } diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0163_AddRmsExternalOrderReferencesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0163_AddRmsExternalOrderReferencesPg.cs index 16158e18..99543241 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0163_AddRmsExternalOrderReferencesPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0163_AddRmsExternalOrderReferencesPg.cs @@ -62,7 +62,7 @@ public override void Up() .WithColumn("modifiedbyuserid").AsCustom("citext").Nullable() .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1L) .WithColumn("deletedon").AsDateTime2().Nullable(); - Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsexternalorders_department_record ON rmsexternalorders (departmentid, recordid);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_rmsexternalorders_department_record ON rmsexternalorders (departmentid, recordid) WHERE deletedon IS NULL;"); Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsexternalorders_department_order ON rmsexternalorders (departmentid, ordernumber);"); } diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0180_AddRmsFieldRolloutPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0180_AddRmsFieldRolloutPg.cs new file mode 100644 index 00000000..e29fca14 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0180_AddRmsFieldRolloutPg.cs @@ -0,0 +1,51 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Field Records media policy and rollout telemetry (RMS plan RMS-1D, registry M0180): the per-attachment + /// record of whether photo coordinates survived upload, and the bounded per-app rollout event stream the + /// adoption dashboards read. Events carry identifiers, counts and outcome codes only — never record content. + /// Guarded for safe retry. + /// + [Migration(180)] + public class M0180_AddRmsFieldRolloutPg : Migration + { + public override void Up() + { + if (Schema.Table("rmsrecordattachments").Exists() && !Schema.Table("rmsrecordattachments").Column("medialocationretained").Exists()) + Alter.Table("rmsrecordattachments").AddColumn("medialocationretained").AsBoolean().NotNullable().WithDefaultValue(false); + + if (!Schema.Table("rmsfieldrolloutevents").Exists()) + { + Create.Table("rmsfieldrolloutevents") + .WithColumn("rmsfieldrollouteventid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("originclient").AsInt32().NotNullable() + .WithColumn("appversion").AsString(32).Nullable() + .WithColumn("clientcapability").AsString(32).Nullable() + .WithColumn("eventtype").AsString(32).NotNullable() + .WithColumn("outcome").AsString(48).NotNullable() + .WithColumn("definitionkey").AsString(64).Nullable() + .WithColumn("definitionversion").AsInt32().Nullable() + .WithColumn("recordid").AsString(36).Nullable() + .WithColumn("userid").AsString(128).Nullable() + .WithColumn("durationms").AsInt64().Nullable() + .WithColumn("itemcount").AsInt32().Nullable() + .WithColumn("occurredon").AsDateTime2().NotNullable() + .WithColumn("recordedon").AsDateTime2().NotNullable(); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsfieldrolloutevents_window ON rmsfieldrolloutevents (departmentid, occurredon);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsfieldrolloutevents_app ON rmsfieldrolloutevents (departmentid, originclient, eventtype);"); + } + } + + public override void Down() + { + if (Schema.Table("rmsfieldrolloutevents").Exists()) + Delete.Table("rmsfieldrolloutevents"); + if (Schema.Table("rmsrecordattachments").Exists() && Schema.Table("rmsrecordattachments").Column("medialocationretained").Exists()) + Delete.Column("medialocationretained").FromTable("rmsrecordattachments"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0181_AddRmsExternalOrderConnectorsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0181_AddRmsExternalOrderConnectorsPg.cs new file mode 100644 index 00000000..f84ad538 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0181_AddRmsExternalOrderConnectorsPg.cs @@ -0,0 +1,108 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// External ordering-system connectors for mutual-aid deployments (RMS plan section 4.1, RMS-1C completion, + /// registry M0181): the per-department connector (provider, feed root, encrypted credential, inbound token + /// hash, read/write authority, rate limit, terms acknowledgement) and its bounded run log, plus the ownership + /// marker on RmsExternalOrders that says whether a connector or a person maintains the source view. The + /// credential is stored only as ciphertext and the inbound token only as a hash. Guarded for safe retry. + /// + [Migration(181)] + public class M0181_AddRmsExternalOrderConnectorsPg : Migration + { + public override void Up() + { + if (Schema.Table("rmsexternalorders").Exists()) + { + if (!Schema.Table("rmsexternalorders").Column("connectorid").Exists()) + Alter.Table("rmsexternalorders").AddColumn("connectorid").AsString(36).Nullable(); + if (!Schema.Table("rmsexternalorders").Column("ownershipmarker").Exists()) + Alter.Table("rmsexternalorders").AddColumn("ownershipmarker").AsString(16).NotNullable().WithDefaultValue("manual"); + } + + if (!Schema.Table("rmsexternalorderconnectors").Exists()) + { + Create.Table("rmsexternalorderconnectors") + .WithColumn("rmsexternalorderconnectorid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("providerkey").AsString(32).NotNullable() + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("sourcesystem").AsString(200).Nullable() + .WithColumn("sourcescheme").AsString(64).NotNullable() + .WithColumn("profilekey").AsString(32).NotNullable() + .WithColumn("baseurl").AsString(1000).NotNullable() + .WithColumn("credentialkind").AsString(16).NotNullable() + .WithColumn("credentialheadername").AsString(100).Nullable() + .WithColumn("credentialciphertext").AsCustom("text").Nullable() + .WithColumn("inboundtokenhash").AsString(128).Nullable() + .WithColumn("readenabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("writeenabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("pollintervalminutes").AsInt32().NotNullable().WithDefaultValue(60) + .WithColumn("maxrequestsperhour").AsInt32().NotNullable().WithDefaultValue(12) + .WithColumn("requeststhishour").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ratewindowstartedon").AsDateTime2().Nullable() + .WithColumn("termsreference").AsString(500).Nullable() + .WithColumn("termsacknowledgedon").AsDateTime2().Nullable() + .WithColumn("termsacknowledgedbyuserid").AsString(128).Nullable() + .WithColumn("isenabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("lastcursor").AsString(1000).Nullable() + .WithColumn("lastpolledon").AsDateTime2().Nullable() + .WithColumn("lastsuccesson").AsDateTime2().Nullable() + .WithColumn("lasterror").AsString(500).Nullable() + .WithColumn("consecutivefailures").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable() + .WithColumn("modifiedbyuserid").AsString(128).Nullable() + .WithColumn("rowversion").AsInt64().NotNullable().WithDefaultValue(1) + .WithColumn("deletedon").AsDateTime2().Nullable(); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsexternalorderconnectors_department ON rmsexternalorderconnectors (departmentid, deletedon);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsexternalorderconnectors_due ON rmsexternalorderconnectors (isenabled, lastpolledon);"); + } + + if (!Schema.Table("rmsexternalorderconnectorruns").Exists()) + { + Create.Table("rmsexternalorderconnectorruns") + .WithColumn("rmsexternalorderconnectorrunid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("rmsexternalorderconnectorid").AsString(36).NotNullable() + .WithColumn("trigger").AsString(16).NotNullable() + .WithColumn("triggeredbyuserid").AsString(128).Nullable() + .WithColumn("startedon").AsDateTime2().NotNullable() + .WithColumn("finishedon").AsDateTime2().Nullable() + .WithColumn("outcome").AsString(16).NotNullable() + .WithColumn("error").AsString(1000).Nullable() + .WithColumn("requestcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ordersseen").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("orderscreated").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("snapshotsrecorded").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("requestsadded").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("unchanged").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rejected").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("conflicts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("sourceversion").AsString(64).Nullable(); + + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rmsexternalorderconnectorruns_connector ON rmsexternalorderconnectorruns (departmentid, rmsexternalorderconnectorid, startedon DESC);"); + } + } + + public override void Down() + { + if (Schema.Table("rmsexternalorderconnectorruns").Exists()) + Delete.Table("rmsexternalorderconnectorruns"); + if (Schema.Table("rmsexternalorderconnectors").Exists()) + Delete.Table("rmsexternalorderconnectors"); + if (Schema.Table("rmsexternalorders").Exists()) + { + if (Schema.Table("rmsexternalorders").Column("ownershipmarker").Exists()) + Delete.Column("ownershipmarker").FromTable("rmsexternalorders"); + if (Schema.Table("rmsexternalorders").Column("connectorid").Exists()) + Delete.Column("connectorid").FromTable("rmsexternalorders"); + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index bd03f47f..334fac24 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -319,6 +319,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); // Field Records work assignments (RMS-1D, registry M0179) builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // NERIS incident report aggregate (RMS-2, registry M0164-M0166) 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 2d3bc5b9..2ff27193 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -279,6 +279,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); // Field Records work assignments (RMS-1D, registry M0179) builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // NERIS incident report aggregate (RMS-2, registry M0164-M0166) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs index 48f73e5f..d65c243f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsDefinitionRepositories.cs @@ -226,6 +226,16 @@ public RmsExternalOrderFillsRepository(IConnectionProvider connectionProvider, S public Task> GetForOrderAsync(int departmentId, string orderId) => QueryAsync($"SELECT * FROM {Tbl("RmsExternalOrderFills")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderId")} = {P}OrderId AND {Col("DeletedOn")} IS NULL ORDER BY {Col("RequestNumber")}", new { DepartmentId = departmentId, OrderId = orderId }); + public async Task> GetForOrdersAsync(int departmentId, IEnumerable orderIds) + { + var rows = new List(); + foreach (var ids in (orderIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().Chunk(1000)) + rows.AddRange(await QueryAsync( + $"SELECT * FROM {Tbl("RmsExternalOrderFills")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsExternalOrderId", "Ids")} AND {Col("DeletedOn")} IS NULL ORDER BY {Col("RequestNumber")}", + new { DepartmentId = departmentId, Ids = ids })); + return rows; + } + public Task GetByIdForDepartmentAsync(int departmentId, string fillId) => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsExternalOrderFills")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderFillId")} = {P}Id AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Id = fillId }); } diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsExternalOrderConnectorRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsExternalOrderConnectorRepositories.cs new file mode 100644 index 00000000..d0815695 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsExternalOrderConnectorRepositories.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// External ordering-system connectors (RMS-1C completion, registry M0181). Dapper over RmsRepositoryBase. + public class RmsExternalOrderConnectorsRepository : RmsRepositoryBase, IRmsExternalOrderConnectorsRepository + { + public RmsExternalOrderConnectorsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string connectorId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsExternalOrderConnectors")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderConnectorId")} = {P}Id", + new { DepartmentId = departmentId, Id = connectorId }); + } + + public Task GetByIdAsync(string connectorId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsExternalOrderConnectors")} WHERE {Col("RmsExternalOrderConnectorId")} = {P}Id", + new { Id = connectorId }); + } + + public Task> GetForDepartmentAsync(int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("RmsExternalOrderConnectors")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL ORDER BY {Col("Name")}", + new { DepartmentId = departmentId }); + } + + public Task> GetDueAsync(DateTime utcNow, int take) + { + var parameters = new DynamicParameters(); + parameters.Add("Now", utcNow); + parameters.Add("Skip", 0); + parameters.Add("Take", take <= 0 ? 50 : Math.Min(take, 500)); + // Due = enabled, read authority, terms acknowledged, never polled or polled longer ago than its own interval. + var elapsed = IsPostgres + ? $"{Col("LastPolledOn")} + ({Col("PollIntervalMinutes")} * INTERVAL '1 minute') <= {P}Now" + : $"DATEADD(MINUTE, {Col("PollIntervalMinutes")}, {Col("LastPolledOn")}) <= {P}Now"; + return QueryAsync( + $"SELECT * FROM {Tbl("RmsExternalOrderConnectors")} WHERE {Col("DeletedOn")} IS NULL AND {Col("IsEnabled")} = {True} AND {Col("ReadEnabled")} = {True} AND {Col("TermsAcknowledgedOn")} IS NOT NULL AND ({Col("LastPolledOn")} IS NULL OR {elapsed}) ORDER BY {Col("LastPolledOn")}, {Col("RmsExternalOrderConnectorId")} {Paging()}", + parameters); + } + + public async Task TryBumpRowVersionAsync(int departmentId, string connectorId, long expectedVersion, CancellationToken cancellationToken = default) + { + var rows = await ExecuteAsync( + $"UPDATE {Tbl("RmsExternalOrderConnectors")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderConnectorId")} = {P}Id AND {Col("RowVersion")} = {P}Expected", + new { DepartmentId = departmentId, Id = connectorId, Expected = expectedVersion }, cancellationToken); + return rows == 1; + } + + private static string True => IsPostgres ? "TRUE" : "1"; + } + + public class RmsExternalOrderConnectorRunsRepository : RmsRepositoryBase, IRmsExternalOrderConnectorRunsRepository + { + public RmsExternalOrderConnectorRunsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForConnectorAsync(int departmentId, string connectorId, int take) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("ConnectorId", connectorId); + parameters.Add("Skip", 0); + parameters.Add("Take", take <= 0 ? 50 : Math.Min(take, 500)); + return QueryAsync( + $"SELECT * FROM {Tbl("RmsExternalOrderConnectorRuns")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderConnectorId")} = {P}ConnectorId ORDER BY {Col("StartedOn")} DESC, {Col("RmsExternalOrderConnectorRunId")} {Paging()}", + parameters); + } + + public Task TrimAsync(int departmentId, string connectorId, int keep, CancellationToken cancellationToken = default) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("ConnectorId", connectorId); + parameters.Add("Skip", Math.Max(1, keep)); + parameters.Add("Take", 100000); + var newestBeyondKeep = $"SELECT {Col("RmsExternalOrderConnectorRunId")} FROM {Tbl("RmsExternalOrderConnectorRuns")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExternalOrderConnectorId")} = {P}ConnectorId ORDER BY {Col("StartedOn")} DESC, {Col("RmsExternalOrderConnectorRunId")} {Paging()}"; + // Both engines refuse a DELETE whose subquery reads the same table with paging; a derived table keeps them happy. + return ExecuteAsync( + $"DELETE FROM {Tbl("RmsExternalOrderConnectorRuns")} WHERE {Col("RmsExternalOrderConnectorRunId")} IN (SELECT {Col("RmsExternalOrderConnectorRunId")} FROM ({newestBeyondKeep}) AS stale)", + parameters, cancellationToken); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs index 0b6c86b0..094033d7 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsFieldRepositories.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Dapper; using Resgrid.Model; @@ -86,4 +87,45 @@ public Task> GetModifiedSinceAsync(int depa parameters); } } + + /// + /// Field Records rollout telemetry (RMS-1D, registry M0180). Counts and coded outcomes only; the table is + /// operational and is pruned by window, so nothing here is a record of what anybody wrote. + /// + public class RmsFieldRolloutEventsRepository : RmsRepositoryBase, IRmsFieldRolloutEventsRepository + { + public RmsFieldRolloutEventsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetForWindowAsync(int departmentId, DateTime sinceUtc, int take) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("Since", sinceUtc); + parameters.Add("Skip", 0); + parameters.Add("Take", take <= 0 ? 20000 : Math.Min(take, 200000)); + return QueryAsync( + $"SELECT * FROM {Tbl("RmsFieldRolloutEvents")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("OccurredOn")} >= {P}Since ORDER BY {Col("OccurredOn")}, {Col("RmsFieldRolloutEventId")} {Paging()}", + parameters); + } + + public async Task InsertBatchAsync(IEnumerable events, CancellationToken cancellationToken = default) + { + var rows = (events ?? Enumerable.Empty()).ToList(); + if (rows.Count == 0) + return 0; + + var sql = $@"INSERT INTO {Tbl("RmsFieldRolloutEvents")} ({Cols("RmsFieldRolloutEventId", "DepartmentId", "OriginClient", "AppVersion", "ClientCapability", "EventType", "Outcome", "DefinitionKey", "DefinitionVersion", "RecordId", "UserId", "DurationMs", "ItemCount", "OccurredOn", "RecordedOn")}) + VALUES ({P}RmsFieldRolloutEventId, {P}DepartmentId, {P}OriginClient, {P}AppVersion, {P}ClientCapability, {P}EventType, {P}Outcome, {P}DefinitionKey, {P}DefinitionVersion, {P}RecordId, {P}UserId, {P}DurationMs, {P}ItemCount, {P}OccurredOn, {P}RecordedOn)"; + // Dapper expands the list into one multi-statement command; the whole batch lands in a single round trip. + return await ExecuteAsync(sql, rows, cancellationToken); + } + + public Task DeleteOlderThanAsync(int departmentId, DateTime cutoffUtc, CancellationToken cancellationToken = default) + { + return ExecuteAsync( + $"DELETE FROM {Tbl("RmsFieldRolloutEvents")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("OccurredOn")} < {P}Cutoff", + new { DepartmentId = departmentId, Cutoff = cutoffUtc }, cancellationToken); + } + } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs index 035ef837..9c443529 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs @@ -428,10 +428,14 @@ public async Task> ClaimDueBatchAsync(string leaseOwn { var claimable = new[] { (int)RmsSubmissionState.Queued, (int)RmsSubmissionState.AwaitingDestination }; var recoverable = $"s.{Col("State")} = {(int)RmsSubmissionState.Failed} AND s.{Col("RequiresReconciliation")} = {P}True AND EXISTS (SELECT 1 FROM {Tbl("RmsSubmissionExchanges")} e WHERE e.{Col("DepartmentId")} = s.{Col("DepartmentId")} AND e.{Col("SubmissionId")} = s.{Col("RmsSubmissionId")} AND e.{Col("Stage")} = 'Response' AND NOT EXISTS (SELECT 1 FROM {Tbl("RmsSubmissionExchanges")} a WHERE a.{Col("DepartmentId")} = e.{Col("DepartmentId")} AND a.{Col("SubmissionId")} = e.{Col("SubmissionId")} AND a.{Col("ExchangeId")} = e.{Col("ExchangeId")} AND a.{Col("Stage")} = 'Applied'))"; + // Worker 41 speaks NERIS. A non-NERIS destination (Back Office plan E4: finance export, e-ISuite, + // EMAC reimbursement, agency records filing) is never claimed here, so it waits for its own dispatcher + // instead of being leased, refused as an unsupported destination, and retried until it exhausts. var candidates = (await QueryAsync( - $"SELECT s.* FROM {Tbl("RmsSubmissions")} s WHERE ({InList("State", "States")} OR ({recoverable})) AND ({Col("NextAttemptOn")} IS NULL OR {Col("NextAttemptOn")} <= {P}Now) " + + $"SELECT s.* FROM {Tbl("RmsSubmissions")} s WHERE ({InList("State", "States")} OR ({recoverable})) AND {InList("Destination", "Destinations")} " + + $"AND ({Col("NextAttemptOn")} IS NULL OR {Col("NextAttemptOn")} <= {P}Now) " + $"AND ({Col("LeaseExpiresOn")} IS NULL OR {Col("LeaseExpiresOn")} < {P}Now) ORDER BY {Col("QueuedOn")} {Paging()}", - new { States = InListValue(claimable), True = true, Now = utcNow, Skip = 0, Take = Math.Clamp(batchSize, 1, 500) }, cancellationToken)).ToList(); + new { States = InListValue(claimable), Destinations = InListValue(RmsSubmissionDestinations.NerisOwned), True = true, Now = utcNow, Skip = 0, Take = Math.Clamp(batchSize, 1, 500) }, cancellationToken)).ToList(); var claimed = new List(); var leaseUntil = utcNow.Add(leaseDuration); diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index 232f0e8e..79c34104 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -140,6 +140,8 @@ protected string InList(string column, string parameterName, string alias = null /// Keep the declared parameter type enumerable so Dapper expands SQL Server lists; Npgsql binds the same array to ANY. protected static int[] InListValue(IEnumerable values) => (values ?? Enumerable.Empty()).ToArray(); + protected static string[] InListValue(IEnumerable values) => (values ?? Enumerable.Empty()).ToArray(); + protected static string Concat(params string[] parts) { return string.Join(IsPostgres ? " || " : " + ", parts); @@ -304,14 +306,16 @@ public Task> GetByDefinitionVersionAsync(int d new { DepartmentId = departmentId, DefinitionKey = definitionKey, DefinitionVersion = definitionVersion, States = InListValue(states) }); } - public Task> GetByIdsAsync(int departmentId, IEnumerable recordIds) + public async Task> GetByIdsAsync(int departmentId, IEnumerable recordIds) { - var ids = (recordIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToArray(); - if (ids.Length == 0) - return Task.FromResult>(new List()); - return QueryAsync( - $"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsOperationalRecordId", "Ids")} AND {Col("DeletedOn")} IS NULL AND {Col("PurgedOn")} IS NULL", - new { DepartmentId = departmentId, Ids = ids }); + // Dapper expands the SQL Server IN list into one parameter per id and a report run can pass thousands, so + // chunk below the 2100-parameter limit the way the other id lookups in this file do. + var rows = new List(); + foreach (var ids in (recordIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().Chunk(1000)) + rows.AddRange(await QueryAsync( + $"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsOperationalRecordId", "Ids")} AND {Col("DeletedOn")} IS NULL AND {Col("PurgedOn")} IS NULL", + new { DepartmentId = departmentId, Ids = ids })); + return rows; } public Task> GetByDepartmentAndStatesAsync(int departmentId, IEnumerable states, int? year, int skip, int take) @@ -349,6 +353,18 @@ public Task> GetFinalizedSinceAsync(int depart new { DepartmentId = departmentId, States = InListValue(states), Since = sinceUtc }); } + public Task> GetCreatedSinceAsync(int departmentId, DateTime sinceUtc, int take) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("Since", sinceUtc); + parameters.Add("Skip", 0); + parameters.Add("Take", take <= 0 ? 5000 : Math.Min(take, 200000)); + return QueryAsync( + $"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("CreatedOn")} >= {P}Since AND {Col("DeletedOn")} IS NULL ORDER BY {Col("CreatedOn")} DESC, {Col("RmsOperationalRecordId")} {Paging()}", + parameters); + } + public Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take, string afterId = null) { // Closed states only: a Record still being authored or reviewed is never a retention candidate, however old. @@ -829,7 +845,7 @@ public Task> QueryAsync(int departmentId, { var (where, parameters) = BuildWhere(departmentId, query); return QueryAsync( - $"SELECT p.* FROM {Tbl("RmsRecordSearchProjections")} p WHERE {where} ORDER BY COALESCE(p.{Col("OccurredOn")}, p.{Col("RecordCreatedOn")}) DESC {Paging()}", + $"SELECT p.* FROM {Tbl("RmsRecordSearchProjections")} p WHERE {where} ORDER BY COALESCE(p.{Col("OccurredOn")}, p.{Col("RecordCreatedOn")}) DESC, p.{Col("RmsRecordSearchProjectionId")} DESC {Paging()}", parameters); } @@ -880,18 +896,19 @@ public Task> GetModifiedSinceAsync(int de parameters); } - public Task> GetByIdsAsync(int departmentId, IEnumerable recordIds) + public async Task> GetByIdsAsync(int departmentId, IEnumerable recordIds) { - var ids = (recordIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList(); - if (ids.Count == 0) - return Task.FromResult>(new List()); - - var parameters = new DynamicParameters(); - parameters.Add("DepartmentId", departmentId); - parameters.Add("Ids", IsPostgres ? (object)ids.ToArray() : ids); - return QueryAsync( - $"SELECT * FROM {Tbl("RmsRecordSearchProjections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {InList("RmsRecordSearchProjectionId", "Ids")}", - parameters); + var rows = new List(); + foreach (var ids in (recordIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().Chunk(1000)) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("Ids", IsPostgres ? (object)ids : ids.ToList()); + rows.AddRange(await QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordSearchProjections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL AND {InList("RmsRecordSearchProjectionId", "Ids")}", + parameters)); + } + return rows; } private (string, DynamicParameters) BuildWhere(int departmentId, RmsRecordQuery query) @@ -927,6 +944,12 @@ public Task> GetByIdsAsync(int department parameters.Add("Year", query.Year.Value); } + if (query.OccurredSince.HasValue) + { + sb.Append($" AND COALESCE(p.{Col("FinalizedOn")}, p.{Col("OccurredOn")}, p.{Col("RecordCreatedOn")}) >= {P}OccurredSince"); + parameters.Add("OccurredSince", query.OccurredSince.Value, System.Data.DbType.DateTime2); + } + if (query.CallId.HasValue) { sb.Append($" AND p.{Col("CallId")} = {P}CallId"); diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs index b683e731..e570cc1b 100644 --- a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs +++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs @@ -151,6 +151,21 @@ private static Dictionary Load(string path) "Records|sv|RequesterOrganization", // And Swedish. "Records|es|Error", // "Error" is the Spanish word. "Records|es|LayoutVisible", // "Visible" is the Spanish word. + "Records|de|RolloutVersion", // "Version" is the German word too. + "Records|sv|RolloutVersion", // Swedish uses "Version" as well. + "Records|fr|RolloutVersion", // "Version" is French as well. + "Records|fr|RolloutMinimumVersion", // "minimum" is the French word too. + "Records|pl|RolloutMinimumVersion", // Polish uses "minimum" as well. + // The Responder app is a product name; it is not translated in any locale. + "Records|de|AppResponder", + "Records|es|AppResponder", + "Records|fr|AppResponder", + "Records|it|AppResponder", + "Records|pl|AppResponder", + "Records|sv|AppResponder", + "Records|uk|AppResponder", + "Records|el|AppResponder", + "Records|ar|AppResponder", "Records|fr|LayoutVisible", // "Visible" is the French word too. "Records|fr|ProjectionKind", // "Projection" is French as well. "Records|fr|Projectionqualifications", // "Qualifications" is French as well. diff --git a/Tests/Resgrid.Tests/Rms/BackOfficeExtensionTests.cs b/Tests/Resgrid.Tests/Rms/BackOfficeExtensionTests.cs new file mode 100644 index 00000000..efd7bcb5 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/BackOfficeExtensionTests.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using static Resgrid.Tests.Rms.RmsDefinitionHarness; + +namespace Resgrid.Tests.Rms +{ + /// + /// The seven RMS extensions the Incident Back Office plan enumerates in section 10A.4. Each is small on its own; + /// what these tests hold is that they behave as that plan's contract assumes, so the program can be built against + /// them without discovering later that a subject type is refused, a number is department-scoped, a non-NERIS + /// submission is dispatched to NERIS, or a Web-only definition reaches a phone. + /// + [TestFixture] + public class BackOfficeExtensionTests + { + private RmsDefinitionHarness _h; + + [SetUp] + public void SetUp() => _h = new RmsDefinitionHarness(); + + private static RecordDefinitionSchema Simple() => Schema(Section("main", "Main", Field("summary", RmsFieldType.ShortText, true))); + + // ---- E1: subject reference types ------------------------------------------------------------------------ + + [Test] + public async Task E1_incident_subject_types_are_accepted_and_an_unknown_subject_is_still_refused() + { + var incidentSubjects = new[] + { + "incidentcommand", "incidentoperationalperiod", "incidentparticipant", + "incidentresource", "incidentfacility", "incidentresourcerequest", "vendor" + }; + + foreach (var subject in incidentSubjects) + { + var validation = await _h.Definitions.ValidateAsync(Dept, new RecordDefinitionDraftInput + { + Name = "Subject " + subject, Schema = Simple(), PermittedSubjectTypes = "call," + subject, + Numbering = new RecordDefinitionNumbering { Prefix = "SUB" } + }); + validation.Issues.Where(i => i.Severity == "error").Should().BeEmpty($"'{subject}' is a supported subject type"); + } + + var all = await _h.Definitions.ValidateAsync(Dept, new RecordDefinitionDraftInput + { + Name = "Every incident subject", Schema = Simple(), PermittedSubjectTypes = "call," + string.Join(",", incidentSubjects), + Numbering = new RecordDefinitionNumbering { Prefix = "SUB" } + }); + all.Issues.Where(i => i.Severity == "error").Should().BeEmpty(); + + var unknown = await _h.Definitions.ValidateAsync(Dept, new RecordDefinitionDraftInput + { + Name = "Bad subject", Schema = Simple(), PermittedSubjectTypes = "call,spaceship", + Numbering = new RecordDefinitionNumbering { Prefix = "SUB" } + }); + unknown.Issues.Should().Contain(i => i.Code == "unknown_subject" && i.Severity == "error"); + } + + // ---- E2: incident-scoped numbering ---------------------------------------------------------------------- + + [Test] + public async Task E2_incident_scoped_numbering_restarts_on_each_call_and_falls_back_without_one() + { + foreach (var callId in new[] { 4100, 4200 }) + _h.Calls.Setup(c => c.GetCallByIdAsync(callId, It.IsAny())) + .ReturnsAsync(new Call { CallId = callId, DepartmentId = Dept, Number = "C-" + callId, Name = "Incident " + callId, Type = "Fire", LoggedOn = new DateTime(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc) }); + + await _h.CreateAndPublishAsync("ics-214", "Activity log", Simple(), d => + { + d.PermittedSubjectTypes = "call,incidentcommand"; + d.Numbering = new RecordDefinitionNumbering { Prefix = "ICS214", Assignment = RmsNumberAssignment.OnCreate, SequenceWidth = 3, PerIncidentSequence = true, ResetYearly = true }; + }); + + var first = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-214", CallId = 4100 }); + var second = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-214", CallId = 4100 }); + var otherIncident = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-214", CallId = 4200 }); + + first.Record.RecordNumber.Should().Be("ICS214-C4100-001"); + second.Record.RecordNumber.Should().Be("ICS214-C4100-002", "the sequence counts within the incident"); + otherIncident.Record.RecordNumber.Should().Be("ICS214-C4200-001", "a different incident starts its own sequence"); + + // Incident scope replaces the year segment: the sequence resets with the incident, not the calendar. + first.Record.RecordNumber.Should().NotContain(DateTime.UtcNow.Year.ToString()); + + var noCall = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput { DefinitionKey = "ics-214" }); + noCall.Record.RecordNumber.Should().Be("ICS214-" + DateTime.UtcNow.Year + "-001", "a Record with no Call keeps the wider department scope rather than colliding"); + } + + [Test] + public async Task E2_incident_scoped_numbering_without_the_call_subject_warns_but_does_not_block() + { + var validation = await _h.Definitions.ValidateAsync(Dept, new RecordDefinitionDraftInput + { + Name = "Incident scoped, no call subject", Schema = Simple(), PermittedSubjectTypes = "unit", + Numbering = new RecordDefinitionNumbering { Prefix = "ICS", PerIncidentSequence = true } + }); + + validation.Issues.Where(i => i.Severity == "error").Should().BeEmpty(); + validation.Issues.Should().Contain(i => i.Code == "no_call_subject" && i.Severity == "warning"); + } + + // ---- E3: external identifier schemes -------------------------------------------------------------------- + + [Test] + public void E3_the_incident_business_identifier_schemes_are_named_and_used_by_the_pack() + { + RmsExternalReferenceSchemes.All.Should().Contain(new[] + { + RmsExternalReferenceSchemes.Iroc, RmsExternalReferenceSchemes.EIsuite, RmsExternalReferenceSchemes.Emac, + RmsExternalReferenceSchemes.WebEoc, RmsExternalReferenceSchemes.Lscms, RmsExternalReferenceSchemes.NfesIclip, + RmsExternalReferenceSchemes.LodgingConfirmation, RmsExternalReferenceSchemes.VendorInvoice, RmsExternalReferenceSchemes.FinancePosting + }); + RmsExternalReferenceSchemes.All.Should().OnlyHaveUniqueItems(); + + var packSchemes = RecordTemplateCatalog.Packs.Single(p => p.Key == RecordTemplateCatalog.IncidentSupportPackKey) + .Definitions.SelectMany(d => d.Schema.Sections).SelectMany(s => s.Fields) + .Where(f => f.Type == RmsFieldType.ExternalReference).Select(f => f.ReferenceType).Distinct().ToList(); + + packSchemes.Should().Contain(new[] + { + RmsExternalReferenceSchemes.Iroc, RmsExternalReferenceSchemes.EIsuite, RmsExternalReferenceSchemes.Emac, + RmsExternalReferenceSchemes.WebEoc, RmsExternalReferenceSchemes.Lscms, RmsExternalReferenceSchemes.NfesIclip, + RmsExternalReferenceSchemes.VendorInvoice, RmsExternalReferenceSchemes.FinancePosting + }); + } + + // ---- E4: non-NERIS submission destinations -------------------------------------------------------------- + + [Test] + public void E4_non_neris_destinations_exist_and_are_not_owned_by_the_neris_worker() + { + var nonNeris = new[] + { + RmsSubmissionDestinations.FinanceExport, RmsSubmissionDestinations.EIsuiteExchange, + RmsSubmissionDestinations.EmacReimbursement, RmsSubmissionDestinations.AgencyRecordsFiling + }; + + foreach (var destination in nonNeris) + { + RmsSubmissionDestinations.IsKnown(destination).Should().BeTrue($"{destination} is a destination RMS recognizes"); + RmsSubmissionDestinations.IsNerisOwned(destination).Should().BeFalse($"worker 41 speaks NERIS and must not claim {destination}"); + } + + RmsSubmissionDestinations.NerisOwned.Should().BeEquivalentTo(new[] { RmsSubmissionDestinations.Neris, RmsSubmissionDestinations.NerisIncidentAnalysis }); + RmsSubmissionDestinations.All.Should().OnlyHaveUniqueItems().And.HaveCount(6); + RmsSubmissionDestinations.IsKnown("SOMETHING_ELSE").Should().BeFalse(); + } + + [Test] + public void E4_the_submission_row_carries_a_non_neris_exchange_without_any_neris_field() + { + // The point of E4: the submission model is a general outbound-exchange record. A finance export fills the + // same columns as a NERIS filing and needs no NERIS profile, contract version or entity identity. + var submission = new RmsSubmission + { + RmsSubmissionId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + RecordId = "record-1", + RecordKind = (int)RmsRecordKind.Operational, + RevisionId = "revision-1", + Destination = RmsSubmissionDestinations.FinanceExport, + DestinationVersion = "1.0", + IdempotencyKey = "record-1:revision-1:finance", + State = (int)RmsSubmissionState.Queued, + PayloadJson = "{}", + PayloadChecksum = RecordSnapshotSerializer.Checksum("{}"), + MaxAttempts = 3, + QueuedOn = DateTime.UtcNow + }; + + submission.DestinationIdentity.Should().BeNull("a non-NERIS destination has no NERIS entity identity"); + RmsSubmissionDestinations.IsKnown(submission.Destination).Should().BeTrue(); + RmsSubmissionDestinations.IsNerisOwned(submission.Destination).Should().BeFalse(); + submission.PayloadChecksum.Should().NotBeNullOrWhiteSpace("the payload is checksummed whatever the destination"); + } + + // ---- E5/E6: the Incident Support pack --------------------------------------------------------------------- + + [Test] + public async Task E5_the_incident_support_pack_ships_every_group_web_only_incident_scoped_and_preview() + { + // Read the shipped catalog directly: GetCatalogAsync also mirrors the catalog into the product-scope + // tables behind a process-wide one-shot latch, and RecordTemplateCatalogTests owns the assertion that + // the mirror runs exactly once. + var pack = RecordTemplateCatalog.Packs.Single(p => p.Key == RecordTemplateCatalog.IncidentSupportPackKey); + + pack.IsPreview.Should().BeTrue("no agency has accepted output from this pack yet"); + pack.SupportedProfiles.Should().BeEquivalentTo(new[] { "generic", "us-nwcg", "us-nims", "us-calif", "ca" }); + pack.Sources.Should().NotBeEmpty(); + pack.Definitions.Should().HaveCountGreaterThan(40); + + // Every group in the plan's section 10A.2 table is represented. + var keys = pack.Definitions.Select(d => d.Key).ToList(); + keys.Should().Contain(new[] + { + // IAP core and the assembled plan + "pack.incident-support.ics-202-objectives", "pack.incident-support.ics-203-organization", + "pack.incident-support.ics-204-assignment", "pack.incident-support.ics-207-org-chart", "pack.incident-support.iap-package", + // Status reporting + "pack.incident-support.ics-209-status-summary", + // Communications + "pack.incident-support.ics-205-comms-plan", "pack.incident-support.ics-205a-comms-list", "pack.incident-support.ics-217a-frequency-inventory", + // Medical and safety + "pack.incident-support.ics-206-medical-plan", "pack.incident-support.ics-208-safety-message", "pack.incident-support.ics-215a-hazard-analysis", + // Resources + "pack.incident-support.ics-210-status-change", "pack.incident-support.ics-211-check-in", "pack.incident-support.ics-213-general-message", + "pack.incident-support.ics-213rr-resource-request", "pack.incident-support.ics-218-support-vehicle-inventory", + "pack.incident-support.ics-219-tcard", "pack.incident-support.ics-221-demobilization", + // Activity and planning + "pack.incident-support.ics-214-activity-log", "pack.incident-support.ics-215-planning-worksheet", + "pack.incident-support.ics-220-air-operations", "pack.incident-support.ics-225-performance-rating", "pack.incident-support.ics-260-resource-order", + // Incident business + "pack.incident-support.sf-261-crew-time", "pack.incident-support.of-286-equipment-use-invoice", + "pack.incident-support.of-288-firefighter-time", "pack.incident-support.of-294-equipment-shift-ticket", + "pack.incident-support.of-296-equipment-inspection", "pack.incident-support.of-297-rental-use-envelope", + "pack.incident-support.of-315-rental-agreement", + // Support operations + "pack.incident-support.facility-inspection", "pack.incident-support.facility-use-agreement", + "pack.incident-support.camp-sanitation-inspection", "pack.incident-support.food-service-inspection", + "pack.incident-support.potable-water-test", "pack.incident-support.shift-ticket", + "pack.incident-support.delivery-receiving-ticket", "pack.incident-support.corrective-action", + "pack.incident-support.incident-accident-report", + // Business administration + "pack.incident-support.delegation-of-authority", "pack.incident-support.funding-authorization", + "pack.incident-support.cost-share-agreement", "pack.incident-support.incident-business-summary", + "pack.incident-support.purchase-justification", "pack.incident-support.conflict-of-interest-attestation" + }); + + foreach (var template in pack.Definitions) + { + template.ClientSurface.IsWebOnly.Should().BeTrue($"{template.Key} is a desk product (E7)"); + template.ClientSurface.AllowOffline.Should().BeFalse($"{template.Key} is never written to a device"); + template.PerIncidentSequence.Should().BeTrue($"{template.Key} numbers per incident (E2)"); + template.PermittedSubjectTypes.Should().Contain("call"); + template.PermittedSubjectTypes.Should().Contain("incidentcommand", "the incident is the subject these records hang from (E1)"); + } + } + + [Test] + public async Task E6_incident_business_records_carry_the_incident_business_category() + { + var pack = RecordTemplateCatalog.Packs.Single(p => p.Key == RecordTemplateCatalog.IncidentSupportPackKey); + + var business = pack.Definitions.Where(d => d.Category == RecordDefinitionCategories.IncidentBusiness).Select(d => d.Key).ToList(); + business.Should().Contain(new[] + { + "pack.incident-support.sf-261-crew-time", "pack.incident-support.of-286-equipment-use-invoice", + "pack.incident-support.of-315-rental-agreement", "pack.incident-support.delegation-of-authority", + "pack.incident-support.funding-authorization", "pack.incident-support.cost-share-agreement", + "pack.incident-support.incident-business-summary", "pack.incident-support.purchase-justification", + "pack.incident-support.conflict-of-interest-attestation", "pack.incident-support.facility-use-agreement" + }); + + pack.Definitions.Single(d => d.Key == "pack.incident-support.ics-202-objectives").Category + .Should().Be(RecordDefinitionCategories.IncidentSupport, "planning products are support, not business"); + + // The category survives a clone, which is what makes it useful for grouping a department's own copies. + var aggregate = await _h.Definitions.CreateAsync(Dept, Admin, new RecordDefinitionCreateInput + { + DefinitionKey = "crew-time", Name = "Crew time report", TemplateKey = "pack.incident-support.sf-261-crew-time", JurisdictionProfileKey = "us-nwcg" + }); + aggregate.Definition.Category.Should().Be(RecordDefinitionCategories.IncidentBusiness); + aggregate.Latest.Numbering.PerIncidentSequence.Should().BeTrue("the clone inherits incident-scoped numbering from the template"); + } + + [Test] + public async Task E5_ledger_owned_figures_carry_a_reference_and_a_restricted_floor() + { + // Section 10A.0: the ledger is authoritative. Where a pack record shows a cost or an hour count it also + // carries the reference the figure came from, and the figure itself is not department-wide reading. + var invoice = await _h.Templates.RenderAsync("pack.incident-support.of-286-equipment-use-invoice", "us-nwcg", null); + invoice.Schema.FindField("claimed_total").Classification.Should().Be(RmsFieldClassification.Restricted); + invoice.Schema.FindField("claimed_total").Aggregatable.Should().BeFalse("a restricted figure loses its safe projection with its floor"); + invoice.Schema.FindField("ledger_reference").Type.Should().Be(RmsFieldType.ExternalReference); + invoice.Schema.FindField("finance_reference").ReferenceType.Should().Be(RmsExternalReferenceSchemes.FinancePosting); + + var crewTime = await _h.Templates.RenderAsync("pack.incident-support.sf-261-crew-time", "us-nwcg", null); + crewTime.Schema.FindField("hours_worked").Classification.Should().Be(RmsFieldClassification.Restricted); + crewTime.Schema.FindField("ledger_reference").Should().NotBeNull(); + + var accident = _h.Templates.GetTemplate("pack.incident-support.incident-accident-report"); + accident.RetentionYears.Should().Be(0, "casualty and exposure records are retained permanently"); + var rendered = await _h.Templates.RenderAsync(accident.Key, "generic", null); + rendered.Schema.FindField("injury_description").Classification.Should().Be(RmsFieldClassification.Restricted); + rendered.Schema.FindField("incident_name").Classification.Should().Be(RmsFieldClassification.Standard, "a floor touches only the fields its policy names"); + } + + [Test] + public async Task E5_the_incident_support_profiles_relabel_without_changing_the_base_template() + { + var nwcg = await _h.Templates.RenderAsync("pack.incident-support.ics-211-check-in", "us-nwcg", "en-US"); + var nims = await _h.Templates.RenderAsync("pack.incident-support.ics-211-check-in", "us-nims", "en-US"); + var california = await _h.Templates.RenderAsync("pack.incident-support.ics-211-check-in", "us-calif", "en-US"); + var canada = await _h.Templates.RenderAsync("pack.incident-support.ics-211-check-in", "ca", "en-CA"); + + nwcg.Schema.FindField("position").Label.Should().Be("ICS position (PMS 310-1)"); + nims.Schema.FindField("position").Label.Should().Be("ICS position (NIMS)"); + california.Schema.FindField("position").Label.Should().Be("ICS position (CICCS)"); + RecordTemplateCatalog.Find("pack.incident-support.ics-211-check-in").Schema.FindField("position").Label + .Should().Be("ICS position", "rendering deep-copies the schema"); + + nwcg.MeasurementSystem.Should().Be("customary"); + canada.MeasurementSystem.Should().Be("metric"); + canada.CurrencyCode.Should().Be("CAD"); + + // "us-calif" is California; "us-ca" remains the U.S.-Canada cross-border pair the mutual-aid pack uses. + RecordTemplateCatalog.FindProfile("us-calif").Subdivision.Should().Be("CA"); + RecordTemplateCatalog.FindProfile("us-ca").Country.Should().Be("US-CA"); + foreach (var profile in new[] { "us-nwcg", "us-nims", "us-calif" }) + RecordTemplateCatalog.FindProfile(profile).ArtifactStatus.Should().Be((int)RmsArtifactStatus.Compatible, "nothing here is an exact named form"); + + var unsupported = () => _h.Templates.RenderAsync("pack.incident-support.ics-211-check-in", "us", null); + await unsupported.Should().ThrowAsync().WithMessage("*does not support profile*"); + } + + // ---- E7: the Web-only client surface -------------------------------------------------------------------- + + [Test] + public async Task E7_a_web_only_definition_is_refused_by_every_field_app_and_stays_web_authorable() + { + var surface = RecordDefinitionClientSurface.WebOnly(); + surface.IsWebOnly.Should().BeTrue(); + surface.AllowOffline.Should().BeFalse(); + new RecordDefinitionClientSurface { Dispatch = true }.IsWebOnly.Should().BeFalse("one app is enough to stop it being Web only"); + + var field = new WebOnlyCatalogFixture(); + field.Publish("desk-product", surface); + field.Publish("phone-product", new RecordDefinitionClientSurface + { + Responder = true, Unit = true, IncidentCommand = true, Dispatch = true, + LaunchContexts = { FieldRecordCatalogV1.LaunchContexts.None } + }); + + foreach (var origin in new[] { RmsOriginClient.Responder, RmsOriginClient.Unit, RmsOriginClient.IncidentCommand, RmsOriginClient.Dispatch }) + { + var catalog = await field.CatalogAsync(origin); + catalog.Definitions.Select(d => d.DefinitionKey).Should().NotContain("desk-product", $"{origin} may not author a Web-only definition"); + catalog.Definitions.Select(d => d.DefinitionKey).Should().Contain("phone-product"); + catalog.Exclusions.Should().Contain(e => e.DefinitionKey == "desk-product" && e.Reason == FieldRecordCatalogV1.ExclusionReasons.SurfaceNotEnabled, + $"{origin} is told why, not left to guess"); + } + + // The Web renderer is not gated by the client surface, so the same definition authors normally there. + await _h.CreateAndPublishAsync("desk-product", "Desk product", Simple(), d => d.ClientSurface = RecordDefinitionClientSurface.WebOnly()); + var draft = await _h.Records.CreateDraftAsync(Dept, Author, new RecordDraftInput + { + DefinitionKey = "desk-product", OriginClient = RmsOriginClient.Web, + Values = new List { Value("main", "summary", "Authored at a desk") } + }); + draft.Record.RmsOperationalRecordId.Should().NotBeNullOrEmpty(); + draft.Record.DefinitionKey.Should().Be("desk-product"); + draft.Values.Scalar("summary").Display.Should().Be("Authored at a desk"); + } + + /// A minimal FieldRecordsService around two published definitions, for the surface-exclusion matrix. + private sealed class WebOnlyCatalogFixture + { + private const int Department = 9; + private readonly List _published = new List(); + private readonly List _summaries = new List(); + private readonly FieldRecordsService _service; + + public WebOnlyCatalogFixture() + { + var cutover = new Mock(); + cutover.Setup(c => c.GetModuleStateAsync(Department, It.IsAny())) + .ReturnsAsync(new RecordsModuleState { DepartmentId = Department, FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active }); + + var authorization = new Mock(); + authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Department)).ReturnsAsync(true); + authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Department, It.IsAny())).ReturnsAsync(true); + authorization.Setup(a => a.GetReadScopeStampAsync(It.IsAny(), Department)).ReturnsAsync("scope-1"); + authorization.Setup(a => a.GetVisibleGroupIdsAsync(It.IsAny(), Department)).ReturnsAsync((List)null); + + var flags = new Mock(); + flags.Setup(f => f.IsEnabledAsync(It.IsAny(), Department, It.IsAny(), It.IsAny>())).ReturnsAsync(true); + + var definitions = new Mock(); + definitions.Setup(d => d.GetPublishedAsync(Department)).ReturnsAsync(() => _published); + definitions.Setup(d => d.ListAsync(Department, It.IsAny())).ReturnsAsync(() => _summaries); + + var protection = new Mock(); + protection.Setup(p => p.GetPolicyByDepartmentIdAsync(Department, It.IsAny())).ReturnsAsync((DepartmentDataProtectionPolicy)null); + + _service = new FieldRecordsService(cutover.Object, authorization.Object, flags.Object, definitions.Object, protection.Object, + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of()); + } + + public void Publish(string key, RecordDefinitionClientSurface surface) + { + var schema = Schema(Section("main", "Main", Field("summary", RmsFieldType.ShortText, true))); + _published.Add(new RmsRecordDefinitionVersion + { + DepartmentId = Department, DefinitionKey = key, Version = 1, State = (int)RmsDefinitionVersionState.Published, + LifecyclePreset = (int)RmsLifecyclePreset.QuickEntry, Schema = schema, ClientSurface = surface, + SchemaChecksum = "chk-" + key, MinimumClientCapability = RecordsClientCapabilities.Derive(schema) + }); + _summaries.Add(new RecordDefinitionSummary { Key = key, Name = key, Category = "Operations", PublishedVersion = 1 }); + } + + public Task CatalogAsync(RmsOriginClient origin) + => _service.GetCatalogAsync(Department, "member", new FieldRecordCatalogRequest + { + Origin = origin, ClientCapability = RecordsClientCapabilities.Packs, AppVersion = "9.9.9", Context = new FieldRecordContext() + }); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/ExternalOrderFeedContractTests.cs b/Tests/Resgrid.Tests/Rms/ExternalOrderFeedContractTests.cs new file mode 100644 index 00000000..d25b1109 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/ExternalOrderFeedContractTests.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using static Resgrid.Tests.Rms.FakeOrderFeedProvider; + +namespace Resgrid.Tests.Rms +{ + /// The Resgrid Mutual-Aid Order Feed v1: what a source must send, what is refused, and how its statuses line up with the local fill lifecycle. + [TestFixture] + public class ExternalOrderFeedContractTests + { + [Test] + public void A_well_formed_feed_parses_with_camel_case_names_and_offsets() + { + var json = JsonConvert.SerializeObject(Feed("2026-09-06T12:00", Order("O-1001", "mobilized", Request("O-1", "mobilized"), Request("E-3", "requested", "equipment")))); + json.Should().Contain("\"orderNumber\":\"O-1001\"").And.Contain("\"requestNumber\":\"O-1\"").And.Contain("\"contract\":\"resgrid.mutual-aid-order-feed.v1\""); + + var feed = ExternalOrderFeedContract.Parse(json, out var problems); + + problems.Should().BeEmpty(); + feed.Source.System.Should().Be("Test Ordering"); + feed.Orders.Should().HaveCount(1); + feed.Orders[0].CapturedOn.Value.UtcDateTime.Should().Be(new System.DateTime(2026, 9, 6, 11, 0, 0, System.DateTimeKind.Utc)); + feed.Orders[0].Requests.Select(r => r.RequestNumber).Should().Equal("O-1", "E-3"); + feed.Orders[0].Artifact.Url.Should().StartWith("https://"); + } + + [Test] + public void Empty_malformed_and_foreign_documents_are_refused_with_a_reason() + { + ExternalOrderFeedContract.Parse("", out var empty).Should().BeNull(); empty.Should().ContainSingle(p => p.Contains("empty")); + ExternalOrderFeedContract.Parse("{not json", out var malformed).Should().BeNull(); malformed.Should().ContainSingle(p => p.Contains("not valid JSON")); + ExternalOrderFeedContract.Parse("{\"contract\":\"other.v2\",\"orders\":[]}", out var foreign).Should().BeNull(); foreign.Should().ContainSingle(p => p.Contains("other.v2") && p.Contains(ExternalOrderFeedContract.Version)); + } + + [Test] + public void Every_order_needs_a_number_a_name_a_known_status_and_numbered_requests_with_known_statuses() + { + var bad = Order(null, "shipped", Request(null, "lost")); + bad.IncidentName = " "; + var json = JsonConvert.SerializeObject(Feed("v1", bad)); + + ExternalOrderFeedContract.Parse(json, out var problems).Should().BeNull(); + + problems.Should().Contain(p => p.EndsWith("orderNumber is required.")); + problems.Should().Contain(p => p.EndsWith("incidentName is required.")); + problems.Should().Contain(p => p.Contains("status 'shipped'")); + problems.Should().Contain(p => p.EndsWith("requestNumber is required.")); + problems.Should().Contain(p => p.Contains("status 'lost'")); + } + + [Test] + public void Artifacts_must_be_https_and_pages_are_bounded() + { + var insecure = Order("O-1", "open", Request("O-1")); insecure.Artifact.Url = "http://orders.example.gov/O-1"; + ExternalOrderFeedContract.Parse(JsonConvert.SerializeObject(Feed("v1", insecure)), out var http).Should().BeNull(); + http.Should().ContainSingle(p => p.Contains("artifact.url must be https")); + + var tooMany = Feed("v1", Enumerable.Range(0, ExternalOrderFeedContract.MaxOrdersPerPage + 1).Select(i => Order("O-" + i, "open")).ToArray()); + ExternalOrderFeedContract.Parse(JsonConvert.SerializeObject(tooMany), out var paged).Should().BeNull(); + paged.Should().ContainSingle(p => p.Contains("Page with the cursor")); + + var crowded = Order("O-1", "open", Enumerable.Range(0, ExternalOrderFeedContract.MaxRequestsPerOrder + 1).Select(i => Request("R-" + i)).ToArray()); + ExternalOrderFeedContract.Parse(JsonConvert.SerializeObject(Feed("v1", crowded)), out var requests).Should().BeNull(); + requests.Should().ContainSingle(p => p.Contains($"the contract allows {ExternalOrderFeedContract.MaxRequestsPerOrder}")); + } + + [Test] + public void Request_statuses_rank_in_lifecycle_order_and_local_fill_statuses_map_onto_them() + { + var order = new List { "requested", "filled", "mobilized", "checked-in", "assigned", "released", "demobilized" }; + order.Select(ExternalOrderFeedContract.RequestStatuses.Rank).Should().BeInAscendingOrder().And.OnlyHaveUniqueItems(); + ExternalOrderFeedContract.RequestStatuses.Rank("cancelled").Should().Be(0, "a cancellation has no place on the ladder"); + ExternalOrderFeedContract.RequestStatuses.Rank("nonsense").Should().Be(0); + + ExternalOrderFeedContract.LocalStatusOf(RmsDeploymentFillStatus.Requested).Should().Be("requested"); + ExternalOrderFeedContract.LocalStatusOf(RmsDeploymentFillStatus.Accepted).Should().Be("filled"); + ExternalOrderFeedContract.LocalStatusOf(RmsDeploymentFillStatus.Mobilized).Should().Be("mobilized"); + ExternalOrderFeedContract.LocalStatusOf(RmsDeploymentFillStatus.CheckedIn).Should().Be("checked-in"); + ExternalOrderFeedContract.LocalStatusOf(RmsDeploymentFillStatus.Assigned).Should().Be("assigned"); + ExternalOrderFeedContract.LocalStatusOf(RmsDeploymentFillStatus.Released).Should().Be("released"); + ExternalOrderFeedContract.LocalStatusOf(RmsDeploymentFillStatus.Demobilized).Should().Be("demobilized"); + ExternalOrderFeedContract.LocalStatusOf(RmsDeploymentFillStatus.Declined).Should().Be("cancelled"); + } + + [Test] + public void The_real_providers_fix_scheme_profile_and_the_identifiers_their_source_guarantees() + { + var iroc = new Resgrid.Services.Records.Connectors.IrocOrderFeedProvider(); + iroc.Key.Should().Be(RmsExternalOrderConnectorProviders.Iroc); iroc.DefaultScheme.Should().Be("iroc"); iroc.DefaultProfileKey.Should().Be(RmsDeploymentProfiles.UsWildland); + iroc.ValidateOrder(Order("O-1", "open", Request("O-1"))).Should().BeEmpty(); + var blank = Order("O-1", "open", Request("O-1", "requested", null)); blank.IncidentNumber = null; blank.OrderingOffice = null; blank.DispatchOffice = null; + iroc.ValidateOrder(blank).Should().HaveCount(3); + + var ciffc = new Resgrid.Services.Records.Connectors.CiffcOrderFeedProvider(); + ciffc.DefaultScheme.Should().Be("ciffc"); ciffc.DefaultProfileKey.Should().Be(RmsDeploymentProfiles.CaWildland); + var noAgreement = Order("X-1", "open"); noAgreement.AgreementReference = null; noAgreement.RequestingAgency = null; noAgreement.ReceivingAgency = null; + ciffc.ValidateOrder(noAgreement).Should().HaveCount(2); + + new Resgrid.Services.Records.Connectors.GenericOrderFeedProvider().ValidateOrder(noAgreement).Should().BeEmpty("a generic source promises nothing beyond the contract"); + new Resgrid.Services.Records.Connectors.AgencyOrderFeedProvider().DefaultProfileKey.Should().Be(RmsDeploymentProfiles.Generic); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/FakeOrderFeedProvider.cs b/Tests/Resgrid.Tests/Rms/FakeOrderFeedProvider.cs new file mode 100644 index 00000000..f3044313 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/FakeOrderFeedProvider.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Tests.Rms +{ + /// + /// An order feed that serves whatever pages the test queued, keyed by cursor, and counts every fetch. No HTTP: + /// the provider contract is what is under test, and the real providers differ only in scheme, profile and + /// validation, which a test can pass in. + /// + public sealed class FakeOrderFeedProvider : IExternalOrderFeedProvider + { + private readonly Func> _validate; + private readonly FakeOrderFeedProvider _pagesFrom; + private readonly Dictionary _pages = new Dictionary(StringComparer.Ordinal); + + public FakeOrderFeedProvider() : this(RmsExternalOrderConnectorProviders.Generic, "local", RmsDeploymentProfiles.LocalMutualAid, null) { } + + /// Another fake whose queued pages and counters this one shares, so a test serves one feed whatever provider the connector names. + public FakeOrderFeedProvider(string key, string scheme, string profile, Func> validate, FakeOrderFeedProvider pagesFrom = null) + { + Key = key; + DefaultScheme = scheme; + DefaultProfileKey = profile; + _validate = validate; + _pagesFrom = pagesFrom; + } + + public string Key { get; } + public string DefaultScheme { get; } + public string DefaultProfileKey { get; } + + public int Fetches { get; private set; } + public List CursorsSeen { get; } = new List(); + public Exception Throw { get; set; } + + /// The page served when the connector has no cursor (or the cursor the test names). + public void Serve(ExternalOrderFeed feed, string forCursor = null) => _pages[forCursor ?? string.Empty] = JsonConvert.SerializeObject(feed); + + public void ServeRaw(string json, string forCursor = null) => _pages[forCursor ?? string.Empty] = json; + + public Task FetchAsync(RmsExternalOrderConnector connector, string credential, string cursor, CancellationToken cancellationToken = default) + { + if (_pagesFrom != null) return _pagesFrom.FetchAsync(connector, credential, cursor, cancellationToken); + Fetches++; + CursorsSeen.Add(cursor); + LastCredential = credential; + if (Throw != null) throw Throw; + return Task.FromResult(_pages.TryGetValue(cursor ?? string.Empty, out var page) ? page : _pages.Values.FirstOrDefault() ?? "{}"); + } + + public string LastCredential { get; private set; } + + public List ValidateOrder(ExternalOrderFeedOrder order) => _validate?.Invoke(order) ?? new List(); + + public static ExternalOrderFeed Feed(string version, params ExternalOrderFeedOrder[] orders) => new ExternalOrderFeed + { + Contract = ExternalOrderFeedContract.Version, + Source = new ExternalOrderFeedSource { System = "Test Ordering", Scheme = "local", Version = version, GeneratedOn = new DateTimeOffset(2026, 9, 6, 12, 0, 0, TimeSpan.Zero) }, + Orders = orders.ToList() + }; + + public static ExternalOrderFeedOrder Order(string number, string status = "open", params ExternalOrderFeedRequest[] requests) => new ExternalOrderFeedOrder + { + OrderNumber = number, IncidentName = "Bear Creek", IncidentNumber = "OR-UPF-000123", IncidentCountry = "US", IncidentSubdivision = "OR", OrderingOffice = "ORCOC", DispatchOffice = "Central Oregon", + RequestingAgency = "USFS", SendingAgency = "Test County Fire", CostCode = "P4NABC", AgreementReference = "MA-2026-01", Status = status, CapturedOn = new DateTimeOffset(2026, 9, 6, 11, 0, 0, TimeSpan.Zero), + Requests = requests.ToList(), Artifact = new ExternalOrderFeedArtifact { Url = "https://orders.example.gov/" + number, ContentType = "application/pdf" } + }; + + public static ExternalOrderFeedRequest Request(string number, string status = "requested", string category = "overhead") => new ExternalOrderFeedRequest + { + RequestNumber = number, Category = category, ResourceKind = category == "equipment" ? "unit" : "person", ResourceType = category == "equipment" ? "Engine T3" : null, Position = category == "equipment" ? "ENGB" : "DIVS", + Status = status, NeededOn = new DateTimeOffset(2026, 9, 7, 8, 0, 0, TimeSpan.Zero) + }; + } +} diff --git a/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs b/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs index 64418412..7ae89c94 100644 --- a/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs +++ b/Tests/Resgrid.Tests/Rms/FakeRmsDefinitionStore.cs @@ -27,6 +27,8 @@ public sealed class FakeRmsDefinitionStore public List Orders { get; } = new List(); public List Fills { get; } = new List(); public List References { get; } = new List(); + public List Connectors { get; } = new List(); + public List ConnectorRuns { get; } = new List(); public Mock DefinitionsRepo { get; } = new Mock(); public Mock VersionsRepo { get; } = new Mock(); @@ -40,6 +42,8 @@ public sealed class FakeRmsDefinitionStore public Mock OrdersRepo { get; } = new Mock(); public Mock FillsRepo { get; } = new Mock(); public Mock ReferencesRepo { get; } = new Mock(); + public Mock ConnectorsRepo { get; } = new Mock(); + public Mock ConnectorRunsRepo { get; } = new Mock(); public FakeRmsDefinitionStore(FakeRmsStore records = null) { @@ -123,6 +127,8 @@ public FakeRmsDefinitionStore(FakeRmsStore records = null) .ReturnsAsync((int d, string id) => Reports.FirstOrDefault(x => x.DepartmentId == d && x.RmsSavedReportDefinitionId == id && x.DeletedOn == null)); ReportsRepo.Setup(r => r.GetForDepartmentAsync(It.IsAny())) .ReturnsAsync((int d) => Reports.Where(x => x.DepartmentId == d && x.DeletedOn == null).ToList()); + ReportsRepo.Setup(r => r.TryBumpRowVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long expected, CancellationToken c) => Bump(Reports.FirstOrDefault(x => x.DepartmentId == d && x.RmsSavedReportDefinitionId == id && x.DeletedOn == null), expected, (x, v) => x.RowVersion = v, x => x.RowVersion)); // Product catalog mirrors PacksRepo.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -159,6 +165,25 @@ public FakeRmsDefinitionStore(FakeRmsStore records = null) ReferencesRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RmsExternalReference e, CancellationToken c, bool f) => { References.Add(e); return e; }); + // External order connectors + ConnectorsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsExternalOrderConnector e, CancellationToken c, bool f) => { Connectors.Add(e); return e; }); + ConnectorsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsExternalOrderConnector e, CancellationToken c, bool f) => { Connectors.RemoveAll(x => x.RmsExternalOrderConnectorId == e.RmsExternalOrderConnectorId); Connectors.Add(e); return e; }); + ConnectorsRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => Clone(Connectors.FirstOrDefault(x => x.DepartmentId == d && x.RmsExternalOrderConnectorId == id))); + ConnectorsRepo.Setup(r => r.GetByIdAsync(It.IsAny())) + .ReturnsAsync((string id) => Clone(Connectors.FirstOrDefault(x => x.RmsExternalOrderConnectorId == id))); + ConnectorsRepo.Setup(r => r.GetForDepartmentAsync(It.IsAny())) + .ReturnsAsync((int d) => Connectors.Where(x => x.DepartmentId == d).Select(Clone).ToList()); + ConnectorsRepo.Setup(r => r.GetDueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((DateTime now, int take) => Connectors.Where(x => x.IsReadyToRun && (!x.LastPolledOn.HasValue || x.LastPolledOn.Value.AddMinutes(x.PollIntervalMinutes) <= now)).Take(take).Select(Clone).ToList()); + ConnectorRunsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsExternalOrderConnectorRun e, CancellationToken c, bool f) => { ConnectorRuns.Add(e); return e; }); + ConnectorRunsRepo.Setup(r => r.GetForConnectorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, int take) => ConnectorRuns.Where(x => x.DepartmentId == d && x.RmsExternalOrderConnectorId == id).OrderByDescending(x => x.StartedOn).Take(take).ToList()); + ConnectorRunsRepo.Setup(r => r.TrimAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(0); + if (records != null) { records.RecordsRepo.Setup(r => r.GetByDefinitionVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) @@ -179,5 +204,9 @@ private static bool Bump(T row, long expected, Action set, FuncRepositories hand back fresh rows; the service must not be able to mutate the store through a reference it never saved. + private static RmsExternalOrderConnector Clone(RmsExternalOrderConnector c) + => c == null ? null : Newtonsoft.Json.JsonConvert.DeserializeObject(Newtonsoft.Json.JsonConvert.SerializeObject(c)); + } } diff --git a/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs b/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs index 403c2ecf..45327e5c 100644 --- a/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs +++ b/Tests/Resgrid.Tests/Rms/FieldRecordCatalogTests.cs @@ -87,7 +87,7 @@ public void SetUp() _command = new Mock(); _service = new FieldRecordsService(_cutover.Object, _authorization.Object, _flags.Object, _definitions.Object, _protection.Object, _records.Object, - _assignments.Object, _units.Object, _groups.Object, _calls.Object, _command.Object); + _assignments.Object, _units.Object, _groups.Object, _calls.Object, _command.Object, Mock.Of()); } [TearDown] diff --git a/Tests/Resgrid.Tests/Rms/RecordAttachmentHygieneTests.cs b/Tests/Resgrid.Tests/Rms/RecordAttachmentHygieneTests.cs index b40b3d5e..a745f544 100644 --- a/Tests/Resgrid.Tests/Rms/RecordAttachmentHygieneTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordAttachmentHygieneTests.cs @@ -80,6 +80,79 @@ public void File_names_are_reduced_to_their_leaf() RecordAttachmentHygiene.Sanitize("/var/tmp/../report.pdf", "application/pdf", new byte[] { 1 }).FileName.Should().Be("report.pdf"); } + [Test] + public void Photo_location_is_stripped_by_default_and_kept_only_when_the_definition_asks_for_it() + { + var bytes = GeoTaggedJpeg(); + + var stripped = RecordAttachmentHygiene.Sanitize("scene.jpg", "image/jpeg", bytes); + + stripped.MetadataStripped.Should().BeTrue(); + stripped.LocationRetained.Should().BeFalse(); + using (var decoded = Image.Load(stripped.Data)) + { + decoded.Metadata.ExifProfile.Should().BeNull("a photo's location is a disclosure nobody asked for"); + } + + var kept = RecordAttachmentHygiene.Sanitize("scene.jpg", "image/jpeg", bytes, true); + + kept.LocationRetained.Should().BeTrue(); + using var withLocation = Image.Load(kept.Data); + withLocation.Metadata.ExifProfile.Should().NotBeNull(); + withLocation.Metadata.ExifProfile.TryGetValue(ExifTag.GPSLatitudeRef, out var latitudeRef).Should().BeTrue(); + latitudeRef.Value.Should().Be("N"); + withLocation.Metadata.ExifProfile.TryGetValue(ExifTag.GPSLatitude, out var latitude).Should().BeTrue(); + latitude.Value.Should().NotBeNull(); + } + + [Test] + public void Keeping_the_location_never_keeps_device_or_person_identity() + { + var kept = RecordAttachmentHygiene.Sanitize("scene.jpg", "image/jpeg", GeoTaggedJpeg(), true); + + using var decoded = Image.Load(kept.Data); + var profile = decoded.Metadata.ExifProfile; + profile.Should().NotBeNull(); + profile.TryGetValue(ExifTag.Make, out _).Should().BeFalse(); + profile.TryGetValue(ExifTag.Model, out _).Should().BeFalse(); + profile.TryGetValue(ExifTag.Software, out _).Should().BeFalse(); + profile.TryGetValue(ExifTag.Artist, out _).Should().BeFalse(); + profile.TryGetValue(ExifTag.ImageUniqueID, out _).Should().BeFalse(); + profile.TryGetValue(ExifTag.DateTimeOriginal, out _).Should().BeFalse(); + } + + [Test] + public void An_image_with_no_location_reports_none_kept_even_when_the_definition_allows_it() + { + var kept = RecordAttachmentHygiene.Sanitize("photo.jpg", "image/jpeg", ImageWithMetadata(null, "jpeg"), true); + + kept.LocationRetained.Should().BeFalse(); + using var decoded = Image.Load(kept.Data); + decoded.Metadata.ExifProfile.Should().BeNull(); + } + + /// A JPEG carrying both a GPS fix and the device/person tags that must never survive it. + private static byte[] GeoTaggedJpeg() + { + using var image = new Image(6, 4); + var exif = new ExifProfile(); + exif.SetValue(ExifTag.Make, "TestCam"); + exif.SetValue(ExifTag.Model, "TC-1"); + exif.SetValue(ExifTag.Software, "hygiene-test"); + exif.SetValue(ExifTag.Artist, "A Responder"); + exif.SetValue(ExifTag.ImageUniqueID, "SERIAL-123"); + exif.SetValue(ExifTag.DateTimeOriginal, "2026:09:06 03:15:00"); + exif.SetValue(ExifTag.GPSLatitudeRef, "N"); + exif.SetValue(ExifTag.GPSLatitude, new[] { new Rational(39, 1), new Rational(45, 1), new Rational(0, 1) }); + exif.SetValue(ExifTag.GPSLongitudeRef, "W"); + exif.SetValue(ExifTag.GPSLongitude, new[] { new Rational(104, 1), new Rational(59, 1), new Rational(0, 1) }); + image.Metadata.ExifProfile = exif; + + using var stream = new MemoryStream(); + image.Save(stream, new JpegEncoder()); + return stream.ToArray(); + } + private static byte[] ImageWithMetadata(Action> unused, string format) { using var image = new Image(6, 4); diff --git a/Tests/Resgrid.Tests/Rms/RecordDeploymentConnectorsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordDeploymentConnectorsServiceTests.cs new file mode 100644 index 00000000..6043a809 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordDeploymentConnectorsServiceTests.cs @@ -0,0 +1,431 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Services.Records.Connectors; +using static Resgrid.Tests.Rms.FakeOrderFeedProvider; +using static Resgrid.Tests.Rms.RmsDefinitionHarness; + +namespace Resgrid.Tests.Rms +{ + /// + /// External ordering-system connectors (RMS plan section 4.1): the gates a connector must pass before it may + /// exist or run, what an import may and may not do to the department's own record, and the reconciliation + /// that replaces every automatic update the plan forbids. + /// + [TestFixture] + public class RecordDeploymentConnectorsServiceTests + { + private RmsDefinitionHarness _h; + private bool _enabled; + private string _passphrase; + private int _disableAfter; + private int _minPoll; + + [SetUp] + public void SetUp() + { + _enabled = RecordsConnectorConfig.Enabled; _passphrase = RecordsConnectorConfig.CredentialPassphrase; _disableAfter = RecordsConnectorConfig.DisableAfterConsecutiveFailures; _minPoll = RecordsConnectorConfig.MinPollIntervalMinutes; + RecordsConnectorConfig.Enabled = true; + RecordsConnectorConfig.CredentialPassphrase = "unit-test-passphrase"; + RecordsConnectorConfig.MinPollIntervalMinutes = 15; + _h = new RmsDefinitionHarness(); + } + + [TearDown] + public void TearDown() + { + RecordsConnectorConfig.Enabled = _enabled; RecordsConnectorConfig.CredentialPassphrase = _passphrase; RecordsConnectorConfig.DisableAfterConsecutiveFailures = _disableAfter; RecordsConnectorConfig.MinPollIntervalMinutes = _minPoll; + } + + private static RecordDeploymentConnectorInput Input(Action configure = null) + { + var input = new RecordDeploymentConnectorInput + { + ProviderKey = RmsExternalOrderConnectorProviders.Generic, Name = "County ordering", SourceSystem = "County Orders", BaseUrl = "https://orders.example.gov/feed", + CredentialKind = RmsConnectorCredentialKinds.Bearer, Credential = "secret-token", ReadEnabled = true, PollIntervalMinutes = 30, MaxRequestsPerHour = 12, TermsReference = "https://orders.example.gov/terms" + }; + configure?.Invoke(input); + return input; + } + + /// A connector that has passed every gate and can run. + private async Task ReadyAsync(Action configure = null) + { + var created = await _h.Connectors.CreateAsync(Dept, Admin, Input(configure)); + await _h.Connectors.AcknowledgeTermsAsync(Dept, Admin, created.Connector.RmsExternalOrderConnectorId); + return await _h.Connectors.SetEnabledAsync(Dept, Admin, created.Connector.RmsExternalOrderConnectorId, true); + } + + #region Gates + + [Test] + public async Task Connector_management_is_department_administration_only() + { + Func create = () => _h.Connectors.CreateAsync(Dept, Author, Input()); + await create.Should().ThrowAsync(); + Func list = () => _h.Connectors.ListAsync(Dept, "viewer"); + await list.Should().ThrowAsync(); + Func reconcile = () => _h.Connectors.GetReconciliationAsync(Dept, Author); + await reconcile.Should().ThrowAsync(); + } + + [Test] + public async Task A_connector_cannot_exist_without_the_installation_switch_and_a_credential_passphrase() + { + RecordsConnectorConfig.Enabled = false; + Func off = () => _h.Connectors.CreateAsync(Dept, Admin, Input()); + await off.Should().ThrowAsync().WithMessage("*switched off*"); + + RecordsConnectorConfig.Enabled = true; + RecordsConnectorConfig.CredentialPassphrase = ""; + Func noPass = () => _h.Connectors.CreateAsync(Dept, Admin, Input()); + await noPass.Should().ThrowAsync().WithMessage("*passphrase*"); + } + + [Test] + public async Task Write_authority_plain_http_and_unknown_providers_are_refused() + { + Func write = () => _h.Connectors.CreateAsync(Dept, Admin, Input(i => i.WriteEnabled = true)); + await write.Should().ThrowAsync().WithMessage("*Write authority*"); + + Func http = () => _h.Connectors.CreateAsync(Dept, Admin, Input(i => i.BaseUrl = "http://orders.example.gov/feed")); + await http.Should().ThrowAsync().WithMessage("*https*"); + + Func provider = () => _h.Connectors.CreateAsync(Dept, Admin, Input(i => i.ProviderKey = "scraper")); + await provider.Should().ThrowAsync().WithMessage("*not a connector provider*"); + + Func header = () => _h.Connectors.CreateAsync(Dept, Admin, Input(i => { i.CredentialKind = RmsConnectorCredentialKinds.Header; i.CredentialHeaderName = null; })); + await header.Should().ThrowAsync().WithMessage("*header name*"); + + _h.Defs.Connectors.Should().BeEmpty(); + } + + [Test] + public async Task Creating_encrypts_the_credential_hashes_a_one_time_token_and_never_hands_either_back() + { + var created = await _h.Connectors.CreateAsync(Dept, Admin, Input(i => i.PollIntervalMinutes = 1)); + + created.InboundToken.Should().NotBeNullOrEmpty().And.HaveLength(43); + created.Connector.CredentialCiphertext.Should().Be("stored"); + created.Connector.InboundTokenHash.Should().Be("set"); + created.Connector.IsEnabled.Should().BeFalse(); + created.Connector.WriteEnabled.Should().BeFalse(); + created.Connector.PollIntervalMinutes.Should().Be(RecordsConnectorConfig.MinPollIntervalMinutes, "an interval below the floor is raised to it"); + + var stored = _h.Defs.Connectors.Single(); + stored.CredentialCiphertext.Should().NotBeNullOrEmpty().And.NotBe("secret-token").And.NotBe("stored"); + stored.InboundTokenHash.Should().Be(RecordDeploymentConnectorsService.Hash(created.InboundToken)).And.NotBe(created.InboundToken); + Resgrid.Framework.SymmetricEncryption.Decrypt(stored.CredentialCiphertext, RecordsConnectorConfig.CredentialPassphrase).Should().Be("secret-token"); + + (await _h.Connectors.GetAsync(Dept, Admin, stored.RmsExternalOrderConnectorId)).CredentialCiphertext.Should().Be("stored"); + _h.Store.Audits.Should().Contain(a => a.Purpose == "Create connector" && a.CorrelationId == stored.RmsExternalOrderConnectorId); + } + + [Test] + public async Task Enabling_needs_acknowledged_terms_read_authority_and_a_credential_where_the_kind_needs_one() + { + var id = (await _h.Connectors.CreateAsync(Dept, Admin, Input(i => i.Credential = null))).Connector.RmsExternalOrderConnectorId; + + Func unacknowledged = () => _h.Connectors.SetEnabledAsync(Dept, Admin, id, true); + await unacknowledged.Should().ThrowAsync().WithMessage("*terms*"); + + await _h.Connectors.AcknowledgeTermsAsync(Dept, Admin, id); + Func noCredential = () => _h.Connectors.SetEnabledAsync(Dept, Admin, id, true); + await noCredential.Should().ThrowAsync().WithMessage("*credential*"); + + var stored = _h.Defs.Connectors.Single(); + await _h.Connectors.UpdateAsync(Dept, Admin, id, stored.RowVersion, Input(i => i.Credential = "now-set")); + var enabled = await _h.Connectors.SetEnabledAsync(Dept, Admin, id, true); + enabled.IsEnabled.Should().BeTrue(); + enabled.IsReadyToRun.Should().BeTrue(); + enabled.TermsAcknowledgedByUserId.Should().Be(Admin); + + // Read authority withdrawn: the connector switches itself off rather than running without authority. + var withdrawn = await _h.Connectors.UpdateAsync(Dept, Admin, id, enabled.RowVersion, Input(i => i.ReadEnabled = false)); + withdrawn.IsEnabled.Should().BeFalse(); + withdrawn.IsReadyToRun.Should().BeFalse(); + } + + [Test] + public async Task Terms_are_acknowledged_for_a_source_so_pointing_elsewhere_needs_a_fresh_acknowledgement() + { + var connector = await ReadyAsync(); + var moved = await _h.Connectors.UpdateAsync(Dept, Admin, connector.RmsExternalOrderConnectorId, connector.RowVersion, Input(i => i.BaseUrl = "https://other.example.gov/feed")); + moved.TermsAcknowledgedOn.Should().BeNull(); + moved.IsEnabled.Should().BeFalse(); + + Func stale = () => _h.Connectors.UpdateAsync(Dept, Admin, connector.RmsExternalOrderConnectorId, connector.RowVersion, Input()); + await stale.Should().ThrowAsync(); + } + + #endregion + + #region Import + + [Test] + public async Task A_run_provisions_unseen_orders_with_their_requests_as_requested_fills_owned_by_the_connector() + { + var connector = await ReadyAsync(); + _h.Feed.Serve(Feed("v1", Order("O-1001", "open", Request("O-1"), Request("E-3", "requested", "equipment"), Request("C-9", "cancelled", "crew")))); + + var result = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + + result.Run.Outcome.Should().Be(RmsConnectorRunOutcomes.Ok, result.Run.Error); + result.Run.Trigger.Should().Be(RmsConnectorRunTriggers.Manual); + result.Run.OrdersSeen.Should().Be(1); result.Run.OrdersCreated.Should().Be(1); result.Run.RequestsAdded.Should().Be(2); result.Run.SnapshotsRecorded.Should().Be(0); + result.Run.SourceVersion.Should().Be("v1"); + _h.Feed.LastCredential.Should().Be("secret-token", "the provider receives the decrypted credential"); + + var order = _h.Defs.Orders.Single(); + order.OrderNumber.Should().Be("O-1001"); + order.SourceScheme.Should().Be("local"); + order.ProfileKey.Should().Be(RmsDeploymentProfiles.LocalMutualAid); + order.OwnershipMarker.Should().Be(RmsExternalOrderOwnership.Connector); + order.ConnectorId.Should().Be(connector.RmsExternalOrderConnectorId); + order.SourceVersion.Should().Be("v1"); + order.ArtifactContentType.Should().Be("application/json"); + order.ArtifactSafeUrl.Should().Be("https://orders.example.gov/O-1001"); + Encoding.UTF8.GetString(order.ArtifactData).Should().Contain("\"orderNumber\":\"O-1001\""); + + var fills = _h.Defs.Fills.Where(f => f.RmsExternalOrderId == order.RmsExternalOrderId).ToList(); + fills.Select(f => f.RequestNumber).Should().BeEquivalentTo(new[] { "O-1", "E-3" }, "a cancelled request is not a fill the department is asked for"); + fills.Should().OnlyContain(f => f.Status == (int)RmsDeploymentFillStatus.Requested); + + var stored = _h.Defs.Connectors.Single(); + stored.LastSuccessOn.Should().NotBeNull(); stored.LastPolledOn.Should().NotBeNull(); stored.RequestsThisHour.Should().Be(1); stored.ConsecutiveFailures.Should().Be(0); + _h.Defs.ConnectorRuns.Should().ContainSingle(r => r.Outcome == RmsConnectorRunOutcomes.Ok); + _h.Store.Audits.Should().Contain(a => a.Purpose.StartsWith("Import order O-1001") && a.ActorUserId == Admin); + } + + [Test] + public async Task An_unchanged_feed_records_nothing_and_a_changed_one_lands_as_a_new_snapshot_without_touching_local_fills() + { + var connector = await ReadyAsync(); + _h.Feed.Serve(Feed("v1", Order("O-1001", "open", Request("O-1")))); + await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + var order = _h.Defs.Orders.Single(); + var fill = _h.Defs.Fills.Single(); + await _h.Deployments.TransitionFillAsync(Dept, Admin, fill.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = RmsDeploymentFillStatus.Accepted }); + var firstChecksum = order.ArtifactChecksum; + + var same = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + same.Run.Unchanged.Should().Be(1); same.Run.SnapshotsRecorded.Should().Be(0); same.Run.OrdersCreated.Should().Be(0); same.Run.RequestsAdded.Should().Be(0); + _h.Defs.References.Should().BeEmpty("no snapshot was superseded"); + + // The source now says the request is mobilized and adds a second request. + _h.Feed.Serve(Feed("v2", Order("O-1001", "mobilized", Request("O-1", "mobilized"), Request("E-3", "requested", "equipment")))); + var changed = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + + changed.Run.SnapshotsRecorded.Should().Be(1); changed.Run.RequestsAdded.Should().Be(1); changed.Run.Conflicts.Should().Be(1); + var stored = _h.Defs.Orders.Single(); + stored.SourceVersion.Should().Be("v2"); + stored.ArtifactChecksum.Should().NotBe(firstChecksum); + _h.Defs.References.Should().ContainSingle(r => r.SemanticRole == "superseded-snapshot" && r.Checksum == firstChecksum, "the first snapshot stays as signed history"); + _h.Defs.Fills.Single(f => f.RequestNumber == "O-1").Status.Should().Be((int)RmsDeploymentFillStatus.Accepted, "the connector never transitions a local fill"); + _h.Defs.Fills.Single(f => f.RequestNumber == "E-3").Status.Should().Be((int)RmsDeploymentFillStatus.Requested); + + var items = await _h.Connectors.GetReconciliationAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + items.Should().ContainSingle(i => i.Kind == RecordDeploymentReconciliationItem.SourceStatusAhead && i.RequestNumber == "O-1" && i.SourceStatus == "mobilized" && i.LocalStatus == "filled"); + } + + [Test] + public async Task Reconciliation_lists_every_disagreement_and_applies_none_of_them() + { + var connector = await ReadyAsync(); + _h.Feed.Serve(Feed("v1", Order("O-1001", "open", Request("O-1"), Request("E-3", "requested", "equipment")))); + await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + var order = _h.Defs.Orders.Single(); + // The department adds a fill of its own and moves the first one along. + await _h.Deployments.AddFillAsync(Dept, Admin, order.RmsExternalOrderId, new RecordDeploymentFillInput { RequestNumber = "LOCAL-7", RequestCategory = "overhead", ResourceKind = "person", Position = "SOFR" }); + var first = _h.Defs.Fills.Single(f => f.RequestNumber == "O-1"); + await _h.Deployments.TransitionFillAsync(Dept, Admin, first.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = RmsDeploymentFillStatus.Accepted }); + await _h.Deployments.TransitionFillAsync(Dept, Admin, first.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = RmsDeploymentFillStatus.Mobilized }); + + // The source closes the order, still shows O-1 merely filled, and has forgotten E-3. + _h.Feed.Serve(Feed("v2", Order("O-1001", "closed", Request("O-1", "filled"), Request("N-4", "requested")))); + await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + + var items = await _h.Connectors.GetReconciliationAsync(Dept, Admin); + items.Select(i => i.Kind + ":" + (i.RequestNumber ?? "-")).Should().BeEquivalentTo( + RecordDeploymentReconciliationItem.SourceClosedLocalOpen + ":-", + RecordDeploymentReconciliationItem.SourceStatusBehind + ":O-1", + RecordDeploymentReconciliationItem.LocalFillMissingInSource + ":E-3", + RecordDeploymentReconciliationItem.LocalFillMissingInSource + ":LOCAL-7"); + items.Should().OnlyContain(i => i.OrderId == order.RmsExternalOrderId && i.RecordId == order.RecordId && i.ConnectorId == connector.RmsExternalOrderConnectorId); + + _h.Defs.Orders.Single().Status.Should().Be((int)RmsExternalOrderStatus.Mobilized, "a source closure never closes the local deployment"); + _h.Defs.Fills.Single(f => f.RequestNumber == "O-1").Status.Should().Be((int)RmsDeploymentFillStatus.Mobilized); + _h.Defs.Fills.Should().Contain(f => f.RequestNumber == "N-4" && f.Status == (int)RmsDeploymentFillStatus.Requested, "a request new to the department is the source's own fact"); + } + + [Test] + public async Task A_closed_out_deployment_is_signed_history_and_takes_no_further_snapshots() + { + var connector = await ReadyAsync(); + _h.Feed.Serve(Feed("v1", Order("O-1001", "open", Request("O-1")))); + await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + var order = _h.Defs.Orders.Single(); + var fill = _h.Defs.Fills.Single(); + foreach (var step in new[] { RmsDeploymentFillStatus.Accepted, RmsDeploymentFillStatus.Mobilized, RmsDeploymentFillStatus.CheckedIn, RmsDeploymentFillStatus.Released, RmsDeploymentFillStatus.Demobilized, RmsDeploymentFillStatus.Returned }) + await _h.Deployments.TransitionFillAsync(Dept, Admin, fill.RmsExternalOrderFillId, new RecordDeploymentFillTransitionInput { Status = step }); + await _h.Deployments.CloseoutAsync(Dept, Admin, order.RmsExternalOrderId, _h.Defs.Orders.Single().RowVersion, "done"); + _h.Defs.Orders.Single().Status.Should().Be((int)RmsExternalOrderStatus.ClosedOut); + + _h.Feed.Serve(Feed("v2", Order("O-1001", "closed", Request("O-1", "released"), Request("Z-1")))); + var result = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + + result.Run.Unchanged.Should().Be(1); result.Run.SnapshotsRecorded.Should().Be(0); result.Run.RequestsAdded.Should().Be(0); + _h.Defs.Orders.Single().SourceVersion.Should().Be("v1"); + } + + [Test] + public async Task Provider_rules_reject_an_order_that_is_not_a_fact_of_that_source() + { + var connector = await ReadyAsync(i => { i.ProviderKey = RmsExternalOrderConnectorProviders.Iroc; i.ProfileKey = null; i.SourceScheme = null; }); + connector.SourceScheme.Should().Be("iroc"); connector.ProfileKey.Should().Be(RmsDeploymentProfiles.UsWildland); + var noIncident = Order("O-2", "open", Request("O-1")); noIncident.IncidentNumber = null; + var noCategory = Order("O-3", "open", Request("O-1", "requested", null)); + _h.Feed.Serve(Feed("v1", Order("O-1", "open", Request("O-1")), noIncident, noCategory)); + + var result = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + + result.Run.Outcome.Should().Be(RmsConnectorRunOutcomes.Ok, result.Run.Error); + result.Run.OrdersSeen.Should().Be(3); result.Run.OrdersCreated.Should().Be(1); result.Run.Rejected.Should().Be(2); + result.Messages.Should().Contain(m => m.StartsWith("Order O-2:") && m.Contains("incident number")).And.Contain(m => m.StartsWith("Order O-3:") && m.Contains("category")); + _h.Defs.Orders.Single().OrderNumber.Should().Be("O-1"); + _h.Defs.Orders.Single().SourceScheme.Should().Be("iroc"); + } + + [Test] + public async Task A_feed_that_breaks_the_contract_is_rejected_whole_and_counted_as_a_failure_of_the_run_not_the_connector() + { + var connector = await ReadyAsync(); + _h.Feed.ServeRaw("{\"contract\":\"somebody-elses.v9\",\"orders\":[]}"); + var result = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + result.Run.Outcome.Should().Be(RmsConnectorRunOutcomes.Rejected); + result.Run.Error.Should().Contain("contract"); + _h.Defs.Orders.Should().BeEmpty(); + _h.Defs.Connectors.Single().IsEnabled.Should().BeTrue(); + } + + [Test] + public async Task The_cursor_is_followed_page_by_page_and_remembered_for_the_next_run() + { + var connector = await ReadyAsync(); + var page1 = Feed("v1", Order("O-1", "open", Request("O-1"))); page1.Cursor = "p2"; + var page2 = Feed("v1", Order("O-2", "open", Request("O-1"))); page2.Cursor = null; + _h.Feed.Serve(page1); _h.Feed.Serve(page2, "p2"); + + var result = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + + result.Run.RequestCount.Should().Be(2); result.Run.OrdersCreated.Should().Be(2); + _h.Feed.CursorsSeen.Should().Equal(null, "p2"); + _h.Defs.Connectors.Single().LastCursor.Should().BeNull("the last page ended the cursor"); + } + + #endregion + + #region Limits and failure + + [Test] + public async Task The_hourly_request_limit_stops_a_run_and_polls_and_pushes_share_it() + { + var connector = await ReadyAsync(i => i.MaxRequestsPerHour = 2); + _h.Feed.Serve(Feed("v1", Order("O-1", "open", Request("O-1")))); + (await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId)).Run.Outcome.Should().Be(RmsConnectorRunOutcomes.Ok); + + var token = await _h.Connectors.RotateInboundTokenAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + var pushed = await _h.Connectors.ImportInboundAsync(connector.RmsExternalOrderConnectorId, token, Newtonsoft.Json.JsonConvert.SerializeObject(Feed("v2", Order("O-1", "open", Request("O-1"), Request("O-2"))))); + pushed.Run.Outcome.Should().Be(RmsConnectorRunOutcomes.Ok); + pushed.Run.Trigger.Should().Be(RmsConnectorRunTriggers.Inbound); + pushed.Run.RequestsAdded.Should().Be(1); + + var limited = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + limited.Run.Outcome.Should().Be(RmsConnectorRunOutcomes.RateLimited); + _h.Feed.Fetches.Should().Be(1, "a spent window refuses before touching the source"); + + // An hour later the window rolls over. + var stored = _h.Defs.Connectors.Single(); + stored.RateWindowStartedOn = DateTime.UtcNow.AddHours(-2); + (await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId)).Run.Outcome.Should().Be(RmsConnectorRunOutcomes.Ok); + _h.Defs.Connectors.Single().RequestsThisHour.Should().Be(1); + } + + [Test] + public async Task An_inbound_push_needs_the_connectors_token_and_learns_nothing_from_a_refusal() + { + var connector = await ReadyAsync(); + var feed = Newtonsoft.Json.JsonConvert.SerializeObject(Feed("v1", Order("O-1", "open", Request("O-1")))); + + Func wrong = () => _h.Connectors.ImportInboundAsync(connector.RmsExternalOrderConnectorId, "not-the-token", feed); + await wrong.Should().ThrowAsync(); + Func unknown = () => _h.Connectors.ImportInboundAsync(Guid.NewGuid().ToString(), "anything", feed); + await unknown.Should().ThrowAsync(); + _h.Defs.ConnectorRuns.Should().BeEmpty("a refused push leaves no trace a caller could probe"); + + var token = await _h.Connectors.RotateInboundTokenAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + (await _h.Connectors.ImportInboundAsync(connector.RmsExternalOrderConnectorId, token, feed)).Run.OrdersCreated.Should().Be(1); + _h.Defs.Orders.Single().CreatedByUserId.Should().Be(Admin, "a push acts under the administrator who authorized the connector"); + + // A disabled connector refuses pushes too, and a deleted one loses its token entirely. + await _h.Connectors.SetEnabledAsync(Dept, Admin, connector.RmsExternalOrderConnectorId, false); + (await _h.Connectors.ImportInboundAsync(connector.RmsExternalOrderConnectorId, token, feed)).Run.Outcome.Should().Be(RmsConnectorRunOutcomes.Disabled); + await _h.Connectors.DeleteAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + Func deleted = () => _h.Connectors.ImportInboundAsync(connector.RmsExternalOrderConnectorId, token, feed); + await deleted.Should().ThrowAsync(); + _h.Defs.Connectors.Single().CredentialCiphertext.Should().BeNull(); + (await _h.Connectors.ListAsync(Dept, Admin)).Should().BeEmpty(); + } + + [Test] + public async Task Repeated_failures_switch_the_connector_off_until_an_administrator_looks() + { + RecordsConnectorConfig.DisableAfterConsecutiveFailures = 2; + var connector = await ReadyAsync(); + _h.Feed.Throw = new InvalidOperationException("The source answered 503 Service Unavailable."); + + var first = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + first.Run.Outcome.Should().Be(RmsConnectorRunOutcomes.Failed); + _h.Defs.Connectors.Single().ConsecutiveFailures.Should().Be(1); + _h.Defs.Connectors.Single().IsEnabled.Should().BeTrue(); + + var second = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + second.Messages.Should().Contain(m => m.Contains("disabled")); + var stored = _h.Defs.Connectors.Single(); + stored.IsEnabled.Should().BeFalse(); + stored.LastError.Should().Contain("503"); + + var third = await _h.Connectors.RunAsync(Dept, Admin, connector.RmsExternalOrderConnectorId); + third.Run.Outcome.Should().Be(RmsConnectorRunOutcomes.Disabled); + _h.Feed.Fetches.Should().Be(2); + + // Re-enabling clears the failure count so the next honest failure starts a new run. + (await _h.Connectors.SetEnabledAsync(Dept, Admin, connector.RmsExternalOrderConnectorId, true)).ConsecutiveFailures.Should().Be(0); + } + + [Test] + public async Task The_poll_sweep_runs_only_connectors_that_are_ready_and_due() + { + var ready = await ReadyAsync(); + var dormant = (await _h.Connectors.CreateAsync(Dept, Admin, Input(i => i.Name = "Dormant"))).Connector; + _h.Feed.Serve(Feed("v1", Order("O-1", "open", Request("O-1")))); + + (await _h.Connectors.RunDueAsync()).Should().Be(1); + _h.Defs.ConnectorRuns.Should().ContainSingle(r => r.RmsExternalOrderConnectorId == ready.RmsExternalOrderConnectorId && r.Trigger == RmsConnectorRunTriggers.Poll && r.TriggeredByUserId == null); + _h.Defs.ConnectorRuns.Should().NotContain(r => r.RmsExternalOrderConnectorId == dormant.RmsExternalOrderConnectorId); + + (await _h.Connectors.RunDueAsync()).Should().Be(0, "the interval has not elapsed since the last poll"); + + RecordsConnectorConfig.Enabled = false; + _h.Defs.Connectors.Single(c => c.RmsExternalOrderConnectorId == ready.RmsExternalOrderConnectorId).LastPolledOn = DateTime.UtcNow.AddHours(-3); + (await _h.Connectors.RunDueAsync()).Should().Be(0, "the installation switch stops every poll"); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordTemplateCatalogTests.cs b/Tests/Resgrid.Tests/Rms/RecordTemplateCatalogTests.cs index 984a8199..016b480d 100644 --- a/Tests/Resgrid.Tests/Rms/RecordTemplateCatalogTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordTemplateCatalogTests.cs @@ -22,7 +22,7 @@ public class RecordTemplateCatalogTests public async Task Every_template_validates_under_every_supported_profile_and_locale() { var catalog = await _h.Templates.GetCatalogAsync(); - catalog.Select(p => p.PackKey).Should().BeEquivalentTo(new[] { "template.launch", "pack.cert", "pack.sar", "pack.disaster-assessment", "pack.eoc", "pack.hazmat", "pack.industrial", "pack.exercise", "pack.mutual-aid" }); + catalog.Select(p => p.PackKey).Should().BeEquivalentTo(new[] { "template.launch", "pack.cert", "pack.sar", "pack.disaster-assessment", "pack.eoc", "pack.hazmat", "pack.industrial", "pack.exercise", "pack.mutual-aid", "pack.incident-support" }); catalog.Single(p => p.PackKey == "template.launch").Definitions.Select(d => d.Key).Should().BeEquivalentTo(new[] { "template.security-patrol", "template.security-incident", "template.delivery-run", "template.bus-route-eod", "template.shift-summary", "template.job-completion" @@ -81,7 +81,7 @@ public async Task Overlays_relabel_convert_units_and_set_currency_without_touchi public async Task Preview_packs_are_labeled_and_locked_classification_floors_apply() { var catalog = await _h.Templates.GetCatalogAsync(); - catalog.Where(p => p.IsPreview).Select(p => p.PackKey).Should().BeEquivalentTo(new[] { "pack.cert", "pack.mutual-aid" }); + catalog.Where(p => p.IsPreview).Select(p => p.PackKey).Should().BeEquivalentTo(new[] { "pack.cert", "pack.mutual-aid", "pack.incident-support" }); catalog.Single(p => p.PackKey == "pack.hazmat").IsPreview.Should().BeFalse(); var incident = await _h.Templates.RenderAsync("template.security-incident", "generic", null); incident.Schema.FindField("name").Classification.Should().Be(RmsFieldClassification.Restricted, "involved-person names carry the pack's restricted floor"); @@ -141,7 +141,7 @@ public async Task Catalog_mirrors_to_product_scope_rows_once() var written = await _h.Templates.EnsureCatalogAsync(); written.Should().BeGreaterThan(0); _h.Defs.Packs.Should().HaveCount(RecordTemplateCatalog.Packs.Count).And.OnlyContain(p => p.DepartmentId == RmsTemplatePackVersion.ProductDepartmentId && p.ContentChecksum != null); - _h.Defs.Profiles.Select(p => p.ProfileKey).Should().BeEquivalentTo(new[] { "generic", "us", "ca", "us-ca" }); + _h.Defs.Profiles.Select(p => p.ProfileKey).Should().BeEquivalentTo(new[] { "generic", "us", "ca", "us-ca", "us-nwcg", "us-nims", "us-calif" }); (await _h.Templates.EnsureCatalogAsync()).Should().Be(0, "a second call is a no-op within the process"); var profiles = await _h.Templates.GetProfilesAsync(); profiles.Single(p => p.ProfileKey == "us").CurrencyCode.Should().Be("USD"); diff --git a/Tests/Resgrid.Tests/Rms/RecordsFieldRolloutServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsFieldRolloutServiceTests.cs new file mode 100644 index 00000000..a8be8e48 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsFieldRolloutServiceTests.cs @@ -0,0 +1,202 @@ +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.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// Per-app Field Records rollout telemetry and its dashboard (RMS plan RMS-1D). What is under test is that a + /// client cannot report itself into someone else's numbers, that only coded outcomes are stored, and that the + /// dashboard is department-administration only. + /// + [TestFixture] + public class RecordsFieldRolloutServiceTests + { + private const int Dept = 9; + private const string Admin = "admin"; + private const string Member = "member"; + + private List _stored; + private Mock _events; + private Mock _authorization; + private Mock _records; + private Mock _flags; + private RecordsFieldRolloutService _service; + private string _minimumResponder; + + [SetUp] + public void SetUp() + { + _minimumResponder = RecordsFieldConfig.MinimumResponderVersion; + _stored = new List(); + _events = new Mock(); + _events.Setup(e => e.InsertBatchAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable rows, CancellationToken c) => { _stored.AddRange(rows); return _stored.Count; }); + _events.Setup(e => e.GetForWindowAsync(Dept, It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, DateTime since, int take) => _stored.Where(row => row.OccurredOn >= since).ToList()); + + _authorization = new Mock(); + _authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.IsDepartmentAdminAsync(Admin, Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.IsDepartmentAdminAsync(Member, Dept)).ReturnsAsync(false); + + _records = new Mock(); + _records.Setup(r => r.GetCreatedSinceAsync(Dept, It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _records.Setup(r => r.GetFinalizedSinceAsync(Dept, It.IsAny())).ReturnsAsync(new List()); + + _flags = new Mock(); + _flags.Setup(f => f.IsEnabledAsync(It.IsAny(), Dept, It.IsAny(), It.IsAny>())).ReturnsAsync(true); + + _service = new RecordsFieldRolloutService(_events.Object, _authorization.Object, _records.Object, _flags.Object); + } + + [TearDown] + public void TearDown() => RecordsFieldConfig.MinimumResponderVersion = _minimumResponder; + + private static RecordFieldRolloutBatch Batch(params RecordFieldRolloutInput[] events) => new RecordFieldRolloutBatch + { + OriginClient = RmsOriginClient.Responder, + AppVersion = "5.4.0", + ClientCapability = "records.v1c", + Events = events.ToList() + }; + + [Test] + public async Task Only_a_field_app_and_an_active_member_may_report_and_unknown_events_are_dropped() + { + var accepted = await _service.RecordBatchAsync(Dept, Member, Batch( + new RecordFieldRolloutInput { EventType = RmsFieldRolloutEventTypes.Catalog, Outcome = "ok" }, + new RecordFieldRolloutInput { EventType = "exfiltrate", Outcome = "here is the narrative" })); + + accepted.Should().Be(1); + _stored.Should().ContainSingle(); + _stored[0].UserId.Should().Be(Member, "the server stamps the member; a client never names one"); + _stored[0].DepartmentId.Should().Be(Dept); + + _stored.Clear(); + var web = Batch(new RecordFieldRolloutInput { EventType = RmsFieldRolloutEventTypes.Catalog }); + web.OriginClient = RmsOriginClient.Web; + (await _service.RecordBatchAsync(Dept, Member, web)).Should().Be(0, "a non-field origin has no rollout to report"); + + _authorization.Setup(a => a.IsActiveMemberAsync("stranger", Dept)).ReturnsAsync(false); + (await _service.RecordBatchAsync(Dept, "stranger", Batch(new RecordFieldRolloutInput { EventType = RmsFieldRolloutEventTypes.Sync }))).Should().Be(0); + _stored.Should().BeEmpty(); + } + + [Test] + public async Task A_wrong_clock_and_an_oversized_batch_cannot_move_rows_outside_the_window() + { + var future = new RecordFieldRolloutInput { EventType = RmsFieldRolloutEventTypes.Sync, OccurredOn = DateTime.UtcNow.AddDays(30) }; + var ancient = new RecordFieldRolloutInput { EventType = RmsFieldRolloutEventTypes.Sync, OccurredOn = DateTime.UtcNow.AddYears(-5) }; + + await _service.RecordBatchAsync(Dept, Member, Batch(future, ancient)); + + _stored.Should().HaveCount(2); + _stored.Should().OnlyContain(row => row.OccurredOn <= DateTime.UtcNow.AddMinutes(1) && row.OccurredOn >= DateTime.UtcNow.AddDays(-RecordsFieldRolloutService.MaxWindowDays).AddMinutes(-1)); + + _stored.Clear(); + var oversized = Batch(Enumerable.Range(0, RecordFieldRolloutBatch.MaxEvents + 50).Select(_ => new RecordFieldRolloutInput { EventType = RmsFieldRolloutEventTypes.Sync }).ToArray()); + await _service.RecordBatchAsync(Dept, Member, oversized); + _stored.Should().HaveCount(RecordFieldRolloutBatch.MaxEvents); + } + + [Test] + public async Task The_dashboard_is_department_administration_only() + { + Func denied = () => _service.GetAsync(Dept, Member); + await denied.Should().ThrowAsync(); + + var rollout = await _service.GetAsync(Dept, Admin); + rollout.Apps.Select(app => app.OriginClient).Should().BeEquivalentTo(new[] { "Responder", "Unit", "IncidentCommand", "Dispatch" }); + rollout.WindowDays.Should().Be(30); + rollout.AnyAppEnabled.Should().BeTrue(); + } + + [Test] + public void Summarize_counts_outcomes_versions_and_the_median_time_to_complete() + { + var now = DateTime.UtcNow; + var events = new List + { + Event("a", "5.2.0", RmsFieldRolloutEventTypes.Catalog, "ok", now.AddHours(-5)), + Event("a", "5.4.0", RmsFieldRolloutEventTypes.Catalog, "ok", now.AddHours(-1)), + Event("b", "5.1.0", RmsFieldRolloutEventTypes.Catalog, "app_version_too_old", now.AddHours(-2)), + Event("b", "5.1.0", RmsFieldRolloutEventTypes.Catalog, "app_version_too_old", now.AddHours(-2)), + Event("a", "5.4.0", RmsFieldRolloutEventTypes.DraftStarted, "ok", now), + Event("a", "5.4.0", RmsFieldRolloutEventTypes.DraftStarted, "ok", now), + Event("a", "5.4.0", RmsFieldRolloutEventTypes.DraftSaved, "ok", now), + Event("a", "5.4.0", RmsFieldRolloutEventTypes.DraftSaved, "etag", now), + Event("a", "5.4.0", RmsFieldRolloutEventTypes.Conflict, "etag", now), + Event("a", "5.4.0", RmsFieldRolloutEventTypes.Attachment, "ok", now), + Event("a", "5.4.0", RmsFieldRolloutEventTypes.Attachment, "too_large", now), + Event("a", "5.4.0", RmsFieldRolloutEventTypes.Abandoned, "ok", now), + Event("a", "5.4.0", RmsFieldRolloutEventTypes.WebHandoff, "ok", now), + Completed("a", "5.4.0", 60_000, now), + Completed("a", "5.4.0", 180_000, now), + Completed("a", "5.4.0", 240_000, now), + }; + + var summary = RecordsFieldRolloutService.Summarize(RmsOriginClient.Responder, events, "5.2.0"); + + summary.ActiveUsers.Should().Be(2); + summary.CompatibleUsers.Should().Be(1, "each person counts on the version they are actually running now"); + summary.CatalogRequests.Should().Be(4); + summary.CatalogFailures.Should().Be(2); + summary.CatalogFailureReasons["app_version_too_old"].Should().Be(2); + summary.DraftsStarted.Should().Be(2); + summary.DraftsSaved.Should().Be(1); + summary.DraftSaveFailures.Should().Be(1); + summary.Conflicts.Should().Be(1); + summary.ConflictKinds["etag"].Should().Be(1); + summary.AttachmentsUploaded.Should().Be(1); + summary.AttachmentFailures.Should().Be(1); + summary.Completed.Should().Be(3); + summary.MedianTimeToCompleteMs.Should().Be(180_000); + summary.Abandoned.Should().Be(1); + summary.AbandonmentRate.Should().Be(0.5); + summary.WebHandoffs.Should().Be(1); + summary.Versions.Should().Contain(version => version.AppVersion == "5.4.0" && version.Users == 1); + } + + [Test] + public void An_app_that_reports_nothing_still_shows_no_numbers_rather_than_wrong_ones() + { + var summary = RecordsFieldRolloutService.Summarize(RmsOriginClient.Dispatch, new List(), "1.0.0"); + + summary.ActiveUsers.Should().Be(0); + summary.CompatibleUsers.Should().Be(0); + summary.AbandonmentRate.Should().BeNull("a rate over nothing is not zero"); + summary.MedianTimeToCompleteMs.Should().BeNull(); + } + + private static RmsFieldRolloutEvent Event(string userId, string version, string type, string outcome, DateTime occurredOn) => new RmsFieldRolloutEvent + { + RmsFieldRolloutEventId = Guid.NewGuid().ToString(), + DepartmentId = Dept, + OriginClient = (int)RmsOriginClient.Responder, + UserId = userId, + AppVersion = version, + EventType = type, + Outcome = outcome, + OccurredOn = occurredOn, + RecordedOn = occurredOn + }; + + private static RmsFieldRolloutEvent Completed(string userId, string version, long durationMs, DateTime occurredOn) + { + var entry = Event(userId, version, RmsFieldRolloutEventTypes.Completed, "ok", occurredOn); + entry.DurationMs = durationMs; + return entry; + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs b/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs index c6f3586f..abb84f1b 100644 --- a/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs @@ -74,6 +74,13 @@ public void Records_services_resolve_from_the_container() Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + // RMS-1C external ordering-system connectors (2026-09-06) + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve>().Select(p => p.Key).Should().BeEquivalentTo(RmsExternalOrderConnectorProviders.All); Resolve>().Should().Contain(a => a.Kind == RmsEvidenceKind.ModuleProjection); Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); diff --git a/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs b/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs index 34a51968..357713e3 100644 --- a/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs +++ b/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs @@ -49,6 +49,8 @@ public sealed class RmsDefinitionHarness public RecordsService Records { get; } public RecordSavedReportsService Reports { get; } public RecordDeploymentsService Deployments { get; } + public FakeOrderFeedProvider Feed { get; } = new FakeOrderFeedProvider(); + public Resgrid.Services.Records.Connectors.RecordDeploymentConnectorsService Connectors { get; } public RmsDefinitionHarness() { @@ -56,6 +58,7 @@ public RmsDefinitionHarness() Defs = new FakeRmsDefinitionStore(Store); Authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + Authorization.Setup(a => a.IsDepartmentAdminAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string u, int d) => u == Admin && d == Dept); Authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); Authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); Authorization.Setup(a => a.CanReadSourceCallAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); @@ -97,7 +100,9 @@ public RmsDefinitionHarness() Store.AuditsRepo.Object, Outbox, Cutover.Object, Settings.Object, Groups.Object, Profiles.Object, Units.Object, Calls.Object, Adp.Object, Store.UnitOfWork.Object, OutboundQueue.Object, new NullRecordAttachmentScanner(), Authorization.Object, Mock.Of(), Protection, Definitions, TypedValues, Roles.Object); Reports = new RecordSavedReportsService(Defs.ReportsRepo.Object, Definitions, Records, Store.RecordsRepo.Object, Defs.ValuesRepo.Object, Defs.GroupsRepo.Object, Authorization.Object, Store.AuditsRepo.Object); - Deployments = new RecordDeploymentsService(Defs.OrdersRepo.Object, Defs.FillsRepo.Object, Defs.ReferencesRepo.Object, Records, Definitions, Templates, Authorization.Object, Store.AuditsRepo.Object, Store.UnitOfWork.Object); + Deployments = new RecordDeploymentsService(Defs.OrdersRepo.Object, Defs.FillsRepo.Object, Defs.ReferencesRepo.Object, Records, Store.RecordsRepo.Object, Definitions, Templates, Authorization.Object, Store.AuditsRepo.Object, Store.UnitOfWork.Object); + Connectors = new Resgrid.Services.Records.Connectors.RecordDeploymentConnectorsService(Defs.ConnectorsRepo.Object, Defs.ConnectorRunsRepo.Object, Defs.OrdersRepo.Object, Defs.FillsRepo.Object, + Deployments, Authorization.Object, Store.AuditsRepo.Object, new IExternalOrderFeedProvider[] { Feed, new FakeOrderFeedProvider(RmsExternalOrderConnectorProviders.Iroc, "iroc", RmsDeploymentProfiles.UsWildland, new Resgrid.Services.Records.Connectors.IrocOrderFeedProvider().ValidateOrder, Feed) }); } /// Creates a blank department definition and replaces the starter schema with in draft v1. diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index 3ca23bd5..d94b5059 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -102,6 +102,8 @@ public void Rms_worker_command_ids_are_the_registry_values() program.Should().Contain("new Commands.RecordsSearchIndexCommand(44)"); // RMS-3e (2026-09-05): department report exports on a schedule. program.Should().Contain("new Commands.RmsScheduledExportCommand(45)"); + // RMS-1C connectors (2026-09-06): the external order feed poll took the last free ID of the released pair. + program.Should().Contain("new Commands.RmsConnectorPollCommand(46)"); } [Test] diff --git a/Tests/Resgrid.Tests/Web/Services/FieldRecordsApiControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/FieldRecordsApiControllerTests.cs index 3d07c2f3..e4dd2487 100644 --- a/Tests/Resgrid.Tests/Web/Services/FieldRecordsApiControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/FieldRecordsApiControllerTests.cs @@ -32,6 +32,7 @@ public class FieldRecordsApiControllerTests private Mock _field; private Mock _assignments; + private Mock _rollout; private FieldRecordsController _controller; private DefaultHttpContext _http; private Activity _activity; @@ -53,7 +54,8 @@ public void SetUp() }; ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = _http }; _activity = new Activity(nameof(FieldRecordsApiControllerTests)).Start(); - _controller = new FieldRecordsController(_field.Object, _assignments.Object) { ControllerContext = new ControllerContext { HttpContext = _http } }; + _rollout = new Mock(); + _controller = new FieldRecordsController(_field.Object, _assignments.Object, _rollout.Object) { ControllerContext = new ControllerContext { HttpContext = _http } }; } [TearDown] diff --git a/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs index eed04a93..49d26619 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/FieldRecordsController.cs @@ -31,11 +31,13 @@ public class FieldRecordsController : V4AuthenticatedApiControllerbase { private readonly IFieldRecordsService _field; private readonly IRecordWorkAssignmentsService _assignments; + private readonly IRecordsFieldRolloutService _rollout; - public FieldRecordsController(IFieldRecordsService field, IRecordWorkAssignmentsService assignments) + public FieldRecordsController(IFieldRecordsService field, IRecordWorkAssignmentsService assignments, IRecordsFieldRolloutService rollout) { _field = field; _assignments = assignments; + _rollout = rollout; } #region Preflight and catalog @@ -230,6 +232,61 @@ public async Task> CancelAssignment([F #endregion + #region Rollout telemetry + + /// + /// Safe rollout datapoints from this app (RMS plan RMS-1D): coded outcomes, counts and durations against the + /// authenticated department and member. Nothing here carries record content, and a report that names an + /// unknown event or a non-field origin is dropped rather than stored. + /// + [HttpPost("Telemetry")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Telemetry([FromBody] FieldRecordTelemetryInput input, CancellationToken cancellationToken) + { + if (input == null || input.Events == null || input.Events.Count == 0) + return BadRequest(); + + var accepted = await _rollout.RecordBatchAsync(DepartmentId, UserId, new RecordFieldRolloutBatch + { + OriginClient = Origin(input.OriginClient), + AppVersion = AppVersion(input.AppVersion), + ClientCapability = input.ClientCapability, + Events = input.Events.Select(e => new RecordFieldRolloutInput + { + EventType = e.EventType, Outcome = e.Outcome, DefinitionKey = e.DefinitionKey, DefinitionVersion = e.DefinitionVersion, + RecordId = e.RecordId, DurationMs = e.DurationMs, ItemCount = e.ItemCount, OccurredOn = e.OccurredOn + }).ToList() + }, cancellationToken); + + var result = new FieldRecordTelemetryResult { Data = new FieldRecordTelemetryData { Accepted = accepted }, Status = ResponseHelper.Success, PageSize = accepted }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// The per-app rollout dashboard for this department (RMS plan RMS-1D). Department administration only. + [HttpGet("Rollout")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Rollout(int windowDays = 30, CancellationToken cancellationToken = default) + { + try + { + var rollout = await _rollout.GetAsync(DepartmentId, UserId, windowDays, cancellationToken); + var result = new FieldRecordRolloutResult { Data = rollout, Status = ResponseHelper.Success, PageSize = rollout.Apps.Count }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + } + + #endregion + #region Helpers private async Task> CommandAsync(Func> action) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentConnectorsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentConnectorsController.cs new file mode 100644 index 00000000..f8cd7304 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentConnectorsController.cs @@ -0,0 +1,325 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// External ordering-system connectors for mutual-aid deployments (RMS plan section 4.1, RMS-1C). Department + /// administration only, enforced here and again in the service. A connector reads a documented feed under an + /// encrypted credential, an hourly request limit, and an acknowledgement of the source's terms; write authority + /// is refused. Import provisions unseen orders and records changed ones as new versioned snapshots; it never + /// transitions a local fill — disagreements are listed by Reconciliation for a person. + /// The Inbound action is the one anonymous endpoint: a source pushes a feed document with the connector's + /// one-time token in X-Resgrid-Connector-Token; the server holds only a hash of that token. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public class RecordDeploymentConnectorsController : V4AuthenticatedApiControllerbase + { + public const string TokenHeader = "X-Resgrid-Connector-Token"; + + private readonly IRecordDeploymentConnectorsService _connectors; + private readonly IRecordsCutoverService _cutoverService; + + public RecordDeploymentConnectorsController(IRecordDeploymentConnectorsService connectors, IRecordsCutoverService cutoverService) + { + _connectors = connectors; + _cutoverService = cutoverService; + } + + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> List() + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + var result = new RecordDeploymentConnectorsResult { Status = ResponseHelper.Success, Data = (await _connectors.ListAsync(DepartmentId, UserId)).Select(ToData).ToList() }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Get")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Get(string id) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + var connector = await _connectors.GetAsync(DepartmentId, UserId, id); + if (connector == null) return NotFound(); + return Ok(Wrap(connector)); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Create")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Create([FromBody] RecordDeploymentConnectorInputData input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + var created = await _connectors.CreateAsync(DepartmentId, UserId, ToInput(input), cancellationToken); + var result = new RecordDeploymentConnectorCreatedResult { Status = ResponseHelper.Created, PageSize = 1, Data = new RecordDeploymentConnectorCreatedData { Connector = ToData(created.Connector), InboundToken = created.InboundToken } }; + ResponseHelper.PopulateV4ResponseData(result); + return StatusCode(StatusCodes.Status201Created, result); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Update")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Update(string id, [FromBody] RecordDeploymentConnectorInputData input, CancellationToken cancellationToken) + { + if (input == null) return BadRequest(); + var gate = await GateAsync(); + if (gate != null) return gate; + try { return Ok(Wrap(await _connectors.UpdateAsync(DepartmentId, UserId, id, input.RowVersion, ToInput(input), cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("SetEnabled")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> SetEnabled(string id, bool enabled, CancellationToken cancellationToken) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try { return Ok(Wrap(await _connectors.SetEnabledAsync(DepartmentId, UserId, id, enabled, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("AcknowledgeTerms")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> AcknowledgeTerms(string id, CancellationToken cancellationToken) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try { return Ok(Wrap(await _connectors.AcknowledgeTermsAsync(DepartmentId, UserId, id, cancellationToken))); } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("RotateToken")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> RotateToken(string id, CancellationToken cancellationToken) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + var token = await _connectors.RotateInboundTokenAsync(DepartmentId, UserId, id, cancellationToken); + var connector = await _connectors.GetAsync(DepartmentId, UserId, id); + var result = new RecordDeploymentConnectorTokenResult { Status = ResponseHelper.Success, PageSize = 1, Data = new RecordDeploymentConnectorCreatedData { Connector = ToData(connector), InboundToken = token } }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpDelete("Delete")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Delete(string id, CancellationToken cancellationToken) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + await _connectors.DeleteAsync(DepartmentId, UserId, id, cancellationToken); + var result = new StandardApiResponseV4Base { Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpPost("Run")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task> Run(string id, CancellationToken cancellationToken) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + var run = await _connectors.RunAsync(DepartmentId, UserId, id, cancellationToken); + var result = new RecordDeploymentConnectorRunApiResult { Status = ResponseHelper.Success, PageSize = 1, Data = ToData(run.Run, run.Messages) }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Runs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Runs(string id, int take = 50) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + var result = new RecordDeploymentConnectorRunsResult { Status = ResponseHelper.Success, Data = (await _connectors.GetRunsAsync(DepartmentId, UserId, id, take)).Select(r => ToData(r, null)).ToList() }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + [HttpGet("Reconciliation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task> Reconciliation(string id = null) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + var items = await _connectors.GetReconciliationAsync(DepartmentId, UserId, id); + var result = new RecordDeploymentReconciliationResult + { + Status = ResponseHelper.Success, + Data = items.Select(i => new RecordDeploymentReconciliationData + { + ConnectorId = i.ConnectorId, OrderId = i.OrderId, RecordId = i.RecordId, OrderNumber = i.OrderNumber, RequestNumber = i.RequestNumber, Kind = i.Kind, + SourceStatus = i.SourceStatus, LocalStatus = i.LocalStatus, SourceVersion = i.SourceVersion, SourceCapturedOn = i.SourceCapturedOn + }).ToList() + }; + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (Exception ex) { return Fail(ex); } + } + + /// + /// A source pushes a feed document. No user session: the connector id is in the query and the connector's + /// token in X-Resgrid-Connector-Token. A wrong token and an unknown connector are refused alike, and a + /// refused push leaves no run row, so the endpoint reveals nothing about what exists. + /// + [HttpPost("Inbound")] + [AllowAnonymous] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + public async Task> Inbound(string connectorId, CancellationToken cancellationToken) + { + if (!RecordsConnectorConfig.Enabled) return NotFound(); + if (!Request.Headers.TryGetValue(TokenHeader, out var tokenValues) || string.IsNullOrWhiteSpace(tokenValues.ToString())) return Unauthorized(); + if (Request.ContentLength.HasValue && Request.ContentLength.Value > RecordsConnectorConfig.MaxFeedBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge); + + string body; + using (var buffer = new MemoryStream()) + { + var chunk = new byte[64 * 1024]; + int read; + while ((read = await Request.Body.ReadAsync(chunk, 0, chunk.Length, cancellationToken)) > 0) + { + buffer.Write(chunk, 0, read); + if (buffer.Length > RecordsConnectorConfig.MaxFeedBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge); + } + body = Encoding.UTF8.GetString(buffer.ToArray()); + } + + try + { + var run = await _connectors.ImportInboundAsync(connectorId, tokenValues.ToString().Trim(), body, cancellationToken); + var result = new RecordDeploymentConnectorRunApiResult { Status = ResponseHelper.Success, PageSize = 1, Data = ToData(run.Run, run.Messages) }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + catch (UnauthorizedAccessException) { return Unauthorized(); } + } + + #region Helpers + + private async Task GateAsync() + { + if (!(await _cutoverService.GetModuleStateAsync(DepartmentId)).FlagEnabled) return NotFound(); + return ClaimsAuthorizationHelper.IsUserDepartmentAdmin() ? null : Forbid(); + } + + private RecordDeploymentConnectorResult Wrap(RmsExternalOrderConnector connector) + { + var result = new RecordDeploymentConnectorResult { Data = ToData(connector), Status = ResponseHelper.Success, PageSize = 1 }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ActionResult Fail(Exception ex) + { + switch (ex) + { + case UnauthorizedAccessException _: return Forbid(); + case RecordConcurrencyException conflict: return Problem(statusCode: StatusCodes.Status409Conflict, title: conflict.Message, type: "record_connector_conflict"); + case ArgumentException argument: return Problem(statusCode: StatusCodes.Status400BadRequest, title: argument.Message, type: "record_connector_validation"); + case InvalidOperationException invalid: return Problem(statusCode: StatusCodes.Status409Conflict, title: invalid.Message, type: "record_connector_state"); + default: throw ex; + } + } + + public static RecordDeploymentConnectorInput ToInput(RecordDeploymentConnectorInputData input) => new RecordDeploymentConnectorInput + { + ProviderKey = input.ProviderKey, Name = input.Name, SourceSystem = input.SourceSystem, SourceScheme = input.SourceScheme, ProfileKey = input.ProfileKey, BaseUrl = input.BaseUrl, + CredentialKind = input.CredentialKind ?? RmsConnectorCredentialKinds.None, CredentialHeaderName = input.CredentialHeaderName, Credential = input.Credential, ReadEnabled = input.ReadEnabled, + WriteEnabled = input.WriteEnabled, PollIntervalMinutes = input.PollIntervalMinutes, MaxRequestsPerHour = input.MaxRequestsPerHour, TermsReference = input.TermsReference + }; + + public static RecordDeploymentConnectorData ToData(RmsExternalOrderConnector c) => c == null ? null : new RecordDeploymentConnectorData + { + Id = c.RmsExternalOrderConnectorId, ProviderKey = c.ProviderKey, Name = c.Name, SourceSystem = c.SourceSystem, SourceScheme = c.SourceScheme, ProfileKey = c.ProfileKey, BaseUrl = c.BaseUrl, + CredentialKind = c.CredentialKind, CredentialHeaderName = c.CredentialHeaderName, HasCredential = !string.IsNullOrEmpty(c.CredentialCiphertext), HasInboundToken = !string.IsNullOrEmpty(c.InboundTokenHash), + ReadEnabled = c.ReadEnabled, WriteEnabled = c.WriteEnabled, PollIntervalMinutes = c.PollIntervalMinutes, MaxRequestsPerHour = c.MaxRequestsPerHour, RequestsThisHour = c.RequestsThisHour, + TermsReference = c.TermsReference, TermsAcknowledgedOn = c.TermsAcknowledgedOn, TermsAcknowledgedByUserId = c.TermsAcknowledgedByUserId, IsEnabled = c.IsEnabled, IsReadyToRun = c.IsReadyToRun, + LastPolledOn = c.LastPolledOn, LastSuccessOn = c.LastSuccessOn, LastError = c.LastError, ConsecutiveFailures = c.ConsecutiveFailures, CreatedOn = c.CreatedOn, ModifiedOn = c.ModifiedOn, RowVersion = c.RowVersion + }; + + public static RecordDeploymentConnectorRunData ToData(RmsExternalOrderConnectorRun r, System.Collections.Generic.List messages) => r == null ? null : new RecordDeploymentConnectorRunData + { + Id = r.RmsExternalOrderConnectorRunId, ConnectorId = r.RmsExternalOrderConnectorId, Trigger = r.Trigger, TriggeredByUserId = r.TriggeredByUserId, StartedOn = r.StartedOn, FinishedOn = r.FinishedOn, + Outcome = r.Outcome, Error = r.Error, RequestCount = r.RequestCount, OrdersSeen = r.OrdersSeen, OrdersCreated = r.OrdersCreated, SnapshotsRecorded = r.SnapshotsRecorded, RequestsAdded = r.RequestsAdded, + Unchanged = r.Unchanged, Rejected = r.Rejected, Conflicts = r.Conflicts, SourceVersion = r.SourceVersion, Messages = messages ?? new System.Collections.Generic.List() + }; + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentsController.cs index 8544971c..9d0672b4 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordDeploymentsController.cs @@ -41,15 +41,16 @@ public RecordDeploymentsController(IRecordDeploymentsService deployments, IRmsEx [HttpGet("List")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Record_View)] - public async Task> List(bool includeClosed = false) + public async Task> List(bool includeClosed = false, int take = 50) { if (!await FlagOnAsync()) return NotFound(); - var orders = await _deployments.ListAsync(DepartmentId, UserId, includeClosed); + // One bounded page, loaded in a single pass; the endpoint used to re-fetch the full aggregate per order. + var aggregates = await _deployments.ListAggregatesAsync(DepartmentId, UserId, includeClosed, take); var result = new RecordDeploymentsResult { Status = ResponseHelper.Success }; - foreach (var order in orders) + foreach (var aggregate in aggregates) { - var aggregate = await _deployments.GetAsync(DepartmentId, UserId, order.RmsExternalOrderId); - if (aggregate != null) result.Data.Add(RecordsRms1bApiMapper.ToDeployment(aggregate)); + var data = RecordsRms1bApiMapper.ToDeployment(aggregate); + if (data != null) result.Data.Add(data); } result.PageSize = result.Data.Count; ResponseHelper.PopulateV4ResponseData(result); @@ -67,7 +68,8 @@ public async Task> Get(string id) { var aggregate = await _deployments.GetAsync(DepartmentId, UserId, id); if (aggregate == null) return NotFound(); - return Ok(Wrap(aggregate)); + var wrapped = Wrap(aggregate); + return wrapped == null ? (ActionResult)NotFound() : Ok(wrapped); } catch (Exception ex) { return Fail(ex); } } @@ -83,7 +85,8 @@ public async Task> GetForRecord(string reco { var aggregate = await _deployments.GetForRecordAsync(DepartmentId, UserId, recordId); if (aggregate == null) return NotFound(); - return Ok(Wrap(aggregate)); + var wrapped = Wrap(aggregate); + return wrapped == null ? (ActionResult)NotFound() : Ok(wrapped); } catch (Exception ex) { return Fail(ex); } } @@ -104,7 +107,9 @@ public async Task> Create([FromBody] Create var created = RecordsRms1bApiMapper.ToCreateInput(input, origin); created.IdempotencyKey = RecordsApiHelper.ResolveIdempotencyKey(input.IdempotencyKey, Request); var aggregate = await _deployments.CreateFromExternalOrderAsync(DepartmentId, UserId, created, cancellationToken); - return StatusCode(StatusCodes.Status201Created, Wrap(aggregate, ResponseHelper.Created)); + var created201 = Wrap(aggregate, ResponseHelper.Created); + if (created201 == null) return NotFound(); + return StatusCode(StatusCodes.Status201Created, created201); } catch (Exception ex) { return Fail(ex); } } @@ -121,7 +126,8 @@ public async Task> AddFill(string id, [From try { await _deployments.AddFillAsync(DepartmentId, UserId, id, input, cancellationToken); - return Ok(Wrap(await _deployments.GetAsync(DepartmentId, UserId, id))); + var wrapped = Wrap(await _deployments.GetAsync(DepartmentId, UserId, id)); + return wrapped == null ? (ActionResult)NotFound() : Ok(wrapped); } catch (Exception ex) { return Fail(ex); } } @@ -139,8 +145,10 @@ public async Task> TransitionFill(string fi if (usable != null) return usable; try { + input.ExpectedRowVersion ??= RecordsApiContract.ParseETag(Request.Headers[RecordsApiContract.IfMatchHeader]); var fill = await _deployments.TransitionFillAsync(DepartmentId, UserId, fillId, input, cancellationToken); - return Ok(Wrap(await _deployments.GetAsync(DepartmentId, UserId, fill.RmsExternalOrderId))); + var wrapped = Wrap(await _deployments.GetAsync(DepartmentId, UserId, fill.RmsExternalOrderId)); + return wrapped == null ? (ActionResult)NotFound() : Ok(wrapped); } catch (Exception ex) { return Fail(ex); } } @@ -160,7 +168,8 @@ public async Task> Snapshot(string id, [Fro byte[] artifact; try { artifact = Convert.FromBase64String(input.ArtifactBase64); } catch (FormatException) { return Problem(statusCode: StatusCodes.Status400BadRequest, title: "ArtifactBase64 is not valid base64.", type: "record_deployment_validation"); } await _deployments.RecordSourceSnapshotAsync(DepartmentId, UserId, id, input.SourceVersion, artifact, input.ArtifactFileName, input.ArtifactContentType, cancellationToken); - return Ok(Wrap(await _deployments.GetAsync(DepartmentId, UserId, id))); + var wrapped = Wrap(await _deployments.GetAsync(DepartmentId, UserId, id)); + return wrapped == null ? (ActionResult)NotFound() : Ok(wrapped); } catch (Exception ex) { return Fail(ex); } } @@ -179,7 +188,8 @@ public async Task> Closeout(string id, [Fro try { await _deployments.CloseoutAsync(DepartmentId, UserId, id, rowVersion, input.Notes, cancellationToken); - return Ok(Wrap(await _deployments.GetAsync(DepartmentId, UserId, id))); + var wrapped = Wrap(await _deployments.GetAsync(DepartmentId, UserId, id)); + return wrapped == null ? (ActionResult)NotFound() : Ok(wrapped); } catch (Exception ex) { return Fail(ex); } } @@ -200,9 +210,12 @@ public async Task Artifact(string id) catch (Exception ex) { return Fail(ex); } } + /// Null when the re-fetch after a write came back empty; every caller turns that into a 404. private RecordDeploymentResult Wrap(RecordDeploymentAggregate aggregate, string status = ResponseHelper.Success) { - var result = new RecordDeploymentResult { Data = RecordsRms1bApiMapper.ToDeployment(aggregate), Status = status, PageSize = 1 }; + var data = RecordsRms1bApiMapper.ToDeployment(aggregate); + if (data == null) return null; + var result = new RecordDeploymentResult { Data = data, Status = status, PageSize = 1 }; ResponseHelper.PopulateV4ResponseData(result); Response.Headers[RecordsApiContract.ETagHeader] = result.Data.ETag; return result; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordSavedReportsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordSavedReportsController.cs index 9bfd68dd..f6da53c3 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordSavedReportsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordSavedReportsController.cs @@ -99,7 +99,8 @@ public async Task Delete(string id, CancellationToken cancellatio { var usable = await UsableAsync(); if (usable != null) return usable; - try { return await _reports.DeleteAsync(DepartmentId, UserId, id, cancellationToken) ? NoContent() : NotFound(); } + var expected = RecordsApiContract.ParseETag(Request.Headers[RecordsApiContract.IfMatchHeader]); + try { return await _reports.DeleteAsync(DepartmentId, UserId, id, expected, cancellationToken) ? NoContent() : NotFound(); } catch (Exception ex) { return Fail(ex); } } diff --git a/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs b/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs index 7c6ba210..6a49d5db 100644 --- a/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/RecordsApiHelper.cs @@ -264,7 +264,8 @@ public static RecordAttachmentData ToAttachment(RmsRecordAttachment a) return new RecordAttachmentData { AttachmentId = a.RmsRecordAttachmentId, RecordId = a.RecordId, FileName = a.FileName, ContentType = a.ContentType, ByteSize = a.ByteSize, Checksum = a.Checksum, Classification = a.Classification, - Description = a.Description, UploadedByUserId = a.UploadedByUserId, UploadedOn = a.UploadedOn, ScanState = a.ScanState, ScanStateName = ((RmsAttachmentScanState)a.ScanState).ToString() + Description = a.Description, UploadedByUserId = a.UploadedByUserId, UploadedOn = a.UploadedOn, ScanState = a.ScanState, ScanStateName = ((RmsAttachmentScanState)a.ScanState).ToString(), + MetadataStripped = a.MetadataStripped, MediaLocationRetained = a.MediaLocationRetained }; } @@ -310,7 +311,7 @@ public static RecordDraftInput ToDraftInput(SaveRecordDraftInput input, RmsOrigi { UnitId = u.UnitId, Dispatched = RecordsApiHelper.Utc(u.Dispatched), Enroute = RecordsApiHelper.Utc(u.Enroute), OnScene = RecordsApiHelper.Utc(u.OnScene), Released = RecordsApiHelper.Utc(u.Released), InQuarters = RecordsApiHelper.Utc(u.InQuarters) }).ToList(), - Values = RecordsRms1bApiMapper.ToValueInputs(input.Values), + Values = input.Values == null ? null : RecordsRms1bApiMapper.ToValueInputs(input.Values), ClientRecordId = input.ClientRecordId, IdempotencyKey = input.IdempotencyKey, OriginClient = origin, diff --git a/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs b/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs index 7c62d364..ae9d18d0 100644 --- a/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs +++ b/Web/Resgrid.Web.Services/Helpers/RecordsRms1bApiMapper.cs @@ -124,7 +124,7 @@ public static RecordTemplateRenderingData ToRendering(RecordTemplateRendering re { TemplateId = t.RmsExportTemplateId, TemplateKey = t.TemplateKey, Name = t.Name, Description = t.Description, Format = ((RmsExportFormat)t.Format).ToString(), Scope = ((RmsExportScope)t.Scope).ToString(), DefinitionKeys = (t.DefinitionKeysCsv ?? string.Empty).Split(',').Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).ToList(), - Columns = string.IsNullOrWhiteSpace(t.ColumnsJson) ? new List() : Newtonsoft.Json.JsonConvert.DeserializeObject>(t.ColumnsJson) ?? new List(), + Columns = RecordsExportService.ParseColumns(t.ColumnsJson), IncludeNarrative = t.IncludeNarrative, IncludeRestricted = t.IncludeRestricted, EgressAcknowledgedOn = t.EgressAcknowledgedOn, EgressAcknowledgedByUserId = t.EgressAcknowledgedByUserId, FileNameTemplate = t.FileNameTemplate, IncludeHeader = t.IncludeHeader, Delimiter = t.Delimiter == "\t" ? "tab" : t.Delimiter, ScheduleKind = ((RmsExportScheduleKind)t.ScheduleKind).ToString(), ScheduleHourLocal = t.ScheduleHourLocal, ScheduleDayOfWeek = t.ScheduleDayOfWeek, ScheduleDayOfMonth = t.ScheduleDayOfMonth, WindowDays = t.WindowDays, NextRunOn = t.NextRunOn, LastRunOn = t.LastRunOn, IsEnabled = t.IsEnabled, CreatedOn = t.CreatedOn, ModifiedOn = t.ModifiedOn, RowVersion = t.RowVersion, ETag = RecordsApiContract.ToETag(t.RowVersion) @@ -157,6 +157,9 @@ public static RecordTemplateRenderingData ToRendering(RecordTemplateRendering re public static RecordDeploymentData ToDeployment(RecordDeploymentAggregate aggregate) { + // A command endpoint re-fetches after the write, and that fetch can come back empty (deleted, or no longer + // visible). Returning null keeps the mapper from being the thing that turns it into a 500. + if (aggregate?.Order == null) return null; var o = aggregate.Order; var pack = RecordTemplateCatalog.PackOf(RecordDeploymentsService.DeploymentTemplateKey); return new RecordDeploymentData @@ -167,7 +170,7 @@ public static RecordDeploymentData ToDeployment(RecordDeploymentAggregate aggreg RequestingAgency = o.RequestingAgency, ReceivingAgency = o.ReceivingAgency, SendingAgency = o.SendingAgency, DepartmentRole = o.DepartmentRole, CostCode = o.CostCode, AgreementReference = o.AgreementReference, CurrencyCode = o.CurrencyCode, MeasurementSystem = o.MeasurementSystem, TimeZoneId = o.TimeZoneId, CapturedOffsetMinutes = o.CapturedOffsetMinutes, SourceCapturedOn = o.SourceCapturedOn, SourceVersion = o.SourceVersion, ArtifactFileName = o.ArtifactFileName, ArtifactContentType = o.ArtifactContentType, ArtifactChecksum = o.ArtifactChecksum, HasArtifact = o.ArtifactChecksum != null, ArtifactSafeUrl = o.ArtifactSafeUrl, - Status = ((RmsExternalOrderStatus)o.Status).ToString(), MobilizedOn = o.MobilizedOn, ReleasedOn = o.ReleasedOn, ClosedOutOn = o.ClosedOutOn, CloseoutNotes = o.CloseoutNotes, AllReturned = aggregate.AllReturned, + Status = ((RmsExternalOrderStatus)o.Status).ToString(), OwnershipMarker = string.IsNullOrEmpty(o.OwnershipMarker) ? RmsExternalOrderOwnership.Manual : o.OwnershipMarker, ConnectorId = o.ConnectorId, MobilizedOn = o.MobilizedOn, ReleasedOn = o.ReleasedOn, ClosedOutOn = o.ClosedOutOn, CloseoutNotes = o.CloseoutNotes, AllReturned = aggregate.AllReturned, IsPreview = pack?.IsPreview ?? true, ProvenanceStatement = "Preview: created from an external order snapshot; no claim that NWCG, CIFFC or a member agency accepts this output until a real order has been filled and reconciled.", CreatedOn = o.CreatedOn, ModifiedOn = o.ModifiedOn, RowVersion = o.RowVersion, ETag = RecordsApiContract.ToETag(o.RowVersion), Fills = aggregate.Fills.Select(ToFill).ToList() }; diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/FieldRecordsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/FieldRecordsApiModels.cs index 18120f83..f6561822 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Records/FieldRecordsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Records/FieldRecordsApiModels.cs @@ -185,6 +185,45 @@ public class FieldRecordAssignInput public int? OriginClient { get; set; } } + /// A batch of safe rollout datapoints from one app (RMS plan RMS-1D). Counts and codes only. + public class FieldRecordTelemetryInput + { + public int? OriginClient { get; set; } + public string AppVersion { get; set; } + public string ClientCapability { get; set; } + public List Events { get; set; } = new List(); + } + + public class FieldRecordTelemetryEventInput + { + /// One of RmsFieldRolloutEventTypes; anything else is dropped rather than stored. + public string EventType { get; set; } + /// "ok" or the coded refusal/conflict the client was given. + public string Outcome { get; set; } + public string DefinitionKey { get; set; } + public int? DefinitionVersion { get; set; } + public string RecordId { get; set; } + public long? DurationMs { get; set; } + public int? ItemCount { get; set; } + public DateTime? OccurredOn { get; set; } + } + + public class FieldRecordTelemetryResult : StandardApiResponseV4Base + { + public FieldRecordTelemetryData Data { get; set; } + } + + public class FieldRecordTelemetryData + { + /// How many events were kept after validation and truncation. + public int Accepted { get; set; } + } + + public class FieldRecordRolloutResult : StandardApiResponseV4Base + { + public RecordsFieldRollout Data { get; set; } + } + public class FieldRecordAssignmentCommandInput { public string AssignmentId { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordDeploymentConnectorsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordDeploymentConnectorsApiModels.cs new file mode 100644 index 00000000..5602ece9 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordDeploymentConnectorsApiModels.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Records +{ + /// + /// External ordering-system connector over v4 (RMS plan section 4.1). The stored credential and the inbound + /// token never leave the server: the connector reports only whether each is set. The inbound token is + /// returned exactly once, at creation or rotation. + /// + public class RecordDeploymentConnectorData + { + public string Id { get; set; } + public string ProviderKey { get; set; } + public string Name { get; set; } + public string SourceSystem { get; set; } + public string SourceScheme { get; set; } + public string ProfileKey { get; set; } + public string BaseUrl { get; set; } + public string CredentialKind { get; set; } + public string CredentialHeaderName { get; set; } + public bool HasCredential { get; set; } + public bool HasInboundToken { get; set; } + public bool ReadEnabled { get; set; } + /// Always false in this release; write authority to an external ordering system is refused. + public bool WriteEnabled { get; set; } + public int PollIntervalMinutes { get; set; } + public int MaxRequestsPerHour { get; set; } + public int RequestsThisHour { get; set; } + public string TermsReference { get; set; } + public DateTime? TermsAcknowledgedOn { get; set; } + public string TermsAcknowledgedByUserId { get; set; } + public bool IsEnabled { get; set; } + public bool IsReadyToRun { get; set; } + public DateTime? LastPolledOn { get; set; } + public DateTime? LastSuccessOn { get; set; } + public string LastError { get; set; } + public int ConsecutiveFailures { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public long RowVersion { get; set; } + } + + public class RecordDeploymentConnectorInputData + { + public string ProviderKey { get; set; } + public string Name { get; set; } + public string SourceSystem { get; set; } + public string SourceScheme { get; set; } + public string ProfileKey { get; set; } + public string BaseUrl { get; set; } + /// none, bearer or header. + public string CredentialKind { get; set; } + public string CredentialHeaderName { get; set; } + /// Plain credential; encrypted at rest and never returned. Blank on update keeps the stored one. + public string Credential { get; set; } + public bool ReadEnabled { get; set; } = true; + /// Must be false; true is refused. + public bool WriteEnabled { get; set; } + public int PollIntervalMinutes { get; set; } = 60; + public int MaxRequestsPerHour { get; set; } = 12; + /// Where the source's terms of use are recorded; required before acknowledgement. + public string TermsReference { get; set; } + /// Update only: the RowVersion the caller last saw. + public long RowVersion { get; set; } + } + + public class RecordDeploymentConnectorRunData + { + public string Id { get; set; } + public string ConnectorId { get; set; } + public string Trigger { get; set; } + public string TriggeredByUserId { get; set; } + public DateTime StartedOn { get; set; } + public DateTime? FinishedOn { get; set; } + public string Outcome { get; set; } + public string Error { get; set; } + public int RequestCount { get; set; } + public int OrdersSeen { get; set; } + public int OrdersCreated { get; set; } + public int SnapshotsRecorded { get; set; } + public int RequestsAdded { get; set; } + public int Unchanged { get; set; } + public int Rejected { get; set; } + public int Conflicts { get; set; } + public string SourceVersion { get; set; } + public List Messages { get; set; } = new List(); + } + + public class RecordDeploymentReconciliationData + { + public string ConnectorId { get; set; } + public string OrderId { get; set; } + public string RecordId { get; set; } + public string OrderNumber { get; set; } + public string RequestNumber { get; set; } + public string Kind { get; set; } + public string SourceStatus { get; set; } + public string LocalStatus { get; set; } + public string SourceVersion { get; set; } + public DateTime? SourceCapturedOn { get; set; } + } + + public class RecordDeploymentConnectorCreatedData + { + public RecordDeploymentConnectorData Connector { get; set; } + /// Shown once. The server keeps only a hash. + public string InboundToken { get; set; } + } + + public class RecordDeploymentConnectorsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordDeploymentConnectorResult : StandardApiResponseV4Base + { + public RecordDeploymentConnectorData Data { get; set; } + } + + public class RecordDeploymentConnectorCreatedResult : StandardApiResponseV4Base + { + public RecordDeploymentConnectorCreatedData Data { get; set; } + } + + public class RecordDeploymentConnectorTokenResult : StandardApiResponseV4Base + { + public RecordDeploymentConnectorCreatedData Data { get; set; } + } + + public class RecordDeploymentConnectorRunApiResult : StandardApiResponseV4Base + { + public RecordDeploymentConnectorRunData Data { get; set; } + } + + public class RecordDeploymentConnectorRunsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RecordDeploymentReconciliationResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs index f123243e..5472118b 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsApiModels.cs @@ -305,6 +305,12 @@ public class RecordAttachmentData public DateTime UploadedOn { get; set; } public int ScanState { get; set; } public string ScanStateName { get; set; } + + /// EXIF, XMP and IPTC were removed on upload; images are always re-encoded (RMS-1D media hygiene). + public bool MetadataStripped { get; set; } + + /// The definition's profile asked for the photo's coordinates and they survived; false on everything else. + public bool MediaLocationRetained { get; set; } } public class RecordRevisionData @@ -344,8 +350,12 @@ public class SaveRecordDraftInput public RecordDetailsInput Details { get; set; } = new RecordDetailsInput(); public List Participants { get; set; } = new List(); public List Units { get; set; } = new List(); - /// Typed values for a department definition (RMS-1B); ignored for locked system definitions. - public List Values { get; set; } = new List(); + /// + /// Typed values for a department definition (RMS-1B); ignored for locked system definitions. A posted list + /// replaces the stored values wholesale, so this stays null when the property is omitted: null is "no value + /// update", an empty list is an explicit clear. Do not give it a default. + /// + public List Values { get; set; } public string DuplicateContinueReason { get; set; } /// RmsOriginClient: 2 Responder, 3 Unit, 4 IncidentCommand, 5 Dispatch, 6 Api (default). Field clients are gated by their Records.Field.* flag. public int? OriginClient { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs index 8bb48535..2b338c7f 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsRms1bApiModels.cs @@ -547,6 +547,9 @@ public class RecordDeploymentData public bool HasArtifact { get; set; } public string ArtifactSafeUrl { get; set; } public string Status { get; set; } + /// manual (a person captured the order) or connector (an external ordering-system connector maintains its snapshots). + public string OwnershipMarker { get; set; } + public string ConnectorId { get; set; } public DateTime? MobilizedOn { get; set; } public DateTime? ReleasedOn { get; set; } public DateTime? ClosedOutOn { get; set; } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index f7fda8e8..dc165a99 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1668,6 +1668,16 @@ Assignments on one Record; empty when the caller cannot read that Record. + + + Safe rollout datapoints from this app (RMS plan RMS-1D): coded outcomes, counts and durations against the + authenticated department and member. Nothing here carries record content, and a report that names an + unknown event or a non-field origin is dropped rather than stored. + + + + The per-app rollout dashboard for this department (RMS plan RMS-1D). Department administration only. + The claimed origin, normalized: a non-field value is System, which every field gate refuses. @@ -2651,6 +2661,24 @@ Saves the definition-scope print layout; every save is a new layout version the footer names. + + + External ordering-system connectors for mutual-aid deployments (RMS plan section 4.1, RMS-1C). Department + administration only, enforced here and again in the service. A connector reads a documented feed under an + encrypted credential, an hourly request limit, and an acknowledgement of the source's terms; write authority + is refused. Import provisions unseen orders and records changed ones as new versioned snapshots; it never + transitions a local fill — disagreements are listed by Reconciliation for a person. + The Inbound action is the one anonymous endpoint: a source pushes a feed document with the connector's + one-time token in X-Resgrid-Connector-Token; the server holds only a hash of that token. + + + + + A source pushes a feed document. No user session: the connector id is in the query and the connector's + token in X-Resgrid-Connector-Token. A wrong token and an unknown connector are refused alike, and a + refused push leaves no run row, so the endpoint reveals nothing about what exists. + + Create Deployment from External Order over v4 (RMS plan section 4.1 external-order fill contract, RMS-1C, @@ -2664,6 +2692,9 @@ Records a later snapshot of the same external order; the previous artifact stays on record as a superseded reference. + + Null when the re-fetch after a write came back empty; every caller turns that into a 404. + Evidence capture over the v4 Records contract (RMS plan section 4.5, RMS-3): readiness, run-card activation, @@ -12398,6 +12429,18 @@ : 1 Person, 2 Unit, 3 Group, 4 CommandRole, 5 DispatchRole. + + A batch of safe rollout datapoints from one app (RMS plan RMS-1D). Counts and codes only. + + + One of RmsFieldRolloutEventTypes; anything else is dropped rather than stored. + + + "ok" or the coded refusal/conflict the client was given. + + + How many events were kept after validation and truncation. + The NERIS incident-analysis filing (RMS-3): the fire/hazmat investigation posted separately from the @@ -12484,6 +12527,34 @@ 409 on a stale incident report save. + + + External ordering-system connector over v4 (RMS plan section 4.1). The stored credential and the inbound + token never leave the server: the connector reports only whether each is set. The inbound token is + returned exactly once, at creation or rotation. + + + + Always false in this release; write authority to an external ordering system is refused. + + + none, bearer or header. + + + Plain credential; encrypted at rest and never returned. Blank on update keeps the stored one. + + + Must be false; true is refused. + + + Where the source's terms of use are recorded; required before acknowledgement. + + + Update only: the RowVersion the caller last saw. + + + Shown once. The server keeps only a hash. + Capability manifest for the Records module (RMS plan sections 5.4, 5.9.1). @@ -12536,6 +12607,12 @@ Department-definition typed values rendered against the pinned version (RMS-1B); null for locked system definitions. + + EXIF, XMP and IPTC were removed on upload; images are always re-encoded (RMS-1D media hygiene). + + + The definition's profile asked for the photo's coordinates and they survived; false on everything else. + Create or save a draft. Dates are UTC. Every list replaces the draft rows wholesale. @@ -12552,7 +12629,11 @@ Client-generated GUID for an offline-created draft; server-assigned when null. - Typed values for a department definition (RMS-1B); ignored for locked system definitions. + + Typed values for a department definition (RMS-1B); ignored for locked system definitions. A posted list + replaces the stored values wholesale, so this stays null when the property is omitted: null is "no value + update", an empty list is an explicit clear. Do not give it a default. + RmsOriginClient: 2 Responder, 3 Unit, 4 IncidentCommand, 5 Dispatch, 6 Api (default). Field clients are gated by their Records.Field.* flag. @@ -12621,6 +12702,9 @@ The stored packet run (download through RecordExportTemplates/DownloadRun); null for an assignment. + + manual (a person captured the order) or connector (an external ordering-system connector maintains its snapshots). + Base64 of the order artifact (PDF, image, export); at most 25 MB decoded. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentConnectorsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentConnectorsController.cs new file mode 100644 index 00000000..0008af9f --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentConnectorsController.cs @@ -0,0 +1,230 @@ +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.Config; +using Resgrid.Model; +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 +{ + /// + /// External ordering-system connectors for mutual-aid deployments (RMS plan section 4.1, RMS-1C). Department + /// administration only. A connector reads the documented Resgrid Mutual-Aid Order Feed under an encrypted + /// credential, an hourly request limit and an acknowledgement of the source's terms; write authority is + /// refused. The reconciliation table lists where the source and the department's own record disagree; it is + /// never applied automatically. + /// + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordDeploymentConnectorsController : SecureBaseController + { + private readonly IRecordDeploymentConnectorsService _connectors; + private readonly IRecordsCutoverService _cutover; + private readonly IDepartmentsService _departments; + private readonly IStringLocalizer _localizer; + + public RecordDeploymentConnectorsController(IRecordDeploymentConnectorsService connectors, IRecordsCutoverService cutover, IDepartmentsService departments, + IStringLocalizer localizer) + { + _connectors = connectors; + _cutover = cutover; + _departments = departments; + _localizer = localizer; + } + + [HttpGet] + public async Task Index() + { + var gate = await GateAsync(); + if (gate != null) return gate; + var model = new RecordDeploymentConnectorsIndexView + { + Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false), + ConnectorsEnabled = RecordsConnectorConfig.Enabled, + Connectors = await _connectors.ListAsync(DepartmentId, UserId), + Reconciliation = await _connectors.GetReconciliationAsync(DepartmentId, UserId) + }; + if (TempData["RecordsMessage"] is string message) model.Message = message; + if (TempData["RecordsError"] is string error) model.ErrorMessage = error; + if (TempData["ConnectorToken"] is string token) model.InboundToken = token; + return View(model); + } + + [HttpGet] + public async Task New() + { + var gate = await GateAsync(); + if (gate != null) return gate; + var model = new RecordDeploymentConnectorEditView { IsNew = true }; + Populate(model); + return View("Edit", model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task New(RecordDeploymentConnectorEditView model, CancellationToken cancellationToken) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + var created = await _connectors.CreateAsync(DepartmentId, UserId, model.ToInput(), cancellationToken); + TempData["RecordsMessage"] = _localizer["ConnectorCreated"].Value; + TempData["ConnectorToken"] = created.InboundToken; + return RedirectToAction("Edit", new { id = created.Connector.RmsExternalOrderConnectorId }); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { model.ErrorMessage = ex.Message; } + model.IsNew = true; + Populate(model); + return View("Edit", model); + } + + [HttpGet] + public async Task Edit(string id) + { + var gate = await GateAsync(); + if (gate != null) return gate; + var connector = await _connectors.GetAsync(DepartmentId, UserId, id); + if (connector == null) return NotFound(); + var model = RecordDeploymentConnectorEditView.From(connector); + await PopulateDetailsAsync(model, connector); + if (TempData["RecordsMessage"] is string message) model.Message = message; + if (TempData["RecordsError"] is string error) model.ErrorMessage = error; + if (TempData["ConnectorToken"] is string token) model.InboundToken = token; + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(string id, RecordDeploymentConnectorEditView model, CancellationToken cancellationToken) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + await _connectors.UpdateAsync(DepartmentId, UserId, id, model.RowVersion, model.ToInput(), cancellationToken); + TempData["RecordsMessage"] = _localizer["ConnectorSaved"].Value; + return RedirectToAction("Edit", new { id }); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (RecordConcurrencyException) { model.ErrorMessage = _localizer["ConcurrencyError"].Value; } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { model.ErrorMessage = ex.Message; } + var connector = await _connectors.GetAsync(DepartmentId, UserId, id); + if (connector == null) return NotFound(); + model.Id = id; + await PopulateDetailsAsync(model, connector); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public Task SetEnabled(string id, bool enabled, CancellationToken cancellationToken) + => ActAsync(id, enabled ? "ConnectorEnabledMessage" : "ConnectorDisabledMessage", () => _connectors.SetEnabledAsync(DepartmentId, UserId, id, enabled, cancellationToken)); + + [HttpPost] + [ValidateAntiForgeryToken] + public Task AcknowledgeTerms(string id, CancellationToken cancellationToken) + => ActAsync(id, "ConnectorTermsAcknowledgedMessage", () => _connectors.AcknowledgeTermsAsync(DepartmentId, UserId, id, cancellationToken)); + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RotateToken(string id, CancellationToken cancellationToken) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + TempData["ConnectorToken"] = await _connectors.RotateInboundTokenAsync(DepartmentId, UserId, id, cancellationToken); + TempData["RecordsMessage"] = _localizer["ConnectorTokenRotated"].Value; + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Edit", new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Run(string id, CancellationToken cancellationToken) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + var result = await _connectors.RunAsync(DepartmentId, UserId, id, cancellationToken); + var summary = $"{_localizer["ConnectorRunFinished"].Value} {result.Run.Outcome}: {result.Run.OrdersCreated}/{result.Run.SnapshotsRecorded}/{result.Run.RequestsAdded}/{result.Run.Conflicts}."; + if (result.Run.Outcome == RmsConnectorRunOutcomes.Ok) TempData["RecordsMessage"] = summary; + else TempData["RecordsError"] = summary + " " + string.Join(" ", result.Messages); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Edit", new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(string id, CancellationToken cancellationToken) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + await _connectors.DeleteAsync(DepartmentId, UserId, id, cancellationToken); + TempData["RecordsMessage"] = _localizer["ConnectorDeleted"].Value; + return RedirectToAction("Index"); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Edit", new { id }); + } + + private async Task ActAsync(string id, string messageKey, Func action) + { + var gate = await GateAsync(); + if (gate != null) return gate; + try + { + await action(); + TempData["RecordsMessage"] = _localizer[messageKey].Value; + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { TempData["RecordsError"] = ex.Message; } + return RedirectToAction("Edit", new { id }); + } + + private async Task GateAsync() + { + if (!(await _cutover.GetModuleStateAsync(DepartmentId)).FlagEnabled) return NotFound(); + return ClaimsAuthorizationHelper.IsUserDepartmentAdmin() ? null : Forbid(); + } + + private static void Populate(RecordDeploymentConnectorEditView model) + { + model.Providers = RmsExternalOrderConnectorProviders.All.Select(p => new SelectListItem { Value = p, Text = p }).ToList(); + model.Profiles = new List { new SelectListItem { Value = "", Text = "-" } }.Concat(RmsDeploymentProfiles.All.Select(p => new SelectListItem { Value = p, Text = p })).ToList(); + model.CredentialKinds = RmsConnectorCredentialKinds.All.Select(k => new SelectListItem { Value = k, Text = k }).ToList(); + model.ConnectorsEnabled = RecordsConnectorConfig.Enabled; + model.MinPollIntervalMinutes = RecordsConnectorConfig.MinPollIntervalMinutes; + } + + private async Task PopulateDetailsAsync(RecordDeploymentConnectorEditView model, RmsExternalOrderConnector connector) + { + Populate(model); + model.Connector = connector; + model.Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false); + model.Runs = await _connectors.GetRunsAsync(DepartmentId, UserId, connector.RmsExternalOrderConnectorId, 50); + model.Reconciliation = await _connectors.GetReconciliationAsync(DepartmentId, UserId, connector.RmsExternalOrderConnectorId); + var names = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); + model.PersonnelNames = names.GroupBy(n => n.UserId).ToDictionary(g => g.Key, g => g.First().Name); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs index 8a901e30..675d4b91 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs @@ -48,7 +48,7 @@ public RecordDeploymentsController(IRecordDeploymentsService deployments, IRecor public async Task Index(bool includeClosed = false) { if (!(await _cutover.GetModuleStateAsync(DepartmentId)).FlagEnabled) return NotFound(); - var model = new RecordDeploymentsIndexView { Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false), Orders = await _deployments.ListAsync(DepartmentId, UserId, includeClosed), IncludeClosed = includeClosed, CanCreate = ClaimsAuthorizationHelper.CanCreateRecord() }; + var model = new RecordDeploymentsIndexView { Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false), Orders = await _deployments.ListAsync(DepartmentId, UserId, includeClosed), IncludeClosed = includeClosed, CanCreate = ClaimsAuthorizationHelper.CanCreateRecord(), IsDepartmentAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() }; if (TempData["RecordsMessage"] is string message) model.Message = message; if (TempData["RecordsError"] is string error) model.ErrorMessage = error; return View(model); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs index 579ac736..d31caa90 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordSavedReportsController.cs @@ -106,7 +106,7 @@ public async Task Delete(string id, CancellationToken cancellatio if (!await CanManageAsync()) return Forbid(); try { - await _reports.DeleteAsync(DepartmentId, UserId, id, cancellationToken); + await _reports.DeleteAsync(DepartmentId, UserId, id, null, cancellationToken); TempData["RecordsMessage"] = _localizer["ReportDeleted"].Value; } catch (UnauthorizedAccessException) { return Forbid(); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index af6477a3..529b4c0f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -38,6 +38,7 @@ public class RecordsController : SecureBaseController { private readonly IRecordsService _recordsService; private readonly IRecordsBulkPacketService _bulk; + private readonly IRecordsFieldRolloutService _fieldRollout; private readonly IRecordsCutoverService _cutoverService; private readonly IRecordsAuthorizationService _recordsAuthorizationService; private readonly IRecordsUdfService _udf; @@ -70,8 +71,9 @@ public RecordsController(IRecordsService recordsService, IRecordsCutoverService ICompositeViewEngine viewEngine, IPdfProvider pdfProvider, IRecordsSearchService recordsSearch, IDepartmentDataProtectionService dataProtection, IDepartmentProfileMediaService branding, IRecordsPrintLayoutService printLayouts, IRecordsAccountabilityService accountability, IRecordsDashboardService dashboard, IRecordsUdfService udf, IRecordsProtectionService protection, IProtectedGrantContext grantContext, IRecordsRevealService reveal, IRecordDefinitionsService definitions, IRecordTypedValuesService typedValues, IContactsService contacts, - IRecordsBulkPacketService bulk) + IRecordsBulkPacketService bulk, IRecordsFieldRolloutService fieldRollout) { + _fieldRollout = fieldRollout; _bulk = bulk; _contacts = contacts; _reveal = reveal; @@ -1584,6 +1586,37 @@ private async Task EditErrorAsync(RecordEditView model, RecordAgg return View("EditDefinition", form); } + /// + /// Per-app Field Records rollout (RMS plan RMS-1D): who is on a compatible version, what the apps were + /// refused, and where authoring stopped. Counts only — this page never shows what anybody wrote. + /// + [HttpGet] + public async Task FieldRollout(int windowDays = 30, CancellationToken cancellationToken = default) + { + if (!await _recordsAuthorizationService.IsActiveMemberAsync(UserId, DepartmentId)) return Forbid(); + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) return NotFound(); + + var model = new RecordsFieldRolloutView + { + ModuleState = moduleState, + Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), + IsDepartmentAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(), + WindowDays = windowDays + }; + + try + { + model.Rollout = await _fieldRollout.GetAsync(DepartmentId, UserId, windowDays, cancellationToken); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + + return View(model); + } + private async Task PopulateBulkAsync(RecordsIndexView model) { model.CanBulkAssign = await _recordsAuthorizationService.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ReviewRecords); diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs index f5a8c134..b2470e16 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs @@ -63,6 +63,7 @@ public class RecordDefinitionEditView : RecordsBaseView public string NumberPrefix { get; set; } public int NumberAssignment { get; set; } = (int)RmsNumberAssignment.OnFinalize; public bool PerGroupSequence { get; set; } + public bool PerIncidentSequence { get; set; } public bool ResetYearly { get; set; } = true; public int SequenceWidth { get; set; } = 4; public int? RetentionYears { get; set; } @@ -73,6 +74,8 @@ public class RecordDefinitionEditView : RecordsBaseView public bool SurfaceDispatch { get; set; } public bool AllowOffline { get; set; } public bool AllowAttachments { get; set; } = true; + /// Media capture hygiene (RMS-1D): keep photo coordinates on this definition's attachments. + public bool RetainMediaLocation { get; set; } public string SchemaJson { get; set; } public string MigrationMapJson { get; set; } public string ChangeNotes { get; set; } @@ -104,10 +107,10 @@ public RecordDefinitionDraftInput ToDraftInput() Name = Name, Category = Category, Description = Description, PermittedSubjectTypes = PermittedSubjectTypes, LifecyclePreset = (RmsLifecyclePreset)LifecyclePreset, ReviewerRoleIds = ReviewerRoleIds ?? new List(), ApproverRoleIds = ApproverRoleIds ?? new List(), ReviewDueHours = ReviewDueHours, ApproveDueHours = ApproveDueHours, RequireAuthorAttestation = RequireAuthorAttestation, - Numbering = new RecordDefinitionNumbering { Prefix = NumberPrefix?.Trim().ToUpperInvariant(), Assignment = (RmsNumberAssignment)NumberAssignment, PerGroupSequence = PerGroupSequence, ResetYearly = ResetYearly, SequenceWidth = SequenceWidth }, + Numbering = new RecordDefinitionNumbering { Prefix = NumberPrefix?.Trim().ToUpperInvariant(), Assignment = (RmsNumberAssignment)NumberAssignment, PerGroupSequence = PerGroupSequence, PerIncidentSequence = PerIncidentSequence, ResetYearly = ResetYearly, SequenceWidth = SequenceWidth }, RetentionYears = RetentionYears, Classification = (RmsFieldClassification)Classification, Schema = RecordDefinitionSchema.Parse(SchemaJson), - ClientSurface = new RecordDefinitionClientSurface { Responder = SurfaceResponder, Unit = SurfaceUnit, IncidentCommand = SurfaceIncidentCommand, Dispatch = SurfaceDispatch, AllowOffline = AllowOffline, AllowAttachments = AllowAttachments }, + ClientSurface = new RecordDefinitionClientSurface { Responder = SurfaceResponder, Unit = SurfaceUnit, IncidentCommand = SurfaceIncidentCommand, Dispatch = SurfaceDispatch, AllowOffline = AllowOffline, AllowAttachments = AllowAttachments, RetainMediaLocation = RetainMediaLocation }, MigrationMap = string.IsNullOrWhiteSpace(MigrationMapJson) ? new List() : Newtonsoft.Json.JsonConvert.DeserializeObject>(MigrationMapJson) ?? new List(), ChangeNotes = ChangeNotes }; @@ -122,9 +125,9 @@ public static RecordDefinitionEditView From(RecordDefinitionAggregate aggregate, Name = aggregate.Definition.Name, Category = aggregate.Definition.Category, Description = aggregate.Definition.Description, PermittedSubjectTypes = aggregate.Definition.PermittedSubjectTypes, LifecyclePreset = version.LifecyclePreset, ReviewerRoleIds = Resgrid.Services.Records.RecordDefinitionsService.ParseIds(version.ReviewerRoleIds), ApproverRoleIds = Resgrid.Services.Records.RecordDefinitionsService.ParseIds(version.ApproverRoleIds), ReviewDueHours = version.ReviewDueHours, ApproveDueHours = version.ApproveDueHours, RequireAuthorAttestation = version.RequireAuthorAttestation, - NumberPrefix = numbering.Prefix, NumberAssignment = (int)numbering.Assignment, PerGroupSequence = numbering.PerGroupSequence, ResetYearly = numbering.ResetYearly, SequenceWidth = numbering.SequenceWidth, + NumberPrefix = numbering.Prefix, NumberAssignment = (int)numbering.Assignment, PerGroupSequence = numbering.PerGroupSequence, PerIncidentSequence = numbering.PerIncidentSequence, ResetYearly = numbering.ResetYearly, SequenceWidth = numbering.SequenceWidth, RetentionYears = version.RetentionYears, Classification = version.Classification, - SurfaceResponder = surface.Responder, SurfaceUnit = surface.Unit, SurfaceIncidentCommand = surface.IncidentCommand, SurfaceDispatch = surface.Dispatch, AllowOffline = surface.AllowOffline, AllowAttachments = surface.AllowAttachments, + SurfaceResponder = surface.Responder, SurfaceUnit = surface.Unit, SurfaceIncidentCommand = surface.IncidentCommand, SurfaceDispatch = surface.Dispatch, AllowOffline = surface.AllowOffline, AllowAttachments = surface.AllowAttachments, RetainMediaLocation = surface.RetainMediaLocation, SchemaJson = Newtonsoft.Json.JsonConvert.SerializeObject(version.Schema, Newtonsoft.Json.Formatting.Indented, new Newtonsoft.Json.JsonSerializerSettings { NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore, DefaultValueHandling = Newtonsoft.Json.DefaultValueHandling.Ignore }), MigrationMapJson = version.MigrationMapJson, ChangeNotes = version.ChangeNotes, Schema = version.Schema, IsPublished = version.IsPublished, MinimumClientCapability = version.MinimumClientCapability ?? RecordsClientCapabilities.Derive(version.Schema) @@ -311,6 +314,7 @@ public class RecordDeploymentsIndexView : RecordsBaseView public List Orders { get; set; } = new List(); public bool IncludeClosed { get; set; } public bool CanCreate { get; set; } + public bool IsDepartmentAdmin { get; set; } } public class RecordDeploymentNewView : RecordsBaseView @@ -427,4 +431,61 @@ public List RowKeys(string sectionKey) /// Whether a stored restricted cell is withheld for this viewer (the input renders disabled and blank). public bool IsWithheld(string fieldKey) => !CanViewRestricted && Schema.FindField(fieldKey)?.Classification == RmsFieldClassification.Restricted; } + + /// External ordering-system connectors (RMS plan section 4.1): the department's connectors and the open reconciliation across them. + public class RecordDeploymentConnectorsIndexView : RecordsBaseView + { + public Department Department { get; set; } + public bool ConnectorsEnabled { get; set; } + public List Connectors { get; set; } = new List(); + public List Reconciliation { get; set; } = new List(); + public string InboundToken { get; set; } + } + + /// One connector: the editable input, and when it exists, its state, run log, reconciliation and the one-time inbound token. + public class RecordDeploymentConnectorEditView : RecordsBaseView + { + public bool IsNew { get; set; } + public string Id { get; set; } + public long RowVersion { get; set; } + public string ProviderKey { get; set; } = RmsExternalOrderConnectorProviders.Generic; + public string Name { get; set; } + public string SourceSystem { get; set; } + public string SourceScheme { get; set; } + public string ProfileKey { get; set; } + public string BaseUrl { get; set; } + public string CredentialKind { get; set; } = RmsConnectorCredentialKinds.None; + public string CredentialHeaderName { get; set; } + public string Credential { get; set; } + public bool ReadEnabled { get; set; } = true; + public int PollIntervalMinutes { get; set; } = 60; + public int MaxRequestsPerHour { get; set; } = 12; + public string TermsReference { get; set; } + + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public RmsExternalOrderConnector Connector { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public Department Department { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public bool ConnectorsEnabled { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public int MinPollIntervalMinutes { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public string InboundToken { get; set; } + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public List Providers { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public List Profiles { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public List CredentialKinds { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public List Runs { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public List Reconciliation { get; set; } = new List(); + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] public Dictionary PersonnelNames { get; set; } = new Dictionary(); + + public RecordDeploymentConnectorInput ToInput() => new RecordDeploymentConnectorInput + { + ProviderKey = ProviderKey, Name = Name, SourceSystem = SourceSystem, SourceScheme = SourceScheme, ProfileKey = ProfileKey, BaseUrl = BaseUrl, CredentialKind = CredentialKind, + CredentialHeaderName = CredentialHeaderName, Credential = Credential, ReadEnabled = ReadEnabled, WriteEnabled = false, PollIntervalMinutes = PollIntervalMinutes, + MaxRequestsPerHour = MaxRequestsPerHour, TermsReference = TermsReference + }; + + public static RecordDeploymentConnectorEditView From(RmsExternalOrderConnector c) => new RecordDeploymentConnectorEditView + { + Id = c.RmsExternalOrderConnectorId, RowVersion = c.RowVersion, ProviderKey = c.ProviderKey, Name = c.Name, SourceSystem = c.SourceSystem, SourceScheme = c.SourceScheme, ProfileKey = c.ProfileKey, + BaseUrl = c.BaseUrl, CredentialKind = c.CredentialKind, CredentialHeaderName = c.CredentialHeaderName, ReadEnabled = c.ReadEnabled, PollIntervalMinutes = c.PollIntervalMinutes, + MaxRequestsPerHour = c.MaxRequestsPerHour, TermsReference = c.TermsReference, Connector = c + }; + } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs index de647abf..cd131a27 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs @@ -72,6 +72,16 @@ public class RecordPrintProvenance } /// Records work queue / list (RMS plan section 4.1, unified queue). + /// Per-app Field Records rollout (RMS plan RMS-1D): adoption, outcomes and where people gave up. + public class RecordsFieldRolloutView : RecordsBaseView + { + public RecordsModuleState ModuleState { get; set; } + public Department Department { get; set; } + public bool IsDepartmentAdmin { get; set; } + public RecordsFieldRollout Rollout { get; set; } + public int WindowDays { get; set; } = 30; + } + public class RecordsIndexView : RecordsBaseView { public RecordsModuleState ModuleState { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml index bb17a61a..30cb12bb 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml @@ -74,7 +74,7 @@

@localizer["DefinitionNumbering"]

-
+

@localizer["DefinitionClassification"]

@localizer["DefinitionClientSurface"]

@@ -86,6 +86,10 @@ +
+ +

@localizer["DefinitionRetainMediaLocationHelp"]

+

@localizer["DefinitionCapability"]: @Model.MinimumClientCapability

diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDeploymentConnectors/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDeploymentConnectors/Edit.cshtml new file mode 100644 index 00000000..4fe9b545 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDeploymentConnectors/Edit.cshtml @@ -0,0 +1,165 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@model Resgrid.Web.Areas.User.Models.Records.RecordDeploymentConnectorEditView +@inject IStringLocalizer localizer +@{ + var c = Model.Connector; + var title = Model.IsNew ? localizer["NewConnector"].Value : (c?.Name ?? Model.Name); + ViewBag.Title = "Resgrid | " + title; + string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; + string Who(string userId) => string.IsNullOrEmpty(userId) ? "-" : (Model.PersonnelNames.TryGetValue(userId, out var n) ? n : userId); + string Kind(string kind) + { + switch (kind) + { + case RecordDeploymentReconciliationItem.SourceReleasedLocalOut: return localizer["ReconciliationSourceReleased"]; + case RecordDeploymentReconciliationItem.SourceClosedLocalOpen: return localizer["ReconciliationSourceClosed"]; + case RecordDeploymentReconciliationItem.SourceRequestMissingLocally: return localizer["ReconciliationSourceRequestMissing"]; + case RecordDeploymentReconciliationItem.LocalFillMissingInSource: return localizer["ReconciliationLocalFillMissing"]; + case RecordDeploymentReconciliationItem.SourceStatusAhead: return localizer["ReconciliationSourceAhead"]; + case RecordDeploymentReconciliationItem.SourceStatusBehind: return localizer["ReconciliationSourceBehind"]; + default: return kind; + } + } + var inboundUrl = c == null ? null : $"/api/v4/RecordDeploymentConnectors/Inbound?connectorId={c.RmsExternalOrderConnectorId}"; +} + +
+
+

@title @localizer["PreviewPack"] + @if (c != null) + { + if (c.IsEnabled) { @localizer["ConnectorEnabled"] } else { @localizer["ConnectorDisabled"] } + if (c.IsReadyToRun) { @localizer["ConnectorReady"] } else { @localizer["ConnectorNotReady"] } + } +

+ +
+ @if (c != null) + { +
+
+
@Html.AntiForgeryToken()
+ @if (c.IsEnabled) + { +
@Html.AntiForgeryToken()
+ } + else + { +
@Html.AntiForgeryToken()
+ } +
+
+ } +
+ +
+ @if (!string.IsNullOrEmpty(Model.Message)) {
@Model.Message
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} + @if (!Model.ConnectorsEnabled) {
@localizer["ConnectorsOff"]
} + @if (!string.IsNullOrEmpty(Model.InboundToken)) + { +
+ @localizer["ConnectorInboundToken"] @Model.InboundToken
+ @localizer["ConnectorInboundTokenOnce"] +
+ } +
+
+
+
@(Model.IsNew ? localizer["NewConnector"] : localizer["ConnectorName"])
+
+
+ @Html.AntiForgeryToken() + +
+
+
+
+
@localizer["ConnectorFeedContract"]
+
+
@localizer["ConnectorCredentialHelp"] @if (c != null && !string.IsNullOrEmpty(c.CredentialCiphertext)) { @localizer["ConnectorCredentialStored"] }
+
+
+

+
@commonLocalizer["Cancel"]
+
+
+
+
+ @if (c != null) + { +
+
+
@localizer["ConnectorState"]
+
+
+
@localizer["ConnectorTermsReference"]
@if (c.TermsAcknowledgedOn.HasValue) { @localizer["ConnectorTermsAcknowledged"] @When(c.TermsAcknowledgedOn) · @Who(c.TermsAcknowledgedByUserId) } else { @localizer["ConnectorTermsNotAcknowledged"] }
+
@localizer["ConnectorLastPolled"]
@When(c.LastPolledOn)
+
@localizer["ConnectorLastSuccess"]
@When(c.LastSuccessOn)
+
@localizer["ConnectorLastError"]
@c.LastError @if (c.ConsecutiveFailures > 0) { (@c.ConsecutiveFailures) }
+
@localizer["ConnectorMaxRequestsPerHour"]
@c.RequestsThisHour / @c.MaxRequestsPerHour
+
@localizer["ConnectorInboundEndpoint"]
@inboundUrl
X-Resgrid-Connector-Token
+
+ @if (!c.TermsAcknowledgedOn.HasValue) + { +
@Html.AntiForgeryToken()
+ } +
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
+
+
+
+
@localizer["Reconciliation"]
+
+ @if (Model.Reconciliation.Count == 0) + { +

@localizer["ReconciliationNone"]

+ } + else + { + + + + @foreach (var item in Model.Reconciliation) + { + + } + +
@localizer["DeploymentOrderNumber"]@localizer["DeploymentRequestNumber"]@localizer["ReconciliationKind"]@localizer["ReconciliationSourceStatus"]@localizer["ReconciliationLocalStatus"]
@item.OrderNumber@item.RequestNumber@Kind(item.Kind)@item.SourceStatus@item.LocalStatus
+ } +
+
+
+ } +
+ @if (c != null) + { +
+
@localizer["ConnectorRuns"]
+
+ + + + @foreach (var r in Model.Runs) + { + + + + + + + + + } + +
@localizer["Created"]@localizer["ConnectorRunTrigger"]@localizer["ConnectorRunOutcome"]@localizer["ConnectorRunCounts"]@localizer["DeploymentSourceVersion"]@localizer["ConnectorLastError"]
@When(r.StartedOn) @Who(r.TriggeredByUserId)@r.Trigger@r.Outcome@r.OrdersSeen / @r.OrdersCreated / @r.SnapshotsRecorded / @r.RequestsAdded / @r.Unchanged / @r.Rejected / @r.Conflicts (@r.RequestCount)@r.SourceVersion@r.Error
+
+
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDeploymentConnectors/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDeploymentConnectors/Index.cshtml new file mode 100644 index 00000000..177b6d6e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDeploymentConnectors/Index.cshtml @@ -0,0 +1,100 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@model Resgrid.Web.Areas.User.Models.Records.RecordDeploymentConnectorsIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Connectors"]; + string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; + string Kind(string kind) + { + switch (kind) + { + case RecordDeploymentReconciliationItem.SourceReleasedLocalOut: return localizer["ReconciliationSourceReleased"]; + case RecordDeploymentReconciliationItem.SourceClosedLocalOpen: return localizer["ReconciliationSourceClosed"]; + case RecordDeploymentReconciliationItem.SourceRequestMissingLocally: return localizer["ReconciliationSourceRequestMissing"]; + case RecordDeploymentReconciliationItem.LocalFillMissingInSource: return localizer["ReconciliationLocalFillMissing"]; + case RecordDeploymentReconciliationItem.SourceStatusAhead: return localizer["ReconciliationSourceAhead"]; + case RecordDeploymentReconciliationItem.SourceStatusBehind: return localizer["ReconciliationSourceBehind"]; + default: return kind; + } + } +} + +
+
+

@localizer["Connectors"] @localizer["PreviewPack"]

+ +
+
+
+ @if (Model.ConnectorsEnabled) { @localizer["NewConnector"] } +
+
+
+ +
+ @if (!string.IsNullOrEmpty(Model.Message)) {
@Model.Message
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} + @if (!Model.ConnectorsEnabled) {
@localizer["ConnectorsOff"]
} +
+
+

@localizer["ConnectorsIntro"]

+

@localizer["ConnectorWriteRefused"]

+ + + + @foreach (var c in Model.Connectors) + { + + + + + + + + + + } + +
@localizer["ConnectorName"]@localizer["ConnectorProvider"]@localizer["ConnectorBaseUrl"]@localizer["ConnectorState"]@localizer["ConnectorLastPolled"]@localizer["ConnectorLastSuccess"]@localizer["ConnectorLastError"]
@c.Name@c.ProviderKey @c.SourceScheme / @c.ProfileKey@c.BaseUrl + @if (c.IsEnabled) { @localizer["ConnectorEnabled"] } else { @localizer["ConnectorDisabled"] } + @if (c.IsReadyToRun) { @localizer["ConnectorReady"] } else { @localizer["ConnectorNotReady"] } + @When(c.LastPolledOn)@When(c.LastSuccessOn)@c.LastError
+
+
+ +
+
@localizer["Reconciliation"]
+
+

@localizer["ReconciliationIntro"]

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

@localizer["ReconciliationNone"]

+ } + else + { + + + + @foreach (var item in Model.Reconciliation) + { + + + + + + + + + } + +
@localizer["DeploymentOrderNumber"]@localizer["DeploymentRequestNumber"]@localizer["ReconciliationKind"]@localizer["ReconciliationSourceStatus"]@localizer["ReconciliationLocalStatus"]@localizer["DeploymentSourceVersion"]
@item.OrderNumber@item.RequestNumber@Kind(item.Kind)@item.SourceStatus@item.LocalStatus@item.SourceVersion @When(item.SourceCapturedOn)
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Index.cshtml index 8e6016de..d1956e1d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Index.cshtml @@ -17,6 +17,7 @@
+ @if (Model.IsDepartmentAdmin) { @localizer["Connectors"] } @if (Model.CanCreate) { @localizer["NewDeployment"] }
@@ -37,7 +38,7 @@ @o.OrderNumber @o.IncidentName @o.IncidentNumber - @o.ProfileKey + @o.ProfileKey @if (o.OwnershipMarker == RmsExternalOrderOwnership.Connector) { } @o.SourceScheme@(string.IsNullOrEmpty(o.SourceSystem) ? "" : " / " + o.SourceSystem) @if (!string.IsNullOrEmpty(o.SourceVersion)) { v@(o.SourceVersion) } @((RmsExternalOrderStatus)o.Status) @o.CreatedOn.TimeConverterToString(Model.Department) diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/EditDefinition.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/EditDefinition.cshtml index 4d88532d..d37fd811 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/EditDefinition.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/EditDefinition.cshtml @@ -14,7 +14,9 @@ GrantExpiresOnUtc = Model.ProtectedGrantExpiresOnUtc, BindForms = new List { "#record-edit-form" } }; - var schemaJson = Newtonsoft.Json.JsonConvert.SerializeObject(Model.Schema, new Newtonsoft.Json.JsonSerializerSettings { NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore, ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() }); + // Written unencoded into a JSON script block below: escape the HTML-sensitive characters so authored schema text + // containing "" cannot close the element early. + var schemaJson = Newtonsoft.Json.JsonConvert.SerializeObject(Model.Schema, new Newtonsoft.Json.JsonSerializerSettings { NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore, ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(), StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml }); }
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/FieldRollout.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/FieldRollout.cshtml new file mode 100644 index 00000000..67f11e33 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Records/FieldRollout.cshtml @@ -0,0 +1,185 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Records.RecordsFieldRolloutView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["FieldRollout"]; +} +@functions { + static string Percent(int part, int whole) => whole <= 0 ? "-" : ((int)System.Math.Round(part * 100d / whole)) + "%"; + + static string Minutes(long? milliseconds) => milliseconds.HasValue ? System.Math.Round(milliseconds.Value / 60000d, 1).ToString("0.0") : "-"; +} + +
+
+

@localizer["FieldRollout"]

+ +
+
+
+ @foreach (var days in new[] { 7, 30, 90 }) + { + @string.Format(localizer["RolloutWindowDays"].Value, days) + } +
+
+
+ +
+

@localizer["FieldRolloutIntro"]

+ + @if (Model.Rollout == null) + { +
@localizer["RolloutUnavailable"]
+ } + else + { + @if (!Model.Rollout.AnyAppEnabled) + { +
@localizer["RolloutNoAppsEnabled"]
+ } + +
+ @foreach (var app in Model.Rollout.Apps) + { + var enabled = Model.Rollout.AppFlags.TryGetValue(app.OriginClient, out var on) && on; + var minimum = Model.Rollout.MinimumAppVersions.TryGetValue(app.OriginClient, out var min) ? min : string.Empty; +
+
+
+
@localizer["App" + app.OriginClient]
+ @(enabled ? localizer["RolloutFlagOn"] : localizer["RolloutFlagOff"]) +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@localizer["RolloutActiveUsers"]@app.ActiveUsers
@localizer["RolloutCompatible"] + @app.CompatibleUsers / @app.ActiveUsers (@Percent(app.CompatibleUsers, app.ActiveUsers)) + @if (!string.IsNullOrWhiteSpace(minimum)) + { + @string.Format(localizer["RolloutMinimumVersion"].Value, minimum) + } +
@localizer["RolloutRecords"]@string.Format(localizer["RolloutRecordsValue"].Value, app.RecordsCreated, app.RecordsFinalized)
@localizer["RolloutCatalog"] + @app.CatalogRequests + @if (app.CatalogFailures > 0) + { + @string.Format(localizer["RolloutFailures"].Value, app.CatalogFailures) + } +
@localizer["RolloutAuthoring"]@string.Format(localizer["RolloutAuthoringValue"].Value, app.DraftsStarted, app.DraftsSaved, app.DraftSaveFailures)
@localizer["RolloutSync"] + @app.Syncs + @if (app.SyncFailures > 0) + { + @string.Format(localizer["RolloutFailures"].Value, app.SyncFailures) + } +
@localizer["RolloutConflicts"] + @app.Conflicts + @foreach (var kind in app.ConflictKinds.OrderByDescending(k => k.Value)) + { + @kind.Key: @kind.Value + } +
@localizer["RolloutAttachments"]@string.Format(localizer["RolloutAttachmentsValue"].Value, app.AttachmentsUploaded, app.AttachmentFailures)
@localizer["RolloutCompleted"]@string.Format(localizer["RolloutCompletedValue"].Value, app.Completed, Minutes(app.MedianTimeToCompleteMs))
@localizer["RolloutAbandoned"] + @app.Abandoned + @if (app.AbandonmentRate.HasValue) + { + (@((int)System.Math.Round(app.AbandonmentRate.Value * 100))%) + } +
@localizer["RolloutWebHandoff"]@app.WebHandoffs
+ + @if (app.Versions.Count > 0) + { +
@localizer["RolloutVersions"]
+ + + + + + + + + + @foreach (var version in app.Versions.Take(8)) + { + var compatible = FieldRecordCatalogV1.MeetsMinimum(version.AppVersion, minimum); + + + + + + } + +
@localizer["RolloutVersion"]@localizer["RolloutUsers"]
@version.AppVersion@version.Users + @if (!compatible) + { + @localizer["RolloutTooOld"] + } +
+ } + + @if (app.CatalogFailureReasons.Count > 0) + { +
@localizer["RolloutRefusals"]
+
    + @foreach (var reason in app.CatalogFailureReasons.OrderByDescending(r => r.Value)) + { +
  • @reason.Key × @reason.Value
  • + } +
+ } +
+
+
+ } +
+ +

@string.Format(localizer["RolloutGeneratedOn"].Value, Model.Rollout.GeneratedOn.ToString("u"))

+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml index fee2ac77..b99deb65 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml @@ -72,6 +72,7 @@ } @if (Model.IsDepartmentAdmin) { + @localizer["FieldRollout"] @localizer["Settings"] }
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml index 0a6bc94d..ead1d84f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/_DefinitionFields.cshtml @@ -77,7 +77,11 @@ string Reference = template ? string.Empty : InputReference(section.Key, rowKey, field.Key); string Unit = template ? field.DefaultUnit : (InputUnit(section.Key, rowKey, field.Key) ?? field.DefaultUnit); string Currency = template ? field.DefaultCurrency : (InputCurrency(section.Key, rowKey, field.Key) ?? field.DefaultCurrency); - var values = template ? new List() : InputValues(section.Key, rowKey, field.Key); + var values = template || withheld ? new List() : InputValues(section.Key, rowKey, field.Key); + // The draft is hydrated with canViewRestricted:true so the author's own save can round-trip it, so a withheld + // field still arrives here carrying its plaintext. Blank it once, up front, rather than per input type: + // "disabled" hides nothing from anyone reading the markup. + if (withheld) { Value = string.Empty; Reference = string.Empty; } sb.Append("
"); sb.Append("